mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
multi-node support
This commit is contained in:
parent
3f15d21f13
commit
d7175e7803
45 changed files with 3369 additions and 162 deletions
84
CHANGES_SUMMARY.md
Normal file
84
CHANGES_SUMMARY.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Сводка изменений для Multi-Node архитектуры
|
||||||
|
|
||||||
|
## ✅ Реализованные компоненты
|
||||||
|
|
||||||
|
### 1. Node-сервис (Worker)
|
||||||
|
- ✅ `node/main.go` - точка входа
|
||||||
|
- ✅ `node/api/server.go` - REST API сервер
|
||||||
|
- ✅ `node/xray/manager.go` - управление XRAY процессом
|
||||||
|
- ✅ `node/Dockerfile` - Docker образ
|
||||||
|
- ✅ `node/docker-compose.yml` - конфигурация Docker Compose
|
||||||
|
- ✅ `node/README.md` - документация
|
||||||
|
|
||||||
|
### 2. Модели базы данных
|
||||||
|
- ✅ `database/model/model.go` - добавлены:
|
||||||
|
- `Node` - модель ноды
|
||||||
|
- `InboundNodeMapping` - соответствие inbound → node
|
||||||
|
- ✅ `database/db.go` - добавлены модели в миграцию
|
||||||
|
|
||||||
|
### 3. Сервисы панели
|
||||||
|
- ✅ `web/service/setting.go` - добавлены методы:
|
||||||
|
- `GetMultiNodeMode()` - получить режим работы
|
||||||
|
- `SetMultiNodeMode(enabled)` - установить режим работы
|
||||||
|
- ✅ `web/service/node.go` - новый сервис для управления нодами
|
||||||
|
- ✅ `web/service/xray.go` - модифицирован для поддержки multi-mode:
|
||||||
|
- Проверка режима работы
|
||||||
|
- В multi-mode: отправка конфигураций на ноды
|
||||||
|
- В single-mode: работает как раньше
|
||||||
|
- ✅ `sub/subService.go` - обновлён для генерации подписок с endpoint нод
|
||||||
|
|
||||||
|
### 4. Контроллеры
|
||||||
|
- ✅ `web/controller/node.go` - новый контроллер для управления нодами
|
||||||
|
- ✅ `web/controller/xui.go` - добавлен маршрут `/nodes` и NodeController
|
||||||
|
|
||||||
|
## 🔄 Логика работы
|
||||||
|
|
||||||
|
### Single-Mode (по умолчанию)
|
||||||
|
1. Все работает как раньше
|
||||||
|
2. Локальный XRAY Core используется
|
||||||
|
3. Подписки генерируются с endpoint панели
|
||||||
|
|
||||||
|
### Multi-Mode
|
||||||
|
1. Включение через настройки панели
|
||||||
|
2. Добавление нод через UI
|
||||||
|
3. Назначение инбаундов нодам
|
||||||
|
4. Конфигурации отправляются на ноды через REST API
|
||||||
|
5. Подписки генерируются с endpoint нод
|
||||||
|
|
||||||
|
## 📝 Файлы для изменения в UI
|
||||||
|
|
||||||
|
Для полной реализации потребуется обновить фронтенд:
|
||||||
|
|
||||||
|
1. **Настройки панели** (`web/html/settings/...`):
|
||||||
|
- Добавить тумблер "Multi-Node Mode"
|
||||||
|
|
||||||
|
2. **Новая страница Nodes** (`web/html/nodes.html`):
|
||||||
|
- Список нод
|
||||||
|
- Добавление/редактирование/удаление нод
|
||||||
|
- Проверка здоровья нод
|
||||||
|
|
||||||
|
3. **Страница Inbounds** (`web/html/inbounds.html`):
|
||||||
|
- Выпадающий список выбора ноды (только в multi-mode)
|
||||||
|
|
||||||
|
4. **Переводы** (`web/translation/...`):
|
||||||
|
- Добавить переводы для новых элементов UI
|
||||||
|
|
||||||
|
## 🚀 Следующие шаги
|
||||||
|
|
||||||
|
1. Обновить фронтенд для управления нодами
|
||||||
|
2. Добавить периодическую проверку здоровья нод (cron job)
|
||||||
|
3. Добавить логирование операций с нодами
|
||||||
|
4. Добавить валидацию конфигураций перед отправкой на ноды
|
||||||
|
5. Добавить обработку ошибок при недоступности нод
|
||||||
|
|
||||||
|
## ⚠️ Важные замечания
|
||||||
|
|
||||||
|
1. **Совместимость**: Все изменения обратно совместимы с single-mode
|
||||||
|
2. **Миграция БД**: Новые таблицы создаются автоматически при первом запуске
|
||||||
|
3. **Безопасность**: API ключи нод должны быть надёжными
|
||||||
|
4. **Сеть**: Ноды должны быть доступны с панели
|
||||||
|
|
||||||
|
## 📚 Документация
|
||||||
|
|
||||||
|
- `MULTI_NODE_ARCHITECTURE.md` - полная документация по архитектуре
|
||||||
|
- `node/README.md` - документация Node-сервиса
|
||||||
264
MULTI_NODE_ARCHITECTURE.md
Normal file
264
MULTI_NODE_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# Multi-Node Architecture для 3x-ui
|
||||||
|
|
||||||
|
## 📋 Обзор
|
||||||
|
|
||||||
|
Реализована поддержка multi-node архитектуры для панели 3x-ui с возможностью переключения между режимами работы:
|
||||||
|
|
||||||
|
- **single-mode** (по умолчанию) - полностью совместим с текущей логикой
|
||||||
|
- **multi-mode** - распределённая архитектура с отдельными нодами
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
### Single-Mode (по умолчанию)
|
||||||
|
|
||||||
|
- Используется встроенный XRAY Core на том же сервере, что и панель
|
||||||
|
- Все инбаунды работают локально
|
||||||
|
- Полная совместимость с существующим функционалом
|
||||||
|
|
||||||
|
### Multi-Mode
|
||||||
|
|
||||||
|
- Панель становится центральным сервером управления (Master)
|
||||||
|
- XRAY Core больше не используется локально
|
||||||
|
- Реальные XRAY инстансы работают на отдельных нодах (Workers)
|
||||||
|
- Панель хранит:
|
||||||
|
- Пользователей
|
||||||
|
- Инбаунды
|
||||||
|
- Правила маршрутизации
|
||||||
|
- Соответствие inbound → node
|
||||||
|
- Панель генерирует подписки с endpoint'ами нод
|
||||||
|
|
||||||
|
## 📦 Компоненты
|
||||||
|
|
||||||
|
### 1. Node-сервис (Worker)
|
||||||
|
|
||||||
|
Отдельный сервис, запускаемый в Docker на каждой ноде.
|
||||||
|
|
||||||
|
**Расположение:** `node/`
|
||||||
|
|
||||||
|
**Функциональность:**
|
||||||
|
- REST API для управления XRAY Core
|
||||||
|
- Применение конфигураций от панели
|
||||||
|
- Перезагрузка XRAY без остановки контейнера
|
||||||
|
- Проверка статуса и здоровья
|
||||||
|
|
||||||
|
**API Endpoints:**
|
||||||
|
- `GET /health` - проверка здоровья (без аутентификации)
|
||||||
|
- `POST /api/v1/apply-config` - применить конфигурацию XRAY
|
||||||
|
- `POST /api/v1/reload` - перезагрузить XRAY
|
||||||
|
- `GET /api/v1/status` - получить статус XRAY
|
||||||
|
|
||||||
|
**Запуск:**
|
||||||
|
```bash
|
||||||
|
cd node
|
||||||
|
NODE_API_KEY=your-secure-api-key docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Изменения в панели
|
||||||
|
|
||||||
|
#### База данных
|
||||||
|
|
||||||
|
Добавлены новые модели:
|
||||||
|
- `Node` - информация о ноде (id, name, address, api_key, status)
|
||||||
|
- `InboundNodeMapping` - соответствие inbound → node
|
||||||
|
|
||||||
|
#### Сервисы
|
||||||
|
|
||||||
|
**SettingService:**
|
||||||
|
- `GetMultiNodeMode()` - получить режим работы
|
||||||
|
- `SetMultiNodeMode(enabled)` - установить режим работы
|
||||||
|
|
||||||
|
**NodeService:**
|
||||||
|
- Управление нодами (CRUD)
|
||||||
|
- Проверка здоровья нод
|
||||||
|
- Назначение инбаундов нодам
|
||||||
|
- Отправка конфигураций на ноды
|
||||||
|
|
||||||
|
**XrayService:**
|
||||||
|
- Автоматическое определение режима работы
|
||||||
|
- В single-mode: работает как раньше
|
||||||
|
- В multi-mode: отправляет конфигурации на ноды вместо запуска локального XRAY
|
||||||
|
|
||||||
|
**SubService:**
|
||||||
|
- В multi-mode: генерирует подписки с endpoint'ами нод
|
||||||
|
- В single-mode: работает как раньше
|
||||||
|
|
||||||
|
#### Контроллеры
|
||||||
|
|
||||||
|
**NodeController:**
|
||||||
|
- `GET /panel/node/list` - список нод
|
||||||
|
- `GET /panel/node/get/:id` - получить ноду
|
||||||
|
- `POST /panel/node/add` - добавить ноду
|
||||||
|
- `POST /panel/node/update/:id` - обновить ноду
|
||||||
|
- `POST /panel/node/del/:id` - удалить ноду
|
||||||
|
- `POST /panel/node/check/:id` - проверить здоровье ноды
|
||||||
|
- `POST /panel/node/checkAll` - проверить все ноды
|
||||||
|
- `GET /panel/node/status/:id` - получить статус ноды
|
||||||
|
|
||||||
|
## 🔄 Как это работает
|
||||||
|
|
||||||
|
### Single-Mode
|
||||||
|
|
||||||
|
1. Пользователь создаёт/изменяет инбаунд
|
||||||
|
2. Панель генерирует конфигурацию XRAY
|
||||||
|
3. Локальный XRAY Core перезапускается с новой конфигурацией
|
||||||
|
4. Подписки генерируются с endpoint панели
|
||||||
|
|
||||||
|
### Multi-Mode
|
||||||
|
|
||||||
|
1. Пользователь создаёт/изменяет инбаунд
|
||||||
|
2. Пользователь назначает инбаунд ноде (через UI)
|
||||||
|
3. Панель генерирует конфигурацию XRAY для этой ноды
|
||||||
|
4. Конфигурация отправляется на ноду через REST API
|
||||||
|
5. Нода применяет конфигурацию и перезапускает свой XRAY Core
|
||||||
|
6. Подписки генерируются с endpoint ноды
|
||||||
|
|
||||||
|
## 🚀 Установка и настройка
|
||||||
|
|
||||||
|
### 1. Настройка панели
|
||||||
|
|
||||||
|
1. Включите multi-node mode в настройках панели (UI тумблер)
|
||||||
|
2. Добавьте ноды через UI (вкладка "Nodes")
|
||||||
|
3. Назначьте инбаунды нодам
|
||||||
|
|
||||||
|
### 2. Настройка ноды
|
||||||
|
|
||||||
|
1. Скопируйте папку `node/` на сервер ноды
|
||||||
|
2. Установите XRAY Core в `bin/` директорию
|
||||||
|
3. Настройте `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- NODE_API_KEY=your-secure-api-key
|
||||||
|
```
|
||||||
|
4. Запустите:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Добавление ноды в панель
|
||||||
|
|
||||||
|
1. Перейдите в раздел "Nodes"
|
||||||
|
2. Нажмите "Add Node"
|
||||||
|
3. Заполните:
|
||||||
|
- **Name**: имя ноды (например, "Node-1")
|
||||||
|
- **Address**: адрес API ноды (например, "http://192.168.1.100:8080")
|
||||||
|
- **API Key**: ключ, указанный в `NODE_API_KEY` на ноде
|
||||||
|
4. Сохраните
|
||||||
|
|
||||||
|
### 4. Назначение инбаунда ноде
|
||||||
|
|
||||||
|
1. Перейдите в раздел "Inbounds"
|
||||||
|
2. Откройте инбаунд для редактирования
|
||||||
|
3. В выпадающем списке "Node" выберите ноду
|
||||||
|
4. Сохраните
|
||||||
|
|
||||||
|
## 📝 Структура файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
3x-ui/
|
||||||
|
├── node/ # Node-сервис (worker)
|
||||||
|
│ ├── main.go # Точка входа
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── server.go # REST API сервер
|
||||||
|
│ ├── xray/
|
||||||
|
│ │ └── manager.go # Управление XRAY процессом
|
||||||
|
│ ├── Dockerfile # Docker образ
|
||||||
|
│ ├── docker-compose.yml # Docker Compose конфигурация
|
||||||
|
│ └── README.md # Документация ноды
|
||||||
|
├── database/
|
||||||
|
│ └── model/
|
||||||
|
│ └── model.go # + Node, InboundNodeMapping
|
||||||
|
├── web/
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── setting.go # + GetMultiNodeMode, SetMultiNodeMode
|
||||||
|
│ │ ├── node.go # NodeService (новый)
|
||||||
|
│ │ ├── xray.go # + поддержка multi-mode
|
||||||
|
│ └── controller/
|
||||||
|
│ ├── node.go # NodeController (новый)
|
||||||
|
│ └── xui.go # + маршрут /nodes
|
||||||
|
└── sub/
|
||||||
|
└── subService.go # + поддержка multi-mode для подписок
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Важные замечания
|
||||||
|
|
||||||
|
1. **Совместимость**: Все изменения минимально инвазивны и сохраняют полную совместимость с single-mode
|
||||||
|
2. **Миграция**: При переключении в multi-mode существующие инбаунды остаются без назначенных нод - их нужно назначить вручную
|
||||||
|
3. **Безопасность**: API ключи нод должны быть надёжными и храниться в безопасности
|
||||||
|
4. **Сеть**: Ноды должны быть доступны с панели по указанным адресам
|
||||||
|
|
||||||
|
## 🔧 Разработка
|
||||||
|
|
||||||
|
### Запуск Node-сервиса в режиме разработки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd node
|
||||||
|
go run main.go -port 8080 -api-key test-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
1. Запустите панель в multi-mode
|
||||||
|
2. Добавьте тестовую ноду
|
||||||
|
3. Создайте инбаунд и назначьте его ноде
|
||||||
|
4. Проверьте, что конфигурация отправляется на ноду
|
||||||
|
5. Проверьте, что подписки содержат правильный endpoint
|
||||||
|
|
||||||
|
## 📚 API Документация
|
||||||
|
|
||||||
|
### Node Service API
|
||||||
|
|
||||||
|
Все запросы (кроме `/health`) требуют заголовок:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Apply Config
|
||||||
|
```http
|
||||||
|
POST /api/v1/apply-config
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"log": {...},
|
||||||
|
"inbounds": [...],
|
||||||
|
"outbounds": [...],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Reload
|
||||||
|
```http
|
||||||
|
POST /api/v1/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Status
|
||||||
|
```http
|
||||||
|
GET /api/v1/status
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"running": true,
|
||||||
|
"version": "1.8.0",
|
||||||
|
"uptime": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Нода не отвечает
|
||||||
|
|
||||||
|
1. Проверьте, что нода запущена: `docker ps`
|
||||||
|
2. Проверьте логи: `docker logs 3x-ui-node`
|
||||||
|
3. Проверьте доступность: `curl http://node-address:8080/health`
|
||||||
|
4. Проверьте API ключ в настройках панели
|
||||||
|
|
||||||
|
### Конфигурация не применяется
|
||||||
|
|
||||||
|
1. Проверьте логи ноды
|
||||||
|
2. Проверьте, что XRAY Core установлен в `bin/`
|
||||||
|
3. Проверьте формат конфигурации
|
||||||
|
|
||||||
|
### Подписки не работают
|
||||||
|
|
||||||
|
1. Убедитесь, что инбаунд назначен ноде
|
||||||
|
2. Проверьте, что endpoint ноды доступен из сети
|
||||||
|
3. Проверьте, что порт инбаунда открыт на ноде
|
||||||
|
|
@ -38,6 +38,8 @@ func initModels() error {
|
||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.Node{},
|
||||||
|
&model.InboundNodeMapping{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := db.AutoMigrate(model); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ type Inbound struct {
|
||||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
|
NodeId *int `json:"nodeId,omitempty" form:"-" gorm:"-"` // Node ID (not stored in Inbound table, from mapping) - DEPRECATED: kept only for backward compatibility with old clients, use NodeIds instead
|
||||||
|
NodeIds []int `json:"nodeIds,omitempty" form:"-" gorm:"-"` // Node IDs array (not stored in Inbound table, from mapping) - use this for multi-node support
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||||
|
|
@ -119,3 +121,22 @@ type Client struct {
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Node represents a worker node in multi-node architecture.
|
||||||
|
type Node struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||||
|
Name string `json:"name" form:"name"` // Node name/identifier
|
||||||
|
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080")
|
||||||
|
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
|
||||||
|
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
|
||||||
|
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboundNodeMapping maps inbounds to nodes in multi-node mode.
|
||||||
|
type InboundNodeMapping struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||||
|
InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID
|
||||||
|
NodeId int `json:"nodeId" form:"nodeId" gorm:"uniqueIndex:idx_inbound_node"` // Node ID
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@ services:
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
container_name: 3xui_app
|
container_name: 3xui_app
|
||||||
# hostname: yourhostname <- optional
|
# hostname: yourhostname <- optional
|
||||||
|
ports:
|
||||||
|
- "2053:2053"
|
||||||
|
- "2096:2096"
|
||||||
volumes:
|
volumes:
|
||||||
- $PWD/db/:/etc/x-ui/
|
- $PWD/db/:/etc/x-ui/
|
||||||
- $PWD/cert/:/root/cert/
|
- $PWD/cert/:/root/cert/
|
||||||
|
|
@ -12,5 +15,5 @@ services:
|
||||||
XRAY_VMESS_AEAD_FORCED: "false"
|
XRAY_VMESS_AEAD_FORCED: "false"
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
tty: true
|
tty: true
|
||||||
network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
9
go.mod
9
go.mod
|
|
@ -1,5 +1,9 @@
|
||||||
module github.com/mhsanaei/3x-ui/v2
|
module github.com/mhsanaei/3x-ui/v2
|
||||||
|
|
||||||
|
// Local development - use local files instead of GitHub
|
||||||
|
// These replace directives ensure we use local code during development
|
||||||
|
// Remove these when changes are pushed to GitHub
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -101,3 +105,8 @@ require (
|
||||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Local development - use local files instead of GitHub
|
||||||
|
// This ensures we use local code during development
|
||||||
|
// Remove this when changes are pushed to GitHub
|
||||||
|
replace github.com/mhsanaei/3x-ui/v2 => ./
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,19 @@ func initDefaultBackend() logging.Backend {
|
||||||
includeTime = true
|
includeTime = true
|
||||||
} else {
|
} else {
|
||||||
// Unix-like: Try syslog, fallback to stderr
|
// Unix-like: Try syslog, fallback to stderr
|
||||||
if syslogBackend, err := logging.NewSyslogBackend(""); err != nil {
|
// Try syslog with "x-ui" tag first
|
||||||
fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err)
|
if syslogBackend, err := logging.NewSyslogBackend("x-ui"); err == nil {
|
||||||
backend = logging.NewLogBackend(os.Stderr, "", 0)
|
|
||||||
includeTime = os.Getppid() > 0
|
|
||||||
} else {
|
|
||||||
backend = syslogBackend
|
backend = syslogBackend
|
||||||
|
} else {
|
||||||
|
// Try with empty tag as fallback
|
||||||
|
if syslogBackend2, err2 := logging.NewSyslogBackend(""); err2 == nil {
|
||||||
|
backend = syslogBackend2
|
||||||
|
} else {
|
||||||
|
// Syslog unavailable - use stderr (normal in containers/Docker)
|
||||||
|
// In containers, syslog is often not configured - this is normal and expected
|
||||||
|
backend = logging.NewLogBackend(os.Stderr, "", 0)
|
||||||
|
includeTime = os.Getppid() > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
node/Dockerfile
Normal file
120
node/Dockerfile
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk --no-cache add curl unzip
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build node service
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o node-service ./node/main.go
|
||||||
|
|
||||||
|
# Download XRAY Core based on target architecture
|
||||||
|
# TARGETARCH is automatically set by Docker BuildKit
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
ARG TARGETOS=linux
|
||||||
|
RUN mkdir -p bin && \
|
||||||
|
cd bin && \
|
||||||
|
case ${TARGETARCH} in \
|
||||||
|
amd64) \
|
||||||
|
ARCH="64" \
|
||||||
|
FNAME="amd64" \
|
||||||
|
;; \
|
||||||
|
arm64) \
|
||||||
|
ARCH="arm64-v8a" \
|
||||||
|
FNAME="arm64" \
|
||||||
|
;; \
|
||||||
|
arm) \
|
||||||
|
ARCH="arm32-v7a" \
|
||||||
|
FNAME="arm32" \
|
||||||
|
;; \
|
||||||
|
armv6) \
|
||||||
|
ARCH="arm32-v6" \
|
||||||
|
FNAME="armv6" \
|
||||||
|
;; \
|
||||||
|
386) \
|
||||||
|
ARCH="32" \
|
||||||
|
FNAME="i386" \
|
||||||
|
;; \
|
||||||
|
*) \
|
||||||
|
ARCH="64" \
|
||||||
|
FNAME="amd64" \
|
||||||
|
;; \
|
||||||
|
esac && \
|
||||||
|
echo "Downloading Xray for ${TARGETARCH} (ARCH=${ARCH}, FNAME=${FNAME})" && \
|
||||||
|
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip" && \
|
||||||
|
echo "Unzipping..." && \
|
||||||
|
unzip -q "Xray-linux-${ARCH}.zip" && \
|
||||||
|
echo "Files after unzip:" && \
|
||||||
|
ls -la && \
|
||||||
|
echo "Removing zip and old data files..." && \
|
||||||
|
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat && \
|
||||||
|
echo "Renaming xray to xray-linux-${FNAME}..." && \
|
||||||
|
mv xray "xray-linux-${FNAME}" && \
|
||||||
|
chmod +x "xray-linux-${FNAME}" && \
|
||||||
|
echo "Verifying xray binary:" && \
|
||||||
|
ls -lh "xray-linux-${FNAME}" && \
|
||||||
|
test -f "xray-linux-${FNAME}" && echo "✓ xray-linux-${FNAME} exists" && \
|
||||||
|
echo "Downloading geo files..." && \
|
||||||
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat && \
|
||||||
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat && \
|
||||||
|
echo "Final files in bin:" && \
|
||||||
|
ls -lah && \
|
||||||
|
echo "File sizes:" && \
|
||||||
|
du -h * && \
|
||||||
|
cd .. && \
|
||||||
|
echo "Verifying files in /build/bin:" && \
|
||||||
|
ls -lah /build/bin/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/node-service .
|
||||||
|
|
||||||
|
# Copy XRAY binary and data files
|
||||||
|
# Use wildcard to copy all files from bin directory
|
||||||
|
COPY --from=builder /build/bin/ ./bin/
|
||||||
|
|
||||||
|
# Verify files were copied and make executable
|
||||||
|
RUN echo "Contents of /app/bin after COPY:" && \
|
||||||
|
ls -la ./bin/ && \
|
||||||
|
echo "Looking for xray binary..." && \
|
||||||
|
if [ -f ./bin/xray-linux-amd64 ]; then \
|
||||||
|
chmod +x ./bin/xray-linux-amd64 && \
|
||||||
|
echo "✓ Found and made executable: xray-linux-amd64"; \
|
||||||
|
elif [ -f ./bin/xray ]; then \
|
||||||
|
chmod +x ./bin/xray && \
|
||||||
|
mv ./bin/xray ./bin/xray-linux-amd64 && \
|
||||||
|
echo "✓ Found xray, renamed to xray-linux-amd64"; \
|
||||||
|
else \
|
||||||
|
echo "✗ ERROR: No xray binary found!" && \
|
||||||
|
echo "All files in bin directory:" && \
|
||||||
|
find ./bin -type f -o -type l && \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories for config and logs
|
||||||
|
RUN mkdir -p /app/config /app/logs
|
||||||
|
|
||||||
|
# Set environment variables for paths
|
||||||
|
ENV XUI_BIN_FOLDER=/app/bin
|
||||||
|
ENV XUI_LOG_FOLDER=/app/logs
|
||||||
|
|
||||||
|
# Expose API port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run node service
|
||||||
|
# The API key will be read from NODE_API_KEY environment variable
|
||||||
|
CMD ["./node-service", "-port", "8080"]
|
||||||
70
node/README.md
Normal file
70
node/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# 3x-ui Node Service
|
||||||
|
|
||||||
|
Node service (worker) для multi-node архитектуры 3x-ui.
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Этот сервис запускается на отдельных серверах и управляет XRAY Core инстансами. Панель 3x-ui (master) отправляет конфигурации на ноды через REST API.
|
||||||
|
|
||||||
|
## Функциональность
|
||||||
|
|
||||||
|
- REST API для управления XRAY Core
|
||||||
|
- Применение конфигураций от панели
|
||||||
|
- Перезагрузка XRAY без остановки контейнера
|
||||||
|
- Проверка статуса и здоровья
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
Проверка здоровья сервиса (без аутентификации)
|
||||||
|
|
||||||
|
### `POST /api/v1/apply-config`
|
||||||
|
Применить новую конфигурацию XRAY
|
||||||
|
- **Headers**: `Authorization: Bearer <api-key>`
|
||||||
|
- **Body**: JSON конфигурация XRAY
|
||||||
|
|
||||||
|
### `POST /api/v1/reload`
|
||||||
|
Перезагрузить XRAY
|
||||||
|
- **Headers**: `Authorization: Bearer <api-key>`
|
||||||
|
|
||||||
|
### `GET /api/v1/status`
|
||||||
|
Получить статус XRAY
|
||||||
|
- **Headers**: `Authorization: Bearer <api-key>`
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd node
|
||||||
|
NODE_API_KEY=your-secure-api-key docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** XRAY Core автоматически скачивается во время сборки Docker-образа для вашей архитектуры. Docker BuildKit автоматически определяет архитектуру хоста. Для явного указания архитектуры используйте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker build --build-arg TARGETARCH=arm64 -t 3x-ui-node -f node/Dockerfile ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вручную
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run node/main.go -port 8080 -api-key your-secure-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
- `NODE_API_KEY` - API ключ для аутентификации (обязательно)
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
node/
|
||||||
|
├── main.go # Точка входа
|
||||||
|
├── api/
|
||||||
|
│ └── server.go # REST API сервер
|
||||||
|
├── xray/
|
||||||
|
│ └── manager.go # Управление XRAY процессом
|
||||||
|
├── Dockerfile # Docker образ
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
149
node/api/server.go
Normal file
149
node/api/server.go
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// Package api provides REST API endpoints for the node service.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server provides REST API for managing the node.
|
||||||
|
type Server struct {
|
||||||
|
port int
|
||||||
|
apiKey string
|
||||||
|
xrayManager *xray.Manager
|
||||||
|
httpServer *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new API server instance.
|
||||||
|
func NewServer(port int, apiKey string, xrayManager *xray.Manager) *Server {
|
||||||
|
return &Server{
|
||||||
|
port: port,
|
||||||
|
apiKey: apiKey,
|
||||||
|
xrayManager: xrayManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP server.
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
router.Use(s.authMiddleware())
|
||||||
|
|
||||||
|
// Health check endpoint (no auth required)
|
||||||
|
router.GET("/health", s.health)
|
||||||
|
|
||||||
|
// API endpoints (require auth)
|
||||||
|
api := router.Group("/api/v1")
|
||||||
|
{
|
||||||
|
api.POST("/apply-config", s.applyConfig)
|
||||||
|
api.POST("/reload", s.reload)
|
||||||
|
api.GET("/status", s.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.httpServer = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.port),
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("API server listening on port %d", s.port)
|
||||||
|
return s.httpServer.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the HTTP server.
|
||||||
|
func (s *Server) Stop() error {
|
||||||
|
if s.httpServer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.httpServer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware validates API key from Authorization header.
|
||||||
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Skip auth for health endpoint
|
||||||
|
if c.Request.URL.Path == "/health" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support both "Bearer <key>" and direct key
|
||||||
|
apiKey := authHeader
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
apiKey = authHeader[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey != s.apiKey {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// health returns the health status of the node.
|
||||||
|
func (s *Server) health(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "3x-ui-node",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyConfig applies a new XRAY configuration.
|
||||||
|
func (s *Server) applyConfig(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate JSON
|
||||||
|
var configJSON json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &configJSON); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.xrayManager.ApplyConfig(body); err != nil {
|
||||||
|
logger.Errorf("Failed to apply config: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Configuration applied successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload reloads XRAY configuration.
|
||||||
|
func (s *Server) reload(c *gin.Context) {
|
||||||
|
if err := s.xrayManager.Reload(); err != nil {
|
||||||
|
logger.Errorf("Failed to reload: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "XRAY reloaded successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// status returns the current status of XRAY.
|
||||||
|
func (s *Server) status(c *gin.Context) {
|
||||||
|
status := s.xrayManager.GetStatus()
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
62
node/docker-compose.yml
Normal file
62
node/docker-compose.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
services:
|
||||||
|
node:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: node/Dockerfile
|
||||||
|
container_name: 3x-ui-node
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||||
|
- NODE_API_KEY=test-key
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "44000:44000"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./logs:/app/logs
|
||||||
|
# Note: ./bin volume is removed to preserve xray binary from image
|
||||||
|
# If you need to persist bin directory, use a different path or copy files manually
|
||||||
|
networks:
|
||||||
|
- xray-network
|
||||||
|
node2:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: node/Dockerfile
|
||||||
|
container_name: 3x-ui-node2
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||||
|
- NODE_API_KEY=test-key
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
- "44001:44000"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./logs:/app/logs
|
||||||
|
# Note: ./bin volume is removed to preserve xray binary from image
|
||||||
|
# If you need to persist bin directory, use a different path or copy files manually
|
||||||
|
networks:
|
||||||
|
- xray-network
|
||||||
|
|
||||||
|
node3:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: node/Dockerfile
|
||||||
|
container_name: 3x-ui-node3
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||||
|
- NODE_API_KEY=test-key
|
||||||
|
ports:
|
||||||
|
- "8082:8080"
|
||||||
|
- "44002:44000"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./logs:/app/logs
|
||||||
|
# Note: ./bin volume is removed to preserve xray binary from image
|
||||||
|
# If you need to persist bin directory, use a different path or copy files manually
|
||||||
|
networks:
|
||||||
|
- xray-network
|
||||||
|
networks:
|
||||||
|
xray-network:
|
||||||
|
driver: bridge
|
||||||
52
node/main.go
Normal file
52
node/main.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Package main is the entry point for the 3x-ui node service (worker).
|
||||||
|
// This service runs XRAY Core and provides a REST API for the master panel to manage it.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/node/api"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var port int
|
||||||
|
var apiKey string
|
||||||
|
flag.IntVar(&port, "port", 8080, "API server port")
|
||||||
|
flag.StringVar(&apiKey, "api-key", "", "API key for authentication (required)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Check environment variable if flag is not provided
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = os.Getenv("NODE_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Fatal("API key is required. Set NODE_API_KEY environment variable or use -api-key flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InitLogger(logging.INFO)
|
||||||
|
|
||||||
|
xrayManager := xray.NewManager()
|
||||||
|
server := api.NewServer(port, apiKey, xrayManager)
|
||||||
|
|
||||||
|
log.Printf("Starting 3x-ui Node Service on port %d", port)
|
||||||
|
if err := server.Start(); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
xrayManager.Stop()
|
||||||
|
server.Stop()
|
||||||
|
log.Println("Shutdown complete")
|
||||||
|
}
|
||||||
126
node/xray/manager.go
Normal file
126
node/xray/manager.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// Package xray provides XRAY Core management for the node service.
|
||||||
|
package xray
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager manages the XRAY Core process lifecycle.
|
||||||
|
type Manager struct {
|
||||||
|
process *xray.Process
|
||||||
|
lock sync.Mutex
|
||||||
|
config *xray.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new XRAY manager instance.
|
||||||
|
func NewManager() *Manager {
|
||||||
|
return &Manager{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if XRAY is currently running.
|
||||||
|
func (m *Manager) IsRunning() bool {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
return m.process != nil && m.process.IsRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the current status of XRAY.
|
||||||
|
func (m *Manager) GetStatus() map[string]interface{} {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"running": m.process != nil && m.process.IsRunning(),
|
||||||
|
"version": "Unknown",
|
||||||
|
"uptime": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.process != nil && m.process.IsRunning() {
|
||||||
|
status["version"] = m.process.GetVersion()
|
||||||
|
status["uptime"] = m.process.GetUptime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyConfig applies a new XRAY configuration and restarts if needed.
|
||||||
|
func (m *Manager) ApplyConfig(configJSON []byte) error {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
|
||||||
|
var newConfig xray.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &newConfig); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If XRAY is running and config is the same, skip restart
|
||||||
|
if m.process != nil && m.process.IsRunning() {
|
||||||
|
oldConfig := m.process.GetConfig()
|
||||||
|
if oldConfig != nil && oldConfig.Equals(&newConfig) {
|
||||||
|
logger.Info("Config unchanged, skipping restart")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Stop existing process
|
||||||
|
if err := m.process.Stop(); err != nil {
|
||||||
|
logger.Warningf("Failed to stop existing XRAY: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new process with new config
|
||||||
|
m.config = &newConfig
|
||||||
|
m.process = xray.NewProcess(&newConfig)
|
||||||
|
if err := m.process.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start XRAY: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("XRAY configuration applied successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload reloads XRAY configuration without full restart (if supported).
|
||||||
|
// Falls back to restart if reload is not available.
|
||||||
|
func (m *Manager) Reload() error {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
|
||||||
|
if m.process == nil || !m.process.IsRunning() {
|
||||||
|
return errors.New("XRAY is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// XRAY doesn't support hot reload, so we need to restart
|
||||||
|
// Save current config
|
||||||
|
if m.config == nil {
|
||||||
|
return errors.New("no config to reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop and restart
|
||||||
|
if err := m.process.Stop(); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop XRAY: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.process = xray.NewProcess(m.config)
|
||||||
|
if err := m.process.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to restart XRAY: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("XRAY reloaded successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the XRAY process.
|
||||||
|
func (m *Manager) Stop() error {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
|
||||||
|
if m.process == nil || !m.process.IsRunning() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.process.Stop()
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
service "github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -40,6 +41,8 @@ func NewSUBController(
|
||||||
subTitle string,
|
subTitle string,
|
||||||
) *SUBController {
|
) *SUBController {
|
||||||
sub := NewSubService(showInfo, rModel)
|
sub := NewSubService(showInfo, rModel)
|
||||||
|
// Initialize NodeService for multi-node support
|
||||||
|
sub.nodeService = service.NodeService{}
|
||||||
a := &SUBController{
|
a := &SUBController{
|
||||||
subTitle: subTitle,
|
subTitle: subTitle,
|
||||||
subPath: subPath,
|
subPath: subPath,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type SubService struct {
|
||||||
datepicker string
|
datepicker string
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
|
nodeService service.NodeService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSubService creates a new subscription service with the given configuration.
|
// NewSubService creates a new subscription service with the given configuration.
|
||||||
|
|
@ -77,7 +78,14 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.Enable && client.SubID == subId {
|
if client.Enable && client.SubID == subId {
|
||||||
link := s.getLink(inbound, client.Email)
|
link := s.getLink(inbound, client.Email)
|
||||||
result = append(result, link)
|
// Split link by newline to handle multiple links (for multiple nodes)
|
||||||
|
linkLines := strings.Split(link, "\n")
|
||||||
|
for _, linkLine := range linkLines {
|
||||||
|
linkLine = strings.TrimSpace(linkLine)
|
||||||
|
if linkLine != "" {
|
||||||
|
result = append(result, linkLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
|
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
|
||||||
clientTraffics = append(clientTraffics, ct)
|
clientTraffics = append(clientTraffics, ct)
|
||||||
if ct.LastOnline > lastOnline {
|
if ct.LastOnline > lastOnline {
|
||||||
|
|
@ -179,78 +187,99 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
if inbound.Protocol != model.VMESS {
|
if inbound.Protocol != model.VMESS {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var address string
|
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
// Get all nodes for this inbound
|
||||||
address = s.address
|
var nodeAddresses []string
|
||||||
} else {
|
multiMode, _ := s.settingService.GetMultiNodeMode()
|
||||||
address = inbound.Listen
|
if multiMode {
|
||||||
|
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err == nil && len(nodes) > 0 {
|
||||||
|
// Extract addresses from all nodes
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeAddr := s.extractNodeHost(node.Address)
|
||||||
|
if nodeAddr != "" {
|
||||||
|
nodeAddresses = append(nodeAddresses, nodeAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
|
||||||
|
// Fallback to default logic if no nodes found
|
||||||
|
var defaultAddress string
|
||||||
|
if len(nodeAddresses) == 0 {
|
||||||
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||||
|
defaultAddress = s.address
|
||||||
|
} else {
|
||||||
|
defaultAddress = inbound.Listen
|
||||||
|
}
|
||||||
|
nodeAddresses = []string{defaultAddress}
|
||||||
|
}
|
||||||
|
// Base object template (address will be set per node)
|
||||||
|
baseObj := map[string]any{
|
||||||
"v": "2",
|
"v": "2",
|
||||||
"add": address,
|
|
||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
"type": "none",
|
"type": "none",
|
||||||
}
|
}
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||||
network, _ := stream["network"].(string)
|
network, _ := stream["network"].(string)
|
||||||
obj["net"] = network
|
baseObj["net"] = network
|
||||||
switch network {
|
switch network {
|
||||||
case "tcp":
|
case "tcp":
|
||||||
tcp, _ := stream["tcpSettings"].(map[string]any)
|
tcp, _ := stream["tcpSettings"].(map[string]any)
|
||||||
header, _ := tcp["header"].(map[string]any)
|
header, _ := tcp["header"].(map[string]any)
|
||||||
typeStr, _ := header["type"].(string)
|
typeStr, _ := header["type"].(string)
|
||||||
obj["type"] = typeStr
|
baseObj["type"] = typeStr
|
||||||
if typeStr == "http" {
|
if typeStr == "http" {
|
||||||
request := header["request"].(map[string]any)
|
request := header["request"].(map[string]any)
|
||||||
requestPath, _ := request["path"].([]any)
|
requestPath, _ := request["path"].([]any)
|
||||||
obj["path"] = requestPath[0].(string)
|
baseObj["path"] = requestPath[0].(string)
|
||||||
headers, _ := request["headers"].(map[string]any)
|
headers, _ := request["headers"].(map[string]any)
|
||||||
obj["host"] = searchHost(headers)
|
baseObj["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
case "kcp":
|
case "kcp":
|
||||||
kcp, _ := stream["kcpSettings"].(map[string]any)
|
kcp, _ := stream["kcpSettings"].(map[string]any)
|
||||||
header, _ := kcp["header"].(map[string]any)
|
header, _ := kcp["header"].(map[string]any)
|
||||||
obj["type"], _ = header["type"].(string)
|
baseObj["type"], _ = header["type"].(string)
|
||||||
obj["path"], _ = kcp["seed"].(string)
|
baseObj["path"], _ = kcp["seed"].(string)
|
||||||
case "ws":
|
case "ws":
|
||||||
ws, _ := stream["wsSettings"].(map[string]any)
|
ws, _ := stream["wsSettings"].(map[string]any)
|
||||||
obj["path"] = ws["path"].(string)
|
baseObj["path"] = ws["path"].(string)
|
||||||
if host, ok := ws["host"].(string); ok && len(host) > 0 {
|
if host, ok := ws["host"].(string); ok && len(host) > 0 {
|
||||||
obj["host"] = host
|
baseObj["host"] = host
|
||||||
} else {
|
} else {
|
||||||
headers, _ := ws["headers"].(map[string]any)
|
headers, _ := ws["headers"].(map[string]any)
|
||||||
obj["host"] = searchHost(headers)
|
baseObj["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
case "grpc":
|
case "grpc":
|
||||||
grpc, _ := stream["grpcSettings"].(map[string]any)
|
grpc, _ := stream["grpcSettings"].(map[string]any)
|
||||||
obj["path"] = grpc["serviceName"].(string)
|
baseObj["path"] = grpc["serviceName"].(string)
|
||||||
obj["authority"] = grpc["authority"].(string)
|
baseObj["authority"] = grpc["authority"].(string)
|
||||||
if grpc["multiMode"].(bool) {
|
if grpc["multiMode"].(bool) {
|
||||||
obj["type"] = "multi"
|
baseObj["type"] = "multi"
|
||||||
}
|
}
|
||||||
case "httpupgrade":
|
case "httpupgrade":
|
||||||
httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any)
|
httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any)
|
||||||
obj["path"] = httpupgrade["path"].(string)
|
baseObj["path"] = httpupgrade["path"].(string)
|
||||||
if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 {
|
if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 {
|
||||||
obj["host"] = host
|
baseObj["host"] = host
|
||||||
} else {
|
} else {
|
||||||
headers, _ := httpupgrade["headers"].(map[string]any)
|
headers, _ := httpupgrade["headers"].(map[string]any)
|
||||||
obj["host"] = searchHost(headers)
|
baseObj["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
case "xhttp":
|
case "xhttp":
|
||||||
xhttp, _ := stream["xhttpSettings"].(map[string]any)
|
xhttp, _ := stream["xhttpSettings"].(map[string]any)
|
||||||
obj["path"] = xhttp["path"].(string)
|
baseObj["path"] = xhttp["path"].(string)
|
||||||
if host, ok := xhttp["host"].(string); ok && len(host) > 0 {
|
if host, ok := xhttp["host"].(string); ok && len(host) > 0 {
|
||||||
obj["host"] = host
|
baseObj["host"] = host
|
||||||
} else {
|
} else {
|
||||||
headers, _ := xhttp["headers"].(map[string]any)
|
headers, _ := xhttp["headers"].(map[string]any)
|
||||||
obj["host"] = searchHost(headers)
|
baseObj["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
obj["mode"] = xhttp["mode"].(string)
|
baseObj["mode"] = xhttp["mode"].(string)
|
||||||
}
|
}
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
obj["tls"] = security
|
baseObj["tls"] = security
|
||||||
if security == "tls" {
|
if security == "tls" {
|
||||||
tlsSetting, _ := stream["tlsSettings"].(map[string]any)
|
tlsSetting, _ := stream["tlsSettings"].(map[string]any)
|
||||||
alpns, _ := tlsSetting["alpn"].([]any)
|
alpns, _ := tlsSetting["alpn"].([]any)
|
||||||
|
|
@ -259,19 +288,19 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
for _, a := range alpns {
|
for _, a := range alpns {
|
||||||
alpn = append(alpn, a.(string))
|
alpn = append(alpn, a.(string))
|
||||||
}
|
}
|
||||||
obj["alpn"] = strings.Join(alpn, ",")
|
baseObj["alpn"] = strings.Join(alpn, ",")
|
||||||
}
|
}
|
||||||
if sniValue, ok := searchKey(tlsSetting, "serverName"); ok {
|
if sniValue, ok := searchKey(tlsSetting, "serverName"); ok {
|
||||||
obj["sni"], _ = sniValue.(string)
|
baseObj["sni"], _ = sniValue.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsSettings, _ := searchKey(tlsSetting, "settings")
|
tlsSettings, _ := searchKey(tlsSetting, "settings")
|
||||||
if tlsSetting != nil {
|
if tlsSetting != nil {
|
||||||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||||
obj["fp"], _ = fpValue.(string)
|
baseObj["fp"], _ = fpValue.(string)
|
||||||
}
|
}
|
||||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||||
obj["allowInsecure"], _ = insecure.(bool)
|
baseObj["allowInsecure"], _ = insecure.(bool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -284,18 +313,22 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
obj["id"] = clients[clientIndex].ID
|
baseObj["id"] = clients[clientIndex].ID
|
||||||
obj["scy"] = clients[clientIndex].Security
|
baseObj["scy"] = clients[clientIndex].Security
|
||||||
|
|
||||||
externalProxies, _ := stream["externalProxy"].([]any)
|
externalProxies, _ := stream["externalProxy"].([]any)
|
||||||
|
|
||||||
|
// Generate links for each node address (or external proxy)
|
||||||
|
links := ""
|
||||||
|
linkIndex := 0
|
||||||
|
|
||||||
|
// First, handle external proxies if any
|
||||||
if len(externalProxies) > 0 {
|
if len(externalProxies) > 0 {
|
||||||
links := ""
|
for _, externalProxy := range externalProxies {
|
||||||
for index, externalProxy := range externalProxies {
|
|
||||||
ep, _ := externalProxy.(map[string]any)
|
ep, _ := externalProxy.(map[string]any)
|
||||||
newSecurity, _ := ep["forceTls"].(string)
|
newSecurity, _ := ep["forceTls"].(string)
|
||||||
newObj := map[string]any{}
|
newObj := map[string]any{}
|
||||||
for key, value := range obj {
|
for key, value := range baseObj {
|
||||||
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) {
|
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) {
|
||||||
newObj[key] = value
|
newObj[key] = value
|
||||||
}
|
}
|
||||||
|
|
@ -307,32 +340,67 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
if newSecurity != "same" {
|
if newSecurity != "same" {
|
||||||
newObj["tls"] = newSecurity
|
newObj["tls"] = newSecurity
|
||||||
}
|
}
|
||||||
if index > 0 {
|
if linkIndex > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
}
|
}
|
||||||
jsonStr, _ := json.MarshalIndent(newObj, "", " ")
|
jsonStr, _ := json.MarshalIndent(newObj, "", " ")
|
||||||
links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
obj["ps"] = s.genRemark(inbound, email, "")
|
// Generate links for each node address
|
||||||
|
for _, nodeAddr := range nodeAddresses {
|
||||||
|
obj := make(map[string]any)
|
||||||
|
for k, v := range baseObj {
|
||||||
|
obj[k] = v
|
||||||
|
}
|
||||||
|
obj["add"] = nodeAddr
|
||||||
|
obj["ps"] = s.genRemark(inbound, email, "")
|
||||||
|
|
||||||
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
if linkIndex > 0 {
|
||||||
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
links += "\n"
|
||||||
|
}
|
||||||
|
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
||||||
|
links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
||||||
|
linkIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
var address string
|
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
||||||
address = s.address
|
|
||||||
} else {
|
|
||||||
address = inbound.Listen
|
|
||||||
}
|
|
||||||
|
|
||||||
if inbound.Protocol != model.VLESS {
|
if inbound.Protocol != model.VLESS {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all nodes for this inbound
|
||||||
|
var nodeAddresses []string
|
||||||
|
multiMode, _ := s.settingService.GetMultiNodeMode()
|
||||||
|
if multiMode {
|
||||||
|
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err == nil && len(nodes) > 0 {
|
||||||
|
// Extract addresses from all nodes
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeAddr := s.extractNodeHost(node.Address)
|
||||||
|
if nodeAddr != "" {
|
||||||
|
nodeAddresses = append(nodeAddresses, nodeAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default logic if no nodes found
|
||||||
|
var defaultAddress string
|
||||||
|
if len(nodeAddresses) == 0 {
|
||||||
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||||
|
defaultAddress = s.address
|
||||||
|
} else {
|
||||||
|
defaultAddress = inbound.Listen
|
||||||
|
}
|
||||||
|
nodeAddresses = []string{defaultAddress}
|
||||||
|
}
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||||
clients, _ := s.inboundService.GetClients(inbound)
|
clients, _ := s.inboundService.GetClients(inbound)
|
||||||
|
|
@ -483,14 +551,18 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
|
|
||||||
externalProxies, _ := stream["externalProxy"].([]any)
|
externalProxies, _ := stream["externalProxy"].([]any)
|
||||||
|
|
||||||
|
// Generate links for each node address (or external proxy)
|
||||||
|
links := ""
|
||||||
|
linkIndex := 0
|
||||||
|
|
||||||
|
// First, handle external proxies if any
|
||||||
if len(externalProxies) > 0 {
|
if len(externalProxies) > 0 {
|
||||||
links := ""
|
for _, externalProxy := range externalProxies {
|
||||||
for index, externalProxy := range externalProxies {
|
|
||||||
ep, _ := externalProxy.(map[string]any)
|
ep, _ := externalProxy.(map[string]any)
|
||||||
newSecurity, _ := ep["forceTls"].(string)
|
newSecurity, _ := ep["forceTls"].(string)
|
||||||
dest, _ := ep["dest"].(string)
|
dest, _ := ep["dest"].(string)
|
||||||
port := int(ep["port"].(float64))
|
epPort := int(ep["port"].(float64))
|
||||||
link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port)
|
link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort)
|
||||||
|
|
||||||
if newSecurity != "same" {
|
if newSecurity != "same" {
|
||||||
params["security"] = newSecurity
|
params["security"] = newSecurity
|
||||||
|
|
@ -511,39 +583,71 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
||||||
|
|
||||||
if index > 0 {
|
if linkIndex > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
}
|
}
|
||||||
links += url.String()
|
links += url.String()
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port)
|
// Generate links for each node address
|
||||||
url, _ := url.Parse(link)
|
for _, nodeAddr := range nodeAddresses {
|
||||||
q := url.Query()
|
link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port)
|
||||||
|
url, _ := url.Parse(link)
|
||||||
|
q := url.Query()
|
||||||
|
|
||||||
for k, v := range params {
|
for k, v := range params {
|
||||||
q.Add(k, v)
|
q.Add(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new query values on the URL
|
||||||
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
url.Fragment = s.genRemark(inbound, email, "")
|
||||||
|
|
||||||
|
if linkIndex > 0 {
|
||||||
|
links += "\n"
|
||||||
|
}
|
||||||
|
links += url.String()
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the new query values on the URL
|
return links
|
||||||
url.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, "")
|
|
||||||
return url.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
|
||||||
var address string
|
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
||||||
address = s.address
|
|
||||||
} else {
|
|
||||||
address = inbound.Listen
|
|
||||||
}
|
|
||||||
if inbound.Protocol != model.Trojan {
|
if inbound.Protocol != model.Trojan {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all nodes for this inbound
|
||||||
|
var nodeAddresses []string
|
||||||
|
multiMode, _ := s.settingService.GetMultiNodeMode()
|
||||||
|
if multiMode {
|
||||||
|
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err == nil && len(nodes) > 0 {
|
||||||
|
// Extract addresses from all nodes
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeAddr := s.extractNodeHost(node.Address)
|
||||||
|
if nodeAddr != "" {
|
||||||
|
nodeAddresses = append(nodeAddresses, nodeAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default logic if no nodes found
|
||||||
|
var defaultAddress string
|
||||||
|
if len(nodeAddresses) == 0 {
|
||||||
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||||
|
defaultAddress = s.address
|
||||||
|
} else {
|
||||||
|
defaultAddress = inbound.Listen
|
||||||
|
}
|
||||||
|
nodeAddresses = []string{defaultAddress}
|
||||||
|
}
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||||
clients, _ := s.inboundService.GetClients(inbound)
|
clients, _ := s.inboundService.GetClients(inbound)
|
||||||
|
|
@ -683,14 +787,18 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
|
|
||||||
externalProxies, _ := stream["externalProxy"].([]any)
|
externalProxies, _ := stream["externalProxy"].([]any)
|
||||||
|
|
||||||
|
// Generate links for each node address (or external proxy)
|
||||||
|
links := ""
|
||||||
|
linkIndex := 0
|
||||||
|
|
||||||
|
// First, handle external proxies if any
|
||||||
if len(externalProxies) > 0 {
|
if len(externalProxies) > 0 {
|
||||||
links := ""
|
for _, externalProxy := range externalProxies {
|
||||||
for index, externalProxy := range externalProxies {
|
|
||||||
ep, _ := externalProxy.(map[string]any)
|
ep, _ := externalProxy.(map[string]any)
|
||||||
newSecurity, _ := ep["forceTls"].(string)
|
newSecurity, _ := ep["forceTls"].(string)
|
||||||
dest, _ := ep["dest"].(string)
|
dest, _ := ep["dest"].(string)
|
||||||
port := int(ep["port"].(float64))
|
epPort := int(ep["port"].(float64))
|
||||||
link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, port)
|
link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort)
|
||||||
|
|
||||||
if newSecurity != "same" {
|
if newSecurity != "same" {
|
||||||
params["security"] = newSecurity
|
params["security"] = newSecurity
|
||||||
|
|
@ -711,40 +819,71 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
||||||
|
|
||||||
if index > 0 {
|
if linkIndex > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
}
|
}
|
||||||
links += url.String()
|
links += url.String()
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port)
|
// Generate links for each node address
|
||||||
|
for _, nodeAddr := range nodeAddresses {
|
||||||
|
link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port)
|
||||||
|
url, _ := url.Parse(link)
|
||||||
|
q := url.Query()
|
||||||
|
|
||||||
url, _ := url.Parse(link)
|
for k, v := range params {
|
||||||
q := url.Query()
|
q.Add(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
for k, v := range params {
|
// Set the new query values on the URL
|
||||||
q.Add(k, v)
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
url.Fragment = s.genRemark(inbound, email, "")
|
||||||
|
|
||||||
|
if linkIndex > 0 {
|
||||||
|
links += "\n"
|
||||||
|
}
|
||||||
|
links += url.String()
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the new query values on the URL
|
return links
|
||||||
url.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, "")
|
|
||||||
return url.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
||||||
var address string
|
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
||||||
address = s.address
|
|
||||||
} else {
|
|
||||||
address = inbound.Listen
|
|
||||||
}
|
|
||||||
if inbound.Protocol != model.Shadowsocks {
|
if inbound.Protocol != model.Shadowsocks {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all nodes for this inbound
|
||||||
|
var nodeAddresses []string
|
||||||
|
multiMode, _ := s.settingService.GetMultiNodeMode()
|
||||||
|
if multiMode {
|
||||||
|
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err == nil && len(nodes) > 0 {
|
||||||
|
// Extract addresses from all nodes
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeAddr := s.extractNodeHost(node.Address)
|
||||||
|
if nodeAddr != "" {
|
||||||
|
nodeAddresses = append(nodeAddresses, nodeAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default logic if no nodes found
|
||||||
|
var defaultAddress string
|
||||||
|
if len(nodeAddresses) == 0 {
|
||||||
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||||
|
defaultAddress = s.address
|
||||||
|
} else {
|
||||||
|
defaultAddress = inbound.Listen
|
||||||
|
}
|
||||||
|
nodeAddresses = []string{defaultAddress}
|
||||||
|
}
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||||
clients, _ := s.inboundService.GetClients(inbound)
|
clients, _ := s.inboundService.GetClients(inbound)
|
||||||
|
|
@ -855,14 +994,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||||
|
|
||||||
externalProxies, _ := stream["externalProxy"].([]any)
|
externalProxies, _ := stream["externalProxy"].([]any)
|
||||||
|
|
||||||
|
// Generate links for each node address (or external proxy)
|
||||||
|
links := ""
|
||||||
|
linkIndex := 0
|
||||||
|
|
||||||
|
// First, handle external proxies if any
|
||||||
if len(externalProxies) > 0 {
|
if len(externalProxies) > 0 {
|
||||||
links := ""
|
for _, externalProxy := range externalProxies {
|
||||||
for index, externalProxy := range externalProxies {
|
|
||||||
ep, _ := externalProxy.(map[string]any)
|
ep, _ := externalProxy.(map[string]any)
|
||||||
newSecurity, _ := ep["forceTls"].(string)
|
newSecurity, _ := ep["forceTls"].(string)
|
||||||
dest, _ := ep["dest"].(string)
|
dest, _ := ep["dest"].(string)
|
||||||
port := int(ep["port"].(float64))
|
epPort := int(ep["port"].(float64))
|
||||||
link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port)
|
link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort)
|
||||||
|
|
||||||
if newSecurity != "same" {
|
if newSecurity != "same" {
|
||||||
params["security"] = newSecurity
|
params["security"] = newSecurity
|
||||||
|
|
@ -883,27 +1026,38 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
||||||
|
|
||||||
if index > 0 {
|
if linkIndex > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
}
|
}
|
||||||
links += url.String()
|
links += url.String()
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port)
|
// Generate links for each node address
|
||||||
url, _ := url.Parse(link)
|
for _, nodeAddr := range nodeAddresses {
|
||||||
q := url.Query()
|
link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port)
|
||||||
|
url, _ := url.Parse(link)
|
||||||
|
q := url.Query()
|
||||||
|
|
||||||
for k, v := range params {
|
for k, v := range params {
|
||||||
q.Add(k, v)
|
q.Add(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new query values on the URL
|
||||||
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
url.Fragment = s.genRemark(inbound, email, "")
|
||||||
|
|
||||||
|
if linkIndex > 0 {
|
||||||
|
links += "\n"
|
||||||
|
}
|
||||||
|
links += url.String()
|
||||||
|
linkIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the new query values on the URL
|
return links
|
||||||
url.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, "")
|
|
||||||
return url.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
|
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
|
||||||
|
|
@ -1218,3 +1372,19 @@ func getHostFromXFH(s string) (string, error) {
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractNodeHost extracts the host from a node API address.
|
||||||
|
// Example: "http://192.168.1.100:8080" -> "192.168.1.100"
|
||||||
|
func (s *SubService) extractNodeHost(nodeAddress string) string {
|
||||||
|
// Remove protocol prefix
|
||||||
|
address := strings.TrimPrefix(nodeAddress, "http://")
|
||||||
|
address = strings.TrimPrefix(address, "https://")
|
||||||
|
|
||||||
|
// Extract host (remove port if present)
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
// No port, return as is
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,48 @@ class DBInbound {
|
||||||
this.streamSettings = "";
|
this.streamSettings = "";
|
||||||
this.tag = "";
|
this.tag = "";
|
||||||
this.sniffing = "";
|
this.sniffing = "";
|
||||||
this.clientStats = ""
|
this.clientStats = "";
|
||||||
|
this.nodeId = null; // Node ID for multi-node mode - DEPRECATED: kept only for backward compatibility, use nodeIds instead
|
||||||
|
this.nodeIds = []; // Node IDs array for multi-node mode - use this for multi-node support
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ObjectUtil.cloneProps(this, data);
|
ObjectUtil.cloneProps(this, data);
|
||||||
|
// Ensure nodeIds is always an array (even if empty)
|
||||||
|
// Priority: use nodeIds if available, otherwise convert from deprecated nodeId
|
||||||
|
// First check if nodeIds exists and is an array (even if empty)
|
||||||
|
// Handle nodeIds from API response - it should be an array
|
||||||
|
if (this.nodeIds !== null && this.nodeIds !== undefined) {
|
||||||
|
if (Array.isArray(this.nodeIds)) {
|
||||||
|
// nodeIds is already an array - ensure all values are numbers
|
||||||
|
if (this.nodeIds.length > 0) {
|
||||||
|
this.nodeIds = this.nodeIds.map(id => {
|
||||||
|
// Convert string to number if needed
|
||||||
|
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||||
|
return numId;
|
||||||
|
}).filter(id => !isNaN(id) && id > 0);
|
||||||
|
} else {
|
||||||
|
// Empty array is valid
|
||||||
|
this.nodeIds = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// nodeIds exists but is not an array - try to convert
|
||||||
|
// This shouldn't happen if API returns correct format, but handle it anyway
|
||||||
|
const nodeId = typeof this.nodeIds === 'string' ? parseInt(this.nodeIds, 10) : this.nodeIds;
|
||||||
|
this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : [];
|
||||||
|
}
|
||||||
|
} else if (this.nodeId !== null && this.nodeId !== undefined) {
|
||||||
|
// Convert deprecated nodeId to nodeIds array (backward compatibility)
|
||||||
|
const nodeId = typeof this.nodeId === 'string' ? parseInt(this.nodeId, 10) : this.nodeId;
|
||||||
|
this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : [];
|
||||||
|
} else {
|
||||||
|
// No nodes assigned - ensure empty array
|
||||||
|
this.nodeIds = [];
|
||||||
|
}
|
||||||
|
// Ensure nodeIds is never null or undefined - always an array
|
||||||
|
if (!Array.isArray(this.nodeIds)) {
|
||||||
|
this.nodeIds = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalGB() {
|
get totalGB() {
|
||||||
|
|
@ -116,6 +153,13 @@ class DBInbound {
|
||||||
sniffing: sniffing,
|
sniffing: sniffing,
|
||||||
clientStats: this.clientStats,
|
clientStats: this.clientStats,
|
||||||
};
|
};
|
||||||
|
// Include nodeIds if available (for multi-node mode)
|
||||||
|
if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) {
|
||||||
|
config.nodeIds = this.nodeIds;
|
||||||
|
} else if (this.nodeId !== null && this.nodeId !== undefined) {
|
||||||
|
// Backward compatibility: convert single nodeId to nodeIds array
|
||||||
|
config.nodeIds = [this.nodeId];
|
||||||
|
}
|
||||||
return Inbound.fromJson(config);
|
return Inbound.fromJson(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1075,6 +1075,8 @@ class Inbound extends XrayCommonClass {
|
||||||
this.tag = tag;
|
this.tag = tag;
|
||||||
this.sniffing = sniffing;
|
this.sniffing = sniffing;
|
||||||
this.clientStats = clientStats;
|
this.clientStats = clientStats;
|
||||||
|
this.nodeIds = []; // Node IDs array for multi-node mode
|
||||||
|
this.nodeId = null; // Backward compatibility
|
||||||
}
|
}
|
||||||
getClientStats() {
|
getClientStats() {
|
||||||
return this.clientStats;
|
return this.clientStats;
|
||||||
|
|
@ -1638,10 +1640,73 @@ class Inbound extends XrayCommonClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract node host from node address (e.g., "http://192.168.1.100:8080" -> "192.168.1.100")
|
||||||
|
extractNodeHost(nodeAddress) {
|
||||||
|
if (!nodeAddress) return '';
|
||||||
|
// Remove protocol prefix
|
||||||
|
let address = nodeAddress.replace(/^https?:\/\//, '');
|
||||||
|
// Extract host (remove port if present)
|
||||||
|
const parts = address.split(':');
|
||||||
|
return parts[0] || address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get node addresses from nodeIds - returns array of all node addresses
|
||||||
|
getNodeAddresses() {
|
||||||
|
// Check if we have nodeIds and availableNodes
|
||||||
|
if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get availableNodes from global app object
|
||||||
|
let availableNodes = null;
|
||||||
|
if (typeof app !== 'undefined' && app.availableNodes) {
|
||||||
|
availableNodes = app.availableNodes;
|
||||||
|
} else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) {
|
||||||
|
availableNodes = window.app.availableNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!availableNodes || availableNodes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get addresses for all node IDs
|
||||||
|
const addresses = [];
|
||||||
|
for (const nodeId of this.nodeIds) {
|
||||||
|
const node = availableNodes.find(n => n.id === nodeId);
|
||||||
|
if (node && node.address) {
|
||||||
|
const host = this.extractNodeHost(node.address);
|
||||||
|
if (host) {
|
||||||
|
addresses.push(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first node address (for backward compatibility)
|
||||||
|
getNodeAddress() {
|
||||||
|
const addresses = this.getNodeAddresses();
|
||||||
|
return addresses.length > 0 ? addresses[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
genAllLinks(remark = '', remarkModel = '-ieo', client) {
|
genAllLinks(remark = '', remarkModel = '-ieo', client) {
|
||||||
let result = [];
|
let result = [];
|
||||||
let email = client ? client.email : '';
|
let email = client ? client.email : '';
|
||||||
let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
|
|
||||||
|
// Get all node addresses
|
||||||
|
const nodeAddresses = this.getNodeAddresses();
|
||||||
|
|
||||||
|
// Determine addresses to use
|
||||||
|
let addresses = [];
|
||||||
|
if (nodeAddresses.length > 0) {
|
||||||
|
addresses = nodeAddresses;
|
||||||
|
} else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||||
|
addresses = [this.listen];
|
||||||
|
} else {
|
||||||
|
addresses = [location.hostname];
|
||||||
|
}
|
||||||
|
|
||||||
let port = this.port;
|
let port = this.port;
|
||||||
const separationChar = remarkModel.charAt(0);
|
const separationChar = remarkModel.charAt(0);
|
||||||
const orderChars = remarkModel.slice(1);
|
const orderChars = remarkModel.slice(1);
|
||||||
|
|
@ -1650,13 +1715,18 @@ class Inbound extends XrayCommonClass {
|
||||||
'e': email,
|
'e': email,
|
||||||
'o': '',
|
'o': '',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) {
|
if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) {
|
||||||
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
// Generate links for each node address
|
||||||
result.push({
|
addresses.forEach((addr) => {
|
||||||
remark: r,
|
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
||||||
link: this.genLink(addr, port, 'same', r, client)
|
result.push({
|
||||||
|
remark: r,
|
||||||
|
link: this.genLink(addr, port, 'same', r, client)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// External proxy takes precedence
|
||||||
this.stream.externalProxy.forEach((ep) => {
|
this.stream.externalProxy.forEach((ep) => {
|
||||||
orders['o'] = ep.remark;
|
orders['o'] = ep.remark;
|
||||||
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
||||||
|
|
@ -1670,7 +1740,18 @@ class Inbound extends XrayCommonClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
genInboundLinks(remark = '', remarkModel = '-ieo') {
|
genInboundLinks(remark = '', remarkModel = '-ieo') {
|
||||||
let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
|
// Get all node addresses
|
||||||
|
const nodeAddresses = this.getNodeAddresses();
|
||||||
|
|
||||||
|
// Determine addresses to use
|
||||||
|
let addresses = [];
|
||||||
|
if (nodeAddresses.length > 0) {
|
||||||
|
addresses = nodeAddresses;
|
||||||
|
} else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||||
|
addresses = [this.listen];
|
||||||
|
} else {
|
||||||
|
addresses = [location.hostname];
|
||||||
|
}
|
||||||
if (this.clients) {
|
if (this.clients) {
|
||||||
let links = [];
|
let links = [];
|
||||||
this.clients.forEach((client) => {
|
this.clients.forEach((client) => {
|
||||||
|
|
@ -1680,11 +1761,20 @@ class Inbound extends XrayCommonClass {
|
||||||
});
|
});
|
||||||
return links.join('\r\n');
|
return links.join('\r\n');
|
||||||
} else {
|
} else {
|
||||||
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
|
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) {
|
||||||
|
// Generate links for each node address
|
||||||
|
let links = [];
|
||||||
|
addresses.forEach((addr) => {
|
||||||
|
links.push(this.genSSLink(addr, this.port, 'same', remark));
|
||||||
|
});
|
||||||
|
return links.join('\r\n');
|
||||||
|
}
|
||||||
if (this.protocol == Protocols.WIREGUARD) {
|
if (this.protocol == Protocols.WIREGUARD) {
|
||||||
let links = [];
|
let links = [];
|
||||||
this.settings.peers.forEach((p, index) => {
|
addresses.forEach((addr) => {
|
||||||
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
|
this.settings.peers.forEach((p, index) => {
|
||||||
|
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return links.join('\r\n');
|
return links.join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
@ -1693,7 +1783,7 @@ class Inbound extends XrayCommonClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
return new Inbound(
|
const inbound = new Inbound(
|
||||||
json.port,
|
json.port,
|
||||||
json.listen,
|
json.listen,
|
||||||
json.protocol,
|
json.protocol,
|
||||||
|
|
@ -1702,7 +1792,14 @@ class Inbound extends XrayCommonClass {
|
||||||
json.tag,
|
json.tag,
|
||||||
Sniffing.fromJson(json.sniffing),
|
Sniffing.fromJson(json.sniffing),
|
||||||
json.clientStats
|
json.clientStats
|
||||||
)
|
);
|
||||||
|
// Restore nodeIds if present
|
||||||
|
if (json.nodeIds && Array.isArray(json.nodeIds)) {
|
||||||
|
inbound.nodeIds = json.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id);
|
||||||
|
} else if (json.nodeId !== null && json.nodeId !== undefined) {
|
||||||
|
inbound.nodeIds = [typeof json.nodeId === 'string' ? parseInt(json.nodeId, 10) : json.nodeId];
|
||||||
|
}
|
||||||
|
return inbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
|
|
@ -1710,7 +1807,7 @@ class Inbound extends XrayCommonClass {
|
||||||
if (this.canEnableStream() || this.stream?.sockopt) {
|
if (this.canEnableStream() || this.stream?.sockopt) {
|
||||||
streamSettings = this.stream.toJson();
|
streamSettings = this.stream.toJson();
|
||||||
}
|
}
|
||||||
return {
|
const result = {
|
||||||
port: this.port,
|
port: this.port,
|
||||||
listen: this.listen,
|
listen: this.listen,
|
||||||
protocol: this.protocol,
|
protocol: this.protocol,
|
||||||
|
|
@ -1720,6 +1817,11 @@ class Inbound extends XrayCommonClass {
|
||||||
sniffing: this.sniffing.toJson(),
|
sniffing: this.sniffing.toJson(),
|
||||||
clientStats: this.clientStats
|
clientStats: this.clientStats
|
||||||
};
|
};
|
||||||
|
// Include nodeIds if present
|
||||||
|
if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) {
|
||||||
|
result.nodeIds = this.nodeIds;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
82
web/assets/js/model/node.js
Normal file
82
web/assets/js/model/node.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
class Node {
|
||||||
|
constructor(data) {
|
||||||
|
this.id = 0;
|
||||||
|
this.name = "";
|
||||||
|
this.address = "";
|
||||||
|
this.apiKey = "";
|
||||||
|
this.status = "unknown";
|
||||||
|
this.lastCheck = 0;
|
||||||
|
this.createdAt = 0;
|
||||||
|
this.updatedAt = 0;
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectUtil.cloneProps(this, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOnline() {
|
||||||
|
return this.status === "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOffline() {
|
||||||
|
return this.status === "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isError() {
|
||||||
|
return this.status === "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isUnknown() {
|
||||||
|
return this.status === "unknown" || !this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusColor() {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'online': return 'green';
|
||||||
|
case 'offline': return 'red';
|
||||||
|
case 'error': return 'red';
|
||||||
|
default: return 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusIcon() {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'online': return 'check-circle';
|
||||||
|
case 'offline': return 'close-circle';
|
||||||
|
case 'error': return 'exclamation-circle';
|
||||||
|
default: return 'question-circle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedLastCheck() {
|
||||||
|
if (!this.lastCheck || this.lastCheck === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const date = new Date(this.lastCheck * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
address: this.address,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
status: this.status,
|
||||||
|
lastCheck: this.lastCheck,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json) {
|
||||||
|
return new Node(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,10 +72,24 @@ class AllSetting {
|
||||||
this.ldapDefaultExpiryDays = 0;
|
this.ldapDefaultExpiryDays = 0;
|
||||||
this.ldapDefaultLimitIP = 0;
|
this.ldapDefaultLimitIP = 0;
|
||||||
|
|
||||||
|
// Multi-node mode settings
|
||||||
|
this.multiNodeMode = false; // Multi-node mode setting
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ObjectUtil.cloneProps(this, data);
|
ObjectUtil.cloneProps(this, data);
|
||||||
|
|
||||||
|
// Ensure multiNodeMode is boolean (handle string "true"/"false" from backend)
|
||||||
|
if (this.multiNodeMode !== undefined && this.multiNodeMode !== null) {
|
||||||
|
if (typeof this.multiNodeMode === 'string') {
|
||||||
|
this.multiNodeMode = this.multiNodeMode === 'true' || this.multiNodeMode === '1';
|
||||||
|
} else {
|
||||||
|
this.multiNodeMode = Boolean(this.multiNodeMode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.multiNodeMode = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other) {
|
equals(other) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
@ -106,9 +107,11 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
err := c.ShouldBind(inbound)
|
err := c.ShouldBind(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to bind inbound data: %v", err)
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
inbound.UserId = user.Id
|
inbound.UserId = user.Id
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||||
|
|
@ -119,9 +122,49 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
|
|
||||||
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to add inbound: %v", err)
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle node assignment in multi-node mode
|
||||||
|
nodeService := service.NodeService{}
|
||||||
|
|
||||||
|
// Get nodeIds from form (array format: nodeIds=1&nodeIds=2)
|
||||||
|
nodeIdsStr := c.PostFormArray("nodeIds")
|
||||||
|
logger.Debugf("Received nodeIds from form: %v", nodeIdsStr)
|
||||||
|
|
||||||
|
// Check if nodeIds array was provided (even if empty)
|
||||||
|
nodeIdStr := c.PostForm("nodeId")
|
||||||
|
if len(nodeIdsStr) > 0 || nodeIdStr != "" {
|
||||||
|
// Multi-node mode: parse nodeIds array
|
||||||
|
nodeIds := make([]int, 0)
|
||||||
|
for _, idStr := range nodeIdsStr {
|
||||||
|
if idStr != "" {
|
||||||
|
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
|
||||||
|
nodeIds = append(nodeIds, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodeIds) > 0 {
|
||||||
|
// Assign to multiple nodes
|
||||||
|
if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil {
|
||||||
|
logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err)
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if nodeIdStr != "" && nodeIdStr != "null" {
|
||||||
|
// Backward compatibility: single nodeId
|
||||||
|
nodeId, err := strconv.Atoi(nodeIdStr)
|
||||||
|
if err == nil && nodeId > 0 {
|
||||||
|
if err := nodeService.AssignInboundToNode(inbound.Id, nodeId); err != nil {
|
||||||
|
logger.Warningf("Failed to assign inbound %d to node %d: %v", inbound.Id, nodeId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
|
|
@ -160,19 +203,87 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get nodeIds from form BEFORE binding to avoid conflict with ShouldBind
|
||||||
|
// Get nodeIds from form (array format: nodeIds=1&nodeIds=2)
|
||||||
|
nodeIdsStr := c.PostFormArray("nodeIds")
|
||||||
|
logger.Debugf("Received nodeIds from form: %v (count: %d)", nodeIdsStr, len(nodeIdsStr))
|
||||||
|
|
||||||
|
// Check if nodeIds array was provided
|
||||||
|
nodeIdStr := c.PostForm("nodeId")
|
||||||
|
logger.Debugf("Received nodeId from form: %s", nodeIdStr)
|
||||||
|
|
||||||
|
// Check if nodeIds or nodeId was explicitly provided in the form
|
||||||
|
_, hasNodeIds := c.GetPostForm("nodeIds")
|
||||||
|
_, hasNodeId := c.GetPostForm("nodeId")
|
||||||
|
logger.Debugf("Form has nodeIds: %v, has nodeId: %v", hasNodeIds, hasNodeId)
|
||||||
|
|
||||||
inbound := &model.Inbound{
|
inbound := &model.Inbound{
|
||||||
Id: id,
|
Id: id,
|
||||||
}
|
}
|
||||||
|
// Bind inbound data (nodeIds will be ignored since we handle it separately)
|
||||||
err = c.ShouldBind(inbound)
|
err = c.ShouldBind(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to bind inbound data: %v", err)
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
|
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to update inbound: %v", err)
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle node assignment in multi-node mode
|
||||||
|
nodeService := service.NodeService{}
|
||||||
|
|
||||||
|
if hasNodeIds || hasNodeId {
|
||||||
|
// Multi-node mode: parse nodeIds array
|
||||||
|
nodeIds := make([]int, 0)
|
||||||
|
for _, idStr := range nodeIdsStr {
|
||||||
|
if idStr != "" {
|
||||||
|
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
|
||||||
|
nodeIds = append(nodeIds, id)
|
||||||
|
} else {
|
||||||
|
logger.Warningf("Invalid nodeId in array: %s (error: %v)", idStr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Parsed nodeIds: %v", nodeIds)
|
||||||
|
|
||||||
|
if len(nodeIds) > 0 {
|
||||||
|
// Assign to multiple nodes
|
||||||
|
if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil {
|
||||||
|
logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err)
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debugf("Successfully assigned inbound %d to nodes %v", inbound.Id, nodeIds)
|
||||||
|
} else if nodeIdStr != "" && nodeIdStr != "null" {
|
||||||
|
// Backward compatibility: single nodeId
|
||||||
|
nodeId, err := strconv.Atoi(nodeIdStr)
|
||||||
|
if err == nil && nodeId > 0 {
|
||||||
|
if err := nodeService.AssignInboundToNode(inbound.Id, nodeId); err != nil {
|
||||||
|
logger.Warningf("Failed to assign inbound %d to node %d: %v", inbound.Id, nodeId, err)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Successfully assigned inbound %d to node %d", inbound.Id, nodeId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warningf("Invalid nodeId: %s (error: %v)", nodeIdStr, err)
|
||||||
|
}
|
||||||
|
} else if hasNodeIds {
|
||||||
|
// nodeIds was explicitly provided but is empty - unassign all
|
||||||
|
if err := nodeService.UnassignInboundFromNode(inbound.Id); err != nil {
|
||||||
|
logger.Warningf("Failed to unassign inbound %d from nodes: %v", inbound.Id, err)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Successfully unassigned inbound %d from all nodes", inbound.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If neither nodeIds nor nodeId was provided, don't change assignments
|
||||||
|
}
|
||||||
|
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
|
|
@ -367,7 +478,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) {
|
||||||
|
|
||||||
// onlines retrieves the list of currently online clients.
|
// onlines retrieves the list of currently online clients.
|
||||||
func (a *InboundController) onlines(c *gin.Context) {
|
func (a *InboundController) onlines(c *gin.Context) {
|
||||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
clients := a.inboundService.GetOnlineClients()
|
||||||
|
jsonObj(c, clients, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// lastOnline retrieves the last online timestamps for clients.
|
// lastOnline retrieves the last online timestamps for clients.
|
||||||
|
|
|
||||||
225
web/controller/node.go
Normal file
225
web/controller/node.go
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// Package controller provides HTTP handlers for node management in multi-node mode.
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeController handles HTTP requests related to node management.
|
||||||
|
type NodeController struct {
|
||||||
|
nodeService service.NodeService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNodeController creates a new NodeController and sets up its routes.
|
||||||
|
func NewNodeController(g *gin.RouterGroup) *NodeController {
|
||||||
|
a := &NodeController{
|
||||||
|
nodeService: service.NodeService{},
|
||||||
|
}
|
||||||
|
a.initRouter(g)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// initRouter initializes the routes for node-related operations.
|
||||||
|
func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g.GET("/list", a.getNodes)
|
||||||
|
g.GET("/get/:id", a.getNode)
|
||||||
|
g.POST("/add", a.addNode)
|
||||||
|
g.POST("/update/:id", a.updateNode)
|
||||||
|
g.POST("/del/:id", a.deleteNode)
|
||||||
|
g.POST("/check/:id", a.checkNode)
|
||||||
|
g.POST("/checkAll", a.checkAllNodes)
|
||||||
|
g.GET("/status/:id", a.getNodeStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNodes retrieves the list of all nodes.
|
||||||
|
func (a *NodeController) getNodes(c *gin.Context) {
|
||||||
|
nodes, err := a.nodeService.GetAllNodes()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to get nodes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich nodes with assigned inbounds information
|
||||||
|
type NodeWithInbounds struct {
|
||||||
|
*model.Node
|
||||||
|
Inbounds []*model.Inbound `json:"inbounds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]NodeWithInbounds, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
inbounds, _ := a.nodeService.GetInboundsForNode(node.Id)
|
||||||
|
result = append(result, NodeWithInbounds{
|
||||||
|
Node: node,
|
||||||
|
Inbounds: inbounds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObj(c, result, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNode retrieves a specific node by its ID.
|
||||||
|
func (a *NodeController) getNode(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := a.nodeService.GetNode(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to get node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, node, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addNode creates a new node.
|
||||||
|
func (a *NodeController) addNode(c *gin.Context) {
|
||||||
|
node := &model.Node{}
|
||||||
|
err := c.ShouldBind(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log received data for debugging
|
||||||
|
logger.Debugf("Adding node: name=%s, address=%s, apiKey=%s", node.Name, node.Address, node.ApiKey)
|
||||||
|
|
||||||
|
// Validate API key before saving
|
||||||
|
err = a.nodeService.ValidateApiKey(node)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("API key validation failed for node %s: %v", node.Address, err)
|
||||||
|
jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default status
|
||||||
|
if node.Status == "" {
|
||||||
|
node.Status = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.nodeService.AddNode(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to add node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check health immediately
|
||||||
|
go a.nodeService.CheckNodeHealth(node)
|
||||||
|
|
||||||
|
jsonMsgObj(c, "Node added successfully", node, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateNode updates an existing node.
|
||||||
|
func (a *NodeController) updateNode(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node := &model.Node{Id: id}
|
||||||
|
err = c.ShouldBind(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing node to check if API key changed
|
||||||
|
existingNode, err := a.nodeService.GetNode(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to get existing node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate API key if it was changed or address was changed
|
||||||
|
if node.ApiKey != "" && (node.ApiKey != existingNode.ApiKey || node.Address != existingNode.Address) {
|
||||||
|
err = a.nodeService.ValidateApiKey(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.nodeService.UpdateNode(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to update node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonMsgObj(c, "Node updated successfully", node, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteNode deletes a node by its ID.
|
||||||
|
func (a *NodeController) deleteNode(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.nodeService.DeleteNode(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to delete node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonMsg(c, "Node deleted successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNode checks the health of a specific node.
|
||||||
|
func (a *NodeController) checkNode(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := a.nodeService.GetNode(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to get node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.nodeService.CheckNodeHealth(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Node health check failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonMsgObj(c, "Node health check completed", node, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAllNodes checks the health of all nodes.
|
||||||
|
func (a *NodeController) checkAllNodes(c *gin.Context) {
|
||||||
|
a.nodeService.CheckAllNodesHealth()
|
||||||
|
jsonMsg(c, "Health check initiated for all nodes", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNodeStatus retrieves the detailed status of a node.
|
||||||
|
func (a *NodeController) getNodeStatus(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Invalid node ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := a.nodeService.GetNode(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to get node", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := a.nodeService.GetNodeStatus(node)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, "Failed to get node status", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObj(c, status, nil)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ type XUIController struct {
|
||||||
|
|
||||||
settingController *SettingController
|
settingController *SettingController
|
||||||
xraySettingController *XraySettingController
|
xraySettingController *XraySettingController
|
||||||
|
nodeController *NodeController
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewXUIController creates a new XUIController and initializes its routes.
|
// NewXUIController creates a new XUIController and initializes its routes.
|
||||||
|
|
@ -28,9 +29,11 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/inbounds", a.inbounds)
|
g.GET("/inbounds", a.inbounds)
|
||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
g.GET("/nodes", a.nodes)
|
||||||
|
|
||||||
a.settingController = NewSettingController(g)
|
a.settingController = NewSettingController(g)
|
||||||
a.xraySettingController = NewXraySettingController(g)
|
a.xraySettingController = NewXraySettingController(g)
|
||||||
|
a.nodeController = NewNodeController(g.Group("/node"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// index renders the main panel index page.
|
// index renders the main panel index page.
|
||||||
|
|
@ -52,3 +55,8 @@ func (a *XUIController) settings(c *gin.Context) {
|
||||||
func (a *XUIController) xraySettings(c *gin.Context) {
|
func (a *XUIController) xraySettings(c *gin.Context) {
|
||||||
html(c, "xray.html", "pages.xray.title", nil)
|
html(c, "xray.html", "pages.xray.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodes renders the nodes management page (multi-node mode).
|
||||||
|
func (a *XUIController) nodes(c *gin.Context) {
|
||||||
|
html(c, "nodes.html", "pages.nodes.title", nil)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,9 @@ type AllSetting struct {
|
||||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
|
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
|
||||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
|
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
|
||||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
|
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
|
||||||
|
|
||||||
|
// Multi-node mode setting
|
||||||
|
MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode
|
||||||
// JSON subscription routing rules
|
// JSON subscription routing rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,29 @@
|
||||||
Vue.component('a-sidebar', {
|
Vue.component('a-sidebar', {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tabs: [
|
tabs: [],
|
||||||
|
activeTab: [
|
||||||
|
'{{ .request_uri }}'
|
||||||
|
],
|
||||||
|
visible: false,
|
||||||
|
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
|
||||||
|
multiNodeMode: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadMultiNodeMode() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post("/panel/setting/all");
|
||||||
|
if (msg && msg.success && msg.obj) {
|
||||||
|
this.multiNodeMode = msg.obj.multiNodeMode || false;
|
||||||
|
this.updateTabs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load multi-node mode:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateTabs() {
|
||||||
|
this.tabs = [
|
||||||
{
|
{
|
||||||
key: '{{ .base_path }}panel/',
|
key: '{{ .base_path }}panel/',
|
||||||
icon: 'dashboard',
|
icon: 'dashboard',
|
||||||
|
|
@ -63,21 +85,24 @@
|
||||||
key: '{{ .base_path }}panel/xray',
|
key: '{{ .base_path }}panel/xray',
|
||||||
icon: 'tool',
|
icon: 'tool',
|
||||||
title: '{{ i18n "menu.xray"}}'
|
title: '{{ i18n "menu.xray"}}'
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
key: '{{ .base_path }}logout/',
|
|
||||||
icon: 'logout',
|
// Add Nodes menu item if multi-node mode is enabled
|
||||||
title: '{{ i18n "menu.logout"}}'
|
if (this.multiNodeMode) {
|
||||||
},
|
this.tabs.splice(3, 0, {
|
||||||
],
|
key: '{{ .base_path }}panel/nodes',
|
||||||
activeTab: [
|
icon: 'cluster',
|
||||||
'{{ .request_uri }}'
|
title: '{{ i18n "menu.nodes"}}'
|
||||||
],
|
});
|
||||||
visible: false,
|
}
|
||||||
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
|
|
||||||
}
|
this.tabs.push({
|
||||||
},
|
key: '{{ .base_path }}logout/',
|
||||||
methods: {
|
icon: 'logout',
|
||||||
|
title: '{{ i18n "menu.logout"}}'
|
||||||
|
});
|
||||||
|
},
|
||||||
openLink(key) {
|
openLink(key) {
|
||||||
return key.startsWith('http') ?
|
return key.startsWith('http') ?
|
||||||
window.open(key) :
|
window.open(key) :
|
||||||
|
|
@ -97,6 +122,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateTabs();
|
||||||
|
this.loadMultiNodeMode();
|
||||||
|
// Watch for multi-node mode changes
|
||||||
|
setInterval(() => {
|
||||||
|
this.loadMultiNodeMode();
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
template: `{{template "component/sidebar/content"}}`,
|
template: `{{template "component/sidebar/content"}}`,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,26 @@
|
||||||
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
|
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="multiNodeMode" label="Nodes">
|
||||||
|
<template slot="extra">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
Select worker nodes where this inbound will run. You can select multiple nodes. Only available in multi-node mode.
|
||||||
|
</template>
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-select v-model="inbound.nodeIds" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
placeholder="Select nodes (optional)" allow-clear>
|
||||||
|
<a-select-option v-for="node in availableNodes" :key="node.id" :value="node.id">
|
||||||
|
[[ node.name ]] <a-tag :color="node.status === 'online' ? 'green' : 'red'" size="small" style="margin-left: 8px;">[[ node.status ]]</a-tag>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<div v-if="availableNodes.length === 0" style="margin-top: 4px; color: #ff4d4f; font-size: 12px;">
|
||||||
|
No nodes available. Please add nodes first.
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,19 @@
|
||||||
<a-tag color="blue">[[ dbInbound.trafficReset ]]</a-tag>
|
<a-tag color="blue">[[ dbInbound.trafficReset ]]</a-tag>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="multiNodeMode && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0">
|
||||||
|
<td>Nodes</td>
|
||||||
|
<td>
|
||||||
|
<template v-for="(nodeId, index) in dbInbound.nodeIds" :key="nodeId">
|
||||||
|
<a-tag v-if="getNodeName(nodeId)" color="blue" :style="{ margin: '0 4px 4px 0' }">
|
||||||
|
[[ getNodeName(nodeId) ]]
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="gray" :style="{ margin: '0 4px 4px 0' }">
|
||||||
|
Node [[ nodeId ]]
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<a-badge>
|
<a-badge>
|
||||||
|
|
@ -706,6 +719,8 @@
|
||||||
el: '#app',
|
el: '#app',
|
||||||
mixins: [MediaQueryMixin],
|
mixins: [MediaQueryMixin],
|
||||||
data: {
|
data: {
|
||||||
|
availableNodes: [],
|
||||||
|
multiNodeMode: false,
|
||||||
themeSwitcher,
|
themeSwitcher,
|
||||||
persianDatepicker,
|
persianDatepicker,
|
||||||
loadingStates: {
|
loadingStates: {
|
||||||
|
|
@ -746,6 +761,44 @@
|
||||||
loading(spinning = true) {
|
loading(spinning = true) {
|
||||||
this.loadingStates.spinning = spinning;
|
this.loadingStates.spinning = spinning;
|
||||||
},
|
},
|
||||||
|
async loadMultiNodeMode() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post("/panel/setting/all");
|
||||||
|
if (msg && msg.success && msg.obj) {
|
||||||
|
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
|
||||||
|
// Store in allSetting for modal access
|
||||||
|
if (!this.allSetting) {
|
||||||
|
this.allSetting = {};
|
||||||
|
}
|
||||||
|
this.allSetting.multiNodeMode = this.multiNodeMode;
|
||||||
|
// Load available nodes if in multi-node mode
|
||||||
|
if (this.multiNodeMode) {
|
||||||
|
await this.loadAvailableNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load multi-node mode:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadAvailableNodes() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get("/panel/node/list");
|
||||||
|
if (msg && msg.success && msg.obj) {
|
||||||
|
this.availableNodes = msg.obj.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
address: node.address,
|
||||||
|
status: node.status || 'unknown'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load available nodes:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getNodeName(nodeId) {
|
||||||
|
const node = this.availableNodes.find(n => n.id === nodeId);
|
||||||
|
return node ? node.name : null;
|
||||||
|
},
|
||||||
async getDBInbounds() {
|
async getDBInbounds() {
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||||
|
|
@ -804,6 +857,11 @@
|
||||||
this.clientCount.splice(0);
|
this.clientCount.splice(0);
|
||||||
for (const inbound of dbInbounds) {
|
for (const inbound of dbInbounds) {
|
||||||
const dbInbound = new DBInbound(inbound);
|
const dbInbound = new DBInbound(inbound);
|
||||||
|
// Ensure nodeIds are properly set after creating DBInbound
|
||||||
|
// The constructor should handle this, but double-check
|
||||||
|
if (!Array.isArray(dbInbound.nodeIds)) {
|
||||||
|
dbInbound.nodeIds = [];
|
||||||
|
}
|
||||||
to_inbound = dbInbound.toInbound()
|
to_inbound = dbInbound.toInbound()
|
||||||
this.inbounds.push(to_inbound);
|
this.inbounds.push(to_inbound);
|
||||||
this.dbInbounds.push(dbInbound);
|
this.dbInbounds.push(dbInbound);
|
||||||
|
|
@ -1041,6 +1099,20 @@
|
||||||
openEditInbound(dbInboundId) {
|
openEditInbound(dbInboundId) {
|
||||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||||
const inbound = dbInbound.toInbound();
|
const inbound = dbInbound.toInbound();
|
||||||
|
// Set nodeIds from dbInbound if available - ensure they are numbers
|
||||||
|
// This is critical: dbInbound is the source of truth for nodeIds
|
||||||
|
let nodeIdsToSet = [];
|
||||||
|
if (dbInbound.nodeIds && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0) {
|
||||||
|
nodeIdsToSet = dbInbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
|
||||||
|
} else if (dbInbound.nodeId !== null && dbInbound.nodeId !== undefined) {
|
||||||
|
// Backward compatibility: single nodeId
|
||||||
|
const nodeId = typeof dbInbound.nodeId === 'string' ? parseInt(dbInbound.nodeId, 10) : dbInbound.nodeId;
|
||||||
|
if (!isNaN(nodeId) && nodeId > 0) {
|
||||||
|
nodeIdsToSet = [nodeId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure nodeIds are set on inbound object before passing to modal
|
||||||
|
inbound.nodeIds = nodeIdsToSet;
|
||||||
inModal.show({
|
inModal.show({
|
||||||
title: '{{ i18n "pages.inbounds.modifyInbound"}}',
|
title: '{{ i18n "pages.inbounds.modifyInbound"}}',
|
||||||
okText: '{{ i18n "update"}}',
|
okText: '{{ i18n "update"}}',
|
||||||
|
|
@ -1075,6 +1147,14 @@
|
||||||
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
||||||
}
|
}
|
||||||
data.sniffing = inbound.sniffing.toString();
|
data.sniffing = inbound.sniffing.toString();
|
||||||
|
|
||||||
|
// Add nodeIds if multi-node mode is enabled
|
||||||
|
if (this.multiNodeMode && inbound.nodeIds && Array.isArray(inbound.nodeIds) && inbound.nodeIds.length > 0) {
|
||||||
|
data.nodeIds = inbound.nodeIds;
|
||||||
|
} else if (this.multiNodeMode && inbound.nodeId) {
|
||||||
|
// Backward compatibility: single nodeId
|
||||||
|
data.nodeId = inbound.nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
await this.submit('/panel/api/inbounds/add', data, inModal);
|
await this.submit('/panel/api/inbounds/add', data, inModal);
|
||||||
},
|
},
|
||||||
|
|
@ -1100,6 +1180,21 @@
|
||||||
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
||||||
}
|
}
|
||||||
data.sniffing = inbound.sniffing.toString();
|
data.sniffing = inbound.sniffing.toString();
|
||||||
|
|
||||||
|
// Add nodeIds if multi-node mode is enabled
|
||||||
|
if (this.multiNodeMode) {
|
||||||
|
if (inbound.nodeIds && Array.isArray(inbound.nodeIds) && inbound.nodeIds.length > 0) {
|
||||||
|
// Ensure all values are numbers
|
||||||
|
data.nodeIds = inbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
|
||||||
|
} else if (inbound.nodeId !== null && inbound.nodeId !== undefined) {
|
||||||
|
// Backward compatibility: single nodeId
|
||||||
|
const nodeId = typeof inbound.nodeId === 'string' ? parseInt(inbound.nodeId, 10) : inbound.nodeId;
|
||||||
|
if (!isNaN(nodeId) && nodeId > 0) {
|
||||||
|
data.nodeId = nodeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no nodes selected, don't send nodeIds field at all - server will handle unassignment
|
||||||
|
}
|
||||||
|
|
||||||
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
|
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
|
||||||
},
|
},
|
||||||
|
|
@ -1252,6 +1347,10 @@
|
||||||
},
|
},
|
||||||
checkFallback(dbInbound) {
|
checkFallback(dbInbound) {
|
||||||
newDbInbound = new DBInbound(dbInbound);
|
newDbInbound = new DBInbound(dbInbound);
|
||||||
|
// Ensure nodeIds are preserved when creating new DBInbound
|
||||||
|
if (dbInbound.nodeIds && Array.isArray(dbInbound.nodeIds)) {
|
||||||
|
newDbInbound.nodeIds = dbInbound.nodeIds;
|
||||||
|
}
|
||||||
if (dbInbound.listen.startsWith("@")) {
|
if (dbInbound.listen.startsWith("@")) {
|
||||||
rootInbound = this.inbounds.find((i) =>
|
rootInbound = this.inbounds.find((i) =>
|
||||||
i.isTcp &&
|
i.isTcp &&
|
||||||
|
|
@ -1312,7 +1411,10 @@
|
||||||
async submit(url, data, modal) {
|
async submit(url, data, modal) {
|
||||||
const msg = await HttpUtil.postWithModal(url, data, modal);
|
const msg = await HttpUtil.postWithModal(url, data, modal);
|
||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
|
// Force reload inbounds to get updated nodeIds from server
|
||||||
await this.getDBInbounds();
|
await this.getDBInbounds();
|
||||||
|
// Force Vue to update the view
|
||||||
|
this.$forceUpdate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getInboundClients(dbInbound) {
|
getInboundClients(dbInbound) {
|
||||||
|
|
@ -1581,7 +1683,8 @@
|
||||||
this.searchInbounds(newVal);
|
this.searchInbounds(newVal);
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
|
await this.loadMultiNodeMode();
|
||||||
if (window.location.protocol !== "https:") {
|
if (window.location.protocol !== "https:") {
|
||||||
this.showAlert = true;
|
this.showAlert = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@
|
||||||
<a-tag>[[ dbInbound.port ]]</a-tag>
|
<a-tag>[[ dbInbound.port ]]</a-tag>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="multiNodeMode && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0">
|
||||||
|
<td>Nodes</td>
|
||||||
|
<td>
|
||||||
|
<template v-for="(nodeId, index) in dbInbound.nodeIds" :key="nodeId">
|
||||||
|
<a-tag v-if="getNodeName(nodeId)" color="blue" :style="{ margin: '0 4px 4px 0' }">
|
||||||
|
[[ getNodeName(nodeId) ]]
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="gray" :style="{ margin: '0 4px 4px 0' }">
|
||||||
|
Node [[ nodeId ]]
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="12">
|
<a-col :xs="24" :md="12">
|
||||||
|
|
@ -508,8 +521,17 @@
|
||||||
clientIps: '',
|
clientIps: '',
|
||||||
show(dbInbound, index) {
|
show(dbInbound, index) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.inbound = dbInbound.toInbound();
|
// Create DBInbound first to ensure nodeIds are properly processed
|
||||||
this.dbInbound = new DBInbound(dbInbound);
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
|
// Ensure nodeIds are properly set - they should be an array
|
||||||
|
if (!Array.isArray(this.dbInbound.nodeIds)) {
|
||||||
|
this.dbInbound.nodeIds = [];
|
||||||
|
}
|
||||||
|
this.inbound = this.dbInbound.toInbound();
|
||||||
|
// Ensure inbound also has nodeIds from dbInbound
|
||||||
|
if (this.dbInbound.nodeIds && Array.isArray(this.dbInbound.nodeIds) && this.dbInbound.nodeIds.length > 0) {
|
||||||
|
this.inbound.nodeIds = this.dbInbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
|
||||||
|
}
|
||||||
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
|
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
|
||||||
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
|
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
|
||||||
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
|
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
|
||||||
|
|
@ -563,6 +585,12 @@
|
||||||
get inbound() {
|
get inbound() {
|
||||||
return this.infoModal.inbound;
|
return this.infoModal.inbound;
|
||||||
},
|
},
|
||||||
|
get multiNodeMode() {
|
||||||
|
return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false;
|
||||||
|
},
|
||||||
|
get availableNodes() {
|
||||||
|
return app && app.availableNodes || [];
|
||||||
|
},
|
||||||
get isActive() {
|
get isActive() {
|
||||||
if (infoModal.clientStats) {
|
if (infoModal.clientStats) {
|
||||||
return infoModal.clientStats.enable;
|
return infoModal.clientStats.enable;
|
||||||
|
|
@ -629,6 +657,10 @@
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
|
getNodeName(nodeId) {
|
||||||
|
const node = this.availableNodes.find(n => n.id === nodeId);
|
||||||
|
return node ? node.name : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@
|
||||||
show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) {
|
show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.okText = okText;
|
this.okText = okText;
|
||||||
|
|
||||||
if (inbound) {
|
if (inbound) {
|
||||||
this.inbound = Inbound.fromJson(inbound.toJson());
|
this.inbound = Inbound.fromJson(inbound.toJson());
|
||||||
} else {
|
} else {
|
||||||
this.inbound = new Inbound();
|
this.inbound = new Inbound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
||||||
// This ensures Vue reactivity works properly
|
// This ensures Vue reactivity works properly
|
||||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
||||||
|
|
@ -35,14 +37,42 @@
|
||||||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbInbound) {
|
if (dbInbound) {
|
||||||
this.dbInbound = new DBInbound(dbInbound);
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
} else {
|
} else {
|
||||||
this.dbInbound = new DBInbound();
|
this.dbInbound = new DBInbound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set nodeIds - ensure it's always an array for Vue reactivity
|
||||||
|
let nodeIdsToSet = [];
|
||||||
|
if (dbInbound) {
|
||||||
|
const dbInboundObj = new DBInbound(dbInbound);
|
||||||
|
if (dbInboundObj.nodeIds && Array.isArray(dbInboundObj.nodeIds) && dbInboundObj.nodeIds.length > 0) {
|
||||||
|
nodeIdsToSet = dbInboundObj.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
|
||||||
|
} else if (dbInboundObj.nodeId !== null && dbInboundObj.nodeId !== undefined) {
|
||||||
|
const nodeId = typeof dbInboundObj.nodeId === 'string' ? parseInt(dbInboundObj.nodeId, 10) : dbInboundObj.nodeId;
|
||||||
|
if (!isNaN(nodeId) && nodeId > 0) {
|
||||||
|
nodeIdsToSet = [nodeId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (inbound && inbound.nodeIds && Array.isArray(inbound.nodeIds)) {
|
||||||
|
// Use nodeIds from inbound if dbInbound is not provided
|
||||||
|
nodeIdsToSet = inbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set nodeIds directly first
|
||||||
|
this.inbound.nodeIds = nodeIdsToSet;
|
||||||
|
|
||||||
this.confirm = confirm;
|
this.confirm = confirm;
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
this.isEdit = isEdit;
|
this.isEdit = isEdit;
|
||||||
|
|
||||||
|
// Ensure Vue reactivity - inModal is in Vue's data, so we can use $set on inModal.inbound
|
||||||
|
if (inboundModalVueInstance && inboundModalVueInstance.$set) {
|
||||||
|
// Use $set to ensure Vue tracks nodeIds property on the inbound object
|
||||||
|
inboundModalVueInstance.$set(inModal.inbound, 'nodeIds', nodeIdsToSet);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
inModal.visible = false;
|
inModal.visible = false;
|
||||||
|
|
@ -108,6 +138,12 @@
|
||||||
get datepicker() {
|
get datepicker() {
|
||||||
return app.datepicker;
|
return app.datepicker;
|
||||||
},
|
},
|
||||||
|
get multiNodeMode() {
|
||||||
|
return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false;
|
||||||
|
},
|
||||||
|
get availableNodes() {
|
||||||
|
return app && app.availableNodes || [];
|
||||||
|
},
|
||||||
get delayedExpireDays() {
|
get delayedExpireDays() {
|
||||||
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
|
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
232
web/html/modals/node_modal.html
Normal file
232
web/html/modals/node_modal.html
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
{{define "modals/nodeModal"}}
|
||||||
|
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title" :confirm-loading="nodeModal.confirmLoading"
|
||||||
|
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :class="themeSwitcher.currentTheme" :ok-text="nodeModal.okText" :width="600">
|
||||||
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
|
<a-form-item :label='{{ i18n "pages.nodes.nodeName" }}'>
|
||||||
|
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label='{{ i18n "pages.nodes.nodeAddress" }}'>
|
||||||
|
<a-input v-model.trim="nodeModal.formData.address" placeholder="http://192.168.1.100:8080"></a-input>
|
||||||
|
<div style="margin-top: 4px; color: #999; font-size: 12px;">
|
||||||
|
{{ i18n "pages.nodes.fullUrlHint" }}
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label='{{ i18n "pages.nodes.nodeApiKey" }}'>
|
||||||
|
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder="{{ i18n "pages.nodes.enterApiKey" }}"></a-input>
|
||||||
|
<div style="margin-top: 4px; color: #999; font-size: 12px;">
|
||||||
|
{{ i18n "pages.nodes.apiKeyHint" }}
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
<script>
|
||||||
|
// Make nodeModal globally available to ensure it works with any base path
|
||||||
|
const nodeModal = window.nodeModal = {
|
||||||
|
visible: false,
|
||||||
|
title: '',
|
||||||
|
confirmLoading: false,
|
||||||
|
okText: '{{ i18n "sure" }}',
|
||||||
|
isEdit: false,
|
||||||
|
currentNode: null,
|
||||||
|
confirm: null,
|
||||||
|
formData: {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
apiKey: ''
|
||||||
|
},
|
||||||
|
ok() {
|
||||||
|
// Validate form data
|
||||||
|
if (!this.formData.name || !this.formData.name.trim()) {
|
||||||
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.formData.address || !this.formData.address.trim()) {
|
||||||
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\/.+/.test(this.formData.address)) {
|
||||||
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.validUrl" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.formData.apiKey || !this.formData.apiKey.trim()) {
|
||||||
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.confirmLoading = true;
|
||||||
|
if (this.confirm) {
|
||||||
|
const result = this.confirm({ ...this.formData });
|
||||||
|
// If confirm returns a promise, handle it
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
result.catch(() => {
|
||||||
|
this.confirmLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If not async, reset loading after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.confirmLoading = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.confirmLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show({ title = '', okText = '{{ i18n "sure" }}', node = null, confirm = (data) => { }, isEdit = false }) {
|
||||||
|
console.log('[nodeModal.show] START - called with:', { title, okText, node, isEdit });
|
||||||
|
console.log('[nodeModal.show] this.visible before:', this.visible);
|
||||||
|
console.log('[nodeModal.show] nodeModalVueInstance:', nodeModalVueInstance);
|
||||||
|
|
||||||
|
// Update properties using 'this' like in inbound_modal
|
||||||
|
this.title = title;
|
||||||
|
this.okText = okText;
|
||||||
|
this.isEdit = isEdit;
|
||||||
|
this.confirm = confirm;
|
||||||
|
console.log('[nodeModal.show] Properties updated:', { title: this.title, okText: this.okText, isEdit: this.isEdit });
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
this.currentNode = node;
|
||||||
|
this.formData = {
|
||||||
|
name: node.name || '',
|
||||||
|
address: node.address || '',
|
||||||
|
apiKey: node.apiKey || ''
|
||||||
|
};
|
||||||
|
console.log('[nodeModal.show] Node data set:', this.formData);
|
||||||
|
} else {
|
||||||
|
this.currentNode = null;
|
||||||
|
this.formData = {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
apiKey: ''
|
||||||
|
};
|
||||||
|
console.log('[nodeModal.show] Form data reset (new node)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set visible - Vue will track this since nodeModal is in Vue instance data
|
||||||
|
console.log('[nodeModal.show] Setting this.visible = true');
|
||||||
|
this.visible = true;
|
||||||
|
console.log('[nodeModal.show] this.visible after setting:', this.visible);
|
||||||
|
|
||||||
|
// Check Vue instance
|
||||||
|
if (nodeModalVueInstance) {
|
||||||
|
console.log('[nodeModal.show] Vue instance exists');
|
||||||
|
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal:', nodeModalVueInstance.nodeModal);
|
||||||
|
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
|
||||||
|
console.log('[nodeModal.show] nodeModalVueInstance.$el:', nodeModalVueInstance.$el);
|
||||||
|
} else {
|
||||||
|
console.warn('[nodeModal.show] WARNING - Vue instance does not exist!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DOM element
|
||||||
|
const modalElement = document.getElementById('node-modal');
|
||||||
|
console.log('[nodeModal.show] Modal element in DOM:', modalElement);
|
||||||
|
if (modalElement) {
|
||||||
|
console.log('[nodeModal.show] Modal element classes:', modalElement.className);
|
||||||
|
console.log('[nodeModal.show] Modal element style.display:', modalElement.style.display);
|
||||||
|
const computedStyle = window.getComputedStyle(modalElement);
|
||||||
|
console.log('[nodeModal.show] Modal element computed display:', computedStyle.display);
|
||||||
|
console.log('[nodeModal.show] Modal element computed visibility:', computedStyle.visibility);
|
||||||
|
console.log('[nodeModal.show] Modal element computed opacity:', computedStyle.opacity);
|
||||||
|
console.log('[nodeModal.show] Modal element computed z-index:', computedStyle.zIndex);
|
||||||
|
|
||||||
|
// Check for Ant Design modal root
|
||||||
|
const modalRoot = document.querySelector('.ant-modal-root');
|
||||||
|
console.log('[nodeModal.show] Ant Design modal root exists:', !!modalRoot);
|
||||||
|
if (modalRoot) {
|
||||||
|
console.log('[nodeModal.show] Modal root style.display:', window.getComputedStyle(modalRoot).display);
|
||||||
|
const modalWrap = modalRoot.querySelector('.ant-modal-wrap');
|
||||||
|
console.log('[nodeModal.show] Modal wrap exists:', !!modalWrap);
|
||||||
|
if (modalWrap) {
|
||||||
|
console.log('[nodeModal.show] Modal wrap style.display:', window.getComputedStyle(modalWrap).display);
|
||||||
|
const modalInWrap = modalWrap.querySelector('#node-modal');
|
||||||
|
console.log('[nodeModal.show] Modal #node-modal in wrap:', !!modalInWrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[nodeModal.show] ERROR - Modal element #node-modal not found in DOM!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use nextTick to check after Vue updates
|
||||||
|
if (nodeModalVueInstance) {
|
||||||
|
nodeModalVueInstance.$nextTick(() => {
|
||||||
|
console.log('[nodeModal.show] After $nextTick - nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
|
||||||
|
const modalElementAfter = document.getElementById('node-modal');
|
||||||
|
if (modalElementAfter) {
|
||||||
|
const modalRootAfter = document.querySelector('.ant-modal-root');
|
||||||
|
if (modalRootAfter) {
|
||||||
|
console.log('[nodeModal.show] After $nextTick - Modal root display:', window.getComputedStyle(modalRootAfter).display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nodeModal.show] END');
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
nodeModal.visible = false;
|
||||||
|
// Reset form data
|
||||||
|
nodeModal.formData = {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
apiKey: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
nodeModal.visible = false;
|
||||||
|
nodeModal.confirmLoading = false;
|
||||||
|
},
|
||||||
|
loading(loading = true) {
|
||||||
|
this.confirmLoading = loading;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store Vue instance globally to ensure methods are always accessible
|
||||||
|
let nodeModalVueInstance = null;
|
||||||
|
|
||||||
|
// Create Vue instance after main app is ready
|
||||||
|
window.initNodeModalVue = function initNodeModalVue() {
|
||||||
|
if (nodeModalVueInstance) {
|
||||||
|
return; // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalElement = document.getElementById('node-modal');
|
||||||
|
if (!modalElement) {
|
||||||
|
setTimeout(initNodeModalVue, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
nodeModalVueInstance = new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
el: '#node-modal',
|
||||||
|
data: {
|
||||||
|
nodeModal: nodeModal,
|
||||||
|
get themeSwitcher() {
|
||||||
|
// Try to get themeSwitcher from window or global scope
|
||||||
|
if (typeof window !== 'undefined' && window.themeSwitcher) {
|
||||||
|
return window.themeSwitcher;
|
||||||
|
}
|
||||||
|
if (typeof themeSwitcher !== 'undefined') {
|
||||||
|
return themeSwitcher;
|
||||||
|
}
|
||||||
|
// Fallback to a simple object if themeSwitcher is not available
|
||||||
|
return { currentTheme: 'light' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.nodeModalVueInstance = nodeModalVueInstance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nodeModal init] ERROR creating Vue instance:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for DOM and main app to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(window.initNodeModalVue, 100);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(window.initNodeModalVue, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
221
web/html/nodes.html
Normal file
221
web/html/nodes.html
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
{{ template "page/head_start" .}}
|
||||||
|
{{ template "page/head_end" .}}
|
||||||
|
|
||||||
|
{{ template "page/body_start" .}}
|
||||||
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' nodes-page'">
|
||||||
|
<a-sidebar></a-sidebar>
|
||||||
|
<a-layout id="content-layout">
|
||||||
|
<a-layout-content>
|
||||||
|
<a-card>
|
||||||
|
<h2>Nodes Management</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<h3>Add New Node</h3>
|
||||||
|
<a-input id="node-name" placeholder="Node Name" style="width: 200px; margin-right: 10px;"></a-input>
|
||||||
|
<a-input id="node-address" placeholder="http://192.168.1.100:8080" style="width: 300px; margin-right: 10px;"></a-input>
|
||||||
|
<a-input-password id="node-apikey" placeholder="API Key" style="width: 200px; margin-right: 10px;"></a-input-password>
|
||||||
|
<a-button type="primary" onclick="addNode()">Add Node</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<a-button onclick="loadNodes()">Refresh</a-button>
|
||||||
|
<a-button onclick="checkAllNodes()" style="margin-left: 10px;">Check All</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nodes-list">
|
||||||
|
<a-spin tip="Loading..."></a-spin>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
{{template "page/body_scripts" .}}
|
||||||
|
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
|
||||||
|
{{template "component/aSidebar" .}}
|
||||||
|
{{template "component/aThemeSwitch" .}}
|
||||||
|
<script>
|
||||||
|
const app = new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
el: '#app',
|
||||||
|
mixins: [MediaQueryMixin],
|
||||||
|
data: {
|
||||||
|
themeSwitcher
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadNodes() {
|
||||||
|
const listDiv = document.getElementById('nodes-list');
|
||||||
|
listDiv.innerHTML = '<a-spin tip="Loading..."></a-spin>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/panel/node/list');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.obj) {
|
||||||
|
let html = '<table style="width: 100%; border-collapse: collapse;"><thead><tr><th style="border: 1px solid #ddd; padding: 8px;">ID</th><th style="border: 1px solid #ddd; padding: 8px;">Name</th><th style="border: 1px solid #ddd; padding: 8px;">Address</th><th style="border: 1px solid #ddd; padding: 8px;">Status</th><th style="border: 1px solid #ddd; padding: 8px;">Assigned Inbounds</th><th style="border: 1px solid #ddd; padding: 8px;">Actions</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
data.obj.forEach(node => {
|
||||||
|
const status = node.status || 'unknown';
|
||||||
|
const statusColor = status === 'online' ? 'green' : status === 'offline' ? 'red' : 'gray';
|
||||||
|
|
||||||
|
// Format assigned inbounds
|
||||||
|
let inboundsText = 'None';
|
||||||
|
if (node.inbounds && node.inbounds.length > 0) {
|
||||||
|
inboundsText = node.inbounds.map(inbound => {
|
||||||
|
const remark = inbound.remark || `Port ${inbound.port}`;
|
||||||
|
return `${remark} (ID: ${inbound.id})`;
|
||||||
|
}).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<tr>
|
||||||
|
<td style="border: 1px solid #ddd; padding: 8px;">${node.id}</td>
|
||||||
|
<td style="border: 1px solid #ddd; padding: 8px;">${node.name || ''}</td>
|
||||||
|
<td style="border: 1px solid #ddd; padding: 8px;">${node.address || ''}</td>
|
||||||
|
<td style="border: 1px solid #ddd; padding: 8px; color: ${statusColor};">${status}</td>
|
||||||
|
<td style="border: 1px solid #ddd; padding: 8px; max-width: 300px; word-wrap: break-word;">${inboundsText}</td>
|
||||||
|
<td style="border: 1px solid #ddd; padding: 8px;">
|
||||||
|
<button onclick="checkNode(${node.id})" style="margin-right: 5px;">Check</button>
|
||||||
|
<button onclick="editNode(${node.id}, '${(node.name || '').replace(/'/g, "\\'")}', '${(node.address || '').replace(/'/g, "\\'")}')" style="margin-right: 5px;">Edit</button>
|
||||||
|
<button onclick="deleteNode(${node.id})" style="color: red;">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
listDiv.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
listDiv.innerHTML = '<p>No nodes found</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
listDiv.innerHTML = '<p style="color: red;">Error loading nodes: ' + error.message + '</p>';
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNode() {
|
||||||
|
const name = document.getElementById('node-name').value;
|
||||||
|
const address = document.getElementById('node-address').value;
|
||||||
|
const apiKey = document.getElementById('node-apikey').value;
|
||||||
|
|
||||||
|
if (!name || !address || !apiKey) {
|
||||||
|
alert('Please fill all fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/panel/node/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address, apiKey })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert('Node added successfully');
|
||||||
|
document.getElementById('node-name').value = '';
|
||||||
|
document.getElementById('node-address').value = '';
|
||||||
|
document.getElementById('node-apikey').value = '';
|
||||||
|
loadNodes();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.msg || 'Failed to add node'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNode(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/panel/node/check/${id}`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert('Node checked');
|
||||||
|
loadNodes();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.msg || 'Failed to check node'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAllNodes() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/panel/node/checkAll', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert('Checking all nodes...');
|
||||||
|
setTimeout(loadNodes, 2000);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.msg || 'Failed to check nodes'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNode(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this node?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/panel/node/del/${id}`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert('Node deleted');
|
||||||
|
loadNodes();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.msg || 'Failed to delete node'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editNode(id, name, address) {
|
||||||
|
const newName = prompt('Enter new name:', name);
|
||||||
|
if (newName === null) return;
|
||||||
|
|
||||||
|
const newAddress = prompt('Enter new address:', address);
|
||||||
|
if (newAddress === null) return;
|
||||||
|
|
||||||
|
const apiKey = prompt('Enter API Key (leave empty to keep current):');
|
||||||
|
|
||||||
|
const data = { name: newName, address: newAddress };
|
||||||
|
if (apiKey) {
|
||||||
|
data.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/panel/node/update/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
alert('Node updated');
|
||||||
|
loadNodes();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (result.msg || 'Failed to update node'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load nodes on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', loadNodes);
|
||||||
|
} else {
|
||||||
|
loadNodes();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{template "page/body_end" .}}
|
||||||
|
|
@ -231,6 +231,44 @@
|
||||||
sample = []
|
sample = []
|
||||||
this.remarkModel.forEach(r => sample.push(this.remarkModels[r]));
|
this.remarkModel.forEach(r => sample.push(this.remarkModels[r]));
|
||||||
this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator);
|
this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator);
|
||||||
|
},
|
||||||
|
onMultiNodeModeChange(enabled) {
|
||||||
|
// Use app reference to ensure correct context
|
||||||
|
const vm = app || this;
|
||||||
|
|
||||||
|
// Ensure allSetting is initialized
|
||||||
|
if (!vm || !vm.allSetting) {
|
||||||
|
console.error('allSetting is not initialized', vm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the value immediately
|
||||||
|
vm.allSetting.multiNodeMode = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
vm.$confirm({
|
||||||
|
title: 'Enable Multi-Node Mode',
|
||||||
|
content: 'Enabling multi-node mode will stop local XRAY Core. Make sure you have configured worker nodes before enabling this mode. Continue?',
|
||||||
|
class: themeSwitcher.currentTheme,
|
||||||
|
okText: '{{ i18n "sure" }}',
|
||||||
|
cancelText: '{{ i18n "cancel" }}',
|
||||||
|
onOk: () => {
|
||||||
|
// Value already set, just update save button state
|
||||||
|
vm.saveBtnDisable = vm.oldAllSetting.equals(vm.allSetting);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
// Revert the value if cancelled
|
||||||
|
vm.allSetting.multiNodeMode = false;
|
||||||
|
vm.saveBtnDisable = vm.oldAllSetting.equals(vm.allSetting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Directly update save button state if disabling
|
||||||
|
vm.saveBtnDisable = vm.oldAllSetting.equals(vm.allSetting);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToNodes() {
|
||||||
|
window.location.href = basePath + 'panel/nodes';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -271,7 +309,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
this.oldAllSetting = new AllSetting(msg.obj);
|
this.oldAllSetting = new AllSetting(msg.obj);
|
||||||
this.allSetting = new AllSetting(msg.obj);
|
const newSetting = new AllSetting(msg.obj);
|
||||||
|
|
||||||
|
// Ensure multiNodeMode is properly converted to boolean
|
||||||
|
if (newSetting.multiNodeMode !== undefined && newSetting.multiNodeMode !== null) {
|
||||||
|
newSetting.multiNodeMode = Boolean(newSetting.multiNodeMode);
|
||||||
|
} else {
|
||||||
|
newSetting.multiNodeMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the object to trigger Vue reactivity
|
||||||
|
this.allSetting = newSetting;
|
||||||
|
|
||||||
|
// Force Vue to recognize the change by using $set for nested property
|
||||||
|
this.$set(this, 'allSetting', newSetting);
|
||||||
|
|
||||||
app.changeRemarkSample();
|
app.changeRemarkSample();
|
||||||
this.saveBtnDisable = true;
|
this.saveBtnDisable = true;
|
||||||
}
|
}
|
||||||
|
|
@ -292,7 +344,10 @@
|
||||||
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
|
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
|
Vue.prototype.$message.success('{{ i18n "pages.settings.toasts.modifySettings" }}');
|
||||||
await this.getAllSetting();
|
await this.getAllSetting();
|
||||||
|
} else {
|
||||||
|
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.settings.toasts.getSettings" }}');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateUser() {
|
async updateUser() {
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,33 @@
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="6" header='LDAP'>
|
<a-collapse-panel key="6" header='Multi-Node Mode'>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>Multi-Node Mode</template>
|
||||||
|
<template #description>Enable distributed architecture with separate worker nodes. When enabled, XRAY Core runs on nodes instead of locally.</template>
|
||||||
|
<template #control>
|
||||||
|
<a-switch v-model="allSetting.multiNodeMode" @change="(enabled) => onMultiNodeModeChange(enabled)"></a-switch>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-alert v-if="allSetting.multiNodeMode" type="info" :style="{ marginTop: '10px' }" show-icon>
|
||||||
|
<template slot="message">
|
||||||
|
Multi-Node Mode Enabled
|
||||||
|
</template>
|
||||||
|
<template slot="description">
|
||||||
|
<div>In this mode:</div>
|
||||||
|
<ul style="margin: 8px 0 0 20px; padding: 0;">
|
||||||
|
<li>XRAY Core will not run locally</li>
|
||||||
|
<li>Configurations will be sent to worker nodes</li>
|
||||||
|
<li>You need to assign inbounds to nodes</li>
|
||||||
|
<li>Subscriptions will use node endpoints</li>
|
||||||
|
</ul>
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<a-button type="link" size="small" @click="goToNodes">Go to Nodes Management</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
</a-collapse-panel>
|
||||||
|
<a-collapse-panel key="7" header='LDAP'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>Enable LDAP sync</template>
|
<template #title>Enable LDAP sync</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,6 +34,14 @@ func NewCheckClientIpJob() *CheckClientIpJob {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) Run() {
|
func (j *CheckClientIpJob) Run() {
|
||||||
|
// Check if multi-node mode is enabled
|
||||||
|
settingService := service.SettingService{}
|
||||||
|
multiMode, err := settingService.GetMultiNodeMode()
|
||||||
|
if err == nil && multiMode {
|
||||||
|
// In multi-node mode, IP checking is handled by nodes
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if j.lastClear == 0 {
|
if j.lastClear == 0 {
|
||||||
j.lastClear = time.Now().Unix()
|
j.lastClear = time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
web/job/check_node_health_job.go
Normal file
51
web/job/check_node_health_job.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Package job provides scheduled background jobs for the 3x-ui panel.
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckNodeHealthJob periodically checks the health of all nodes in multi-node mode.
|
||||||
|
type CheckNodeHealthJob struct {
|
||||||
|
nodeService service.NodeService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCheckNodeHealthJob creates a new job for checking node health.
|
||||||
|
func NewCheckNodeHealthJob() *CheckNodeHealthJob {
|
||||||
|
return &CheckNodeHealthJob{
|
||||||
|
nodeService: service.NodeService{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the health check for all nodes.
|
||||||
|
func (j *CheckNodeHealthJob) Run() {
|
||||||
|
// Check if multi-node mode is enabled
|
||||||
|
settingService := service.SettingService{}
|
||||||
|
multiMode, err := settingService.GetMultiNodeMode()
|
||||||
|
if err != nil || !multiMode {
|
||||||
|
return // Skip if multi-node mode is not enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := j.nodeService.GetAllNodes()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get nodes for health check: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return // No nodes to check
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Checking health of %d nodes", len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
n := node // Capture loop variable
|
||||||
|
go func() {
|
||||||
|
if err := j.nodeService.CheckNodeHealth(n); err != nil {
|
||||||
|
logger.Debugf("Node %s (%s) health check failed: %v", n.Name, n.Address, err)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Node %s (%s) is %s", n.Name, n.Address, n.Status)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,13 @@ func NewCheckXrayRunningJob() *CheckXrayRunningJob {
|
||||||
|
|
||||||
// Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks.
|
// Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks.
|
||||||
func (j *CheckXrayRunningJob) Run() {
|
func (j *CheckXrayRunningJob) Run() {
|
||||||
|
// Skip in multi-node mode - there's no local Xray process to check
|
||||||
|
settingService := service.SettingService{}
|
||||||
|
multiMode, err := settingService.GetMultiNodeMode()
|
||||||
|
if err == nil && multiMode {
|
||||||
|
return // Skip if multi-node mode is enabled
|
||||||
|
}
|
||||||
|
|
||||||
if !j.xrayService.DidXrayCrash() {
|
if !j.xrayService.DidXrayCrash() {
|
||||||
j.checkTime = 0
|
j.checkTime = 0
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,26 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Enrich client stats with UUID/SubId from inbound settings
|
|
||||||
|
// Enrich with node assignments
|
||||||
|
nodeService := NodeService{}
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
|
// Load all nodes for this inbound
|
||||||
|
nodes, err := nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err == nil && len(nodes) > 0 {
|
||||||
|
nodeIds := make([]int, len(nodes))
|
||||||
|
for i, node := range nodes {
|
||||||
|
nodeIds[i] = node.Id
|
||||||
|
}
|
||||||
|
inbound.NodeIds = nodeIds
|
||||||
|
// Don't set nodeId - it's deprecated and causes confusion
|
||||||
|
// nodeId is only for backward compatibility when receiving data from old clients
|
||||||
|
} else {
|
||||||
|
// Ensure empty array if no nodes assigned
|
||||||
|
inbound.NodeIds = []int{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich client stats with UUID/SubId from inbound settings
|
||||||
clients, _ := s.GetClients(inbound)
|
clients, _ := s.GetClients(inbound)
|
||||||
if len(clients) == 0 || len(inbound.ClientStats) == 0 {
|
if len(clients) == 0 || len(inbound.ClientStats) == 0 {
|
||||||
continue
|
continue
|
||||||
|
|
@ -347,6 +365,13 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete node mappings for this inbound (cascade delete)
|
||||||
|
err = db.Where("inbound_id = ?", id).Delete(&model.InboundNodeMapping{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
inbound, err := s.GetInbound(id)
|
inbound, err := s.GetInbound(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -372,6 +397,23 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with node assignments
|
||||||
|
nodeService := NodeService{}
|
||||||
|
nodes, err := nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err == nil && len(nodes) > 0 {
|
||||||
|
nodeIds := make([]int, len(nodes))
|
||||||
|
for i, node := range nodes {
|
||||||
|
nodeIds[i] = node.Id
|
||||||
|
}
|
||||||
|
inbound.NodeIds = nodeIds
|
||||||
|
// Don't set nodeId - it's deprecated and causes confusion
|
||||||
|
// nodeId is only for backward compatibility when receiving data from old clients
|
||||||
|
} else {
|
||||||
|
// Ensure empty array if no nodes assigned
|
||||||
|
inbound.NodeIds = []int{}
|
||||||
|
}
|
||||||
|
|
||||||
return inbound, nil
|
return inbound, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1055,7 +1097,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set onlineUsers
|
// Set onlineUsers
|
||||||
p.SetOnlineClients(onlineClients)
|
if p != nil {
|
||||||
|
p.SetOnlineClients(onlineClients)
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Save(dbClientTraffics).Error
|
err = tx.Save(dbClientTraffics).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -2329,6 +2373,9 @@ func (s *InboundService) MigrateDB() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetOnlineClients() []string {
|
func (s *InboundService) GetOnlineClients() []string {
|
||||||
|
if p == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
return p.GetOnlineClients()
|
return p.GetOnlineClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
316
web/service/node.go
Normal file
316
web/service/node.go
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
// Package service provides Node management service for multi-node architecture.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeService provides business logic for managing nodes in multi-node mode.
|
||||||
|
type NodeService struct{}
|
||||||
|
|
||||||
|
// GetAllNodes retrieves all nodes from the database.
|
||||||
|
func (s *NodeService) GetAllNodes() ([]*model.Node, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var nodes []*model.Node
|
||||||
|
err := db.Find(&nodes).Error
|
||||||
|
return nodes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNode retrieves a node by ID.
|
||||||
|
func (s *NodeService) GetNode(id int) (*model.Node, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var node model.Node
|
||||||
|
err := db.First(&node, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNode creates a new node.
|
||||||
|
func (s *NodeService) AddNode(node *model.Node) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Create(node).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNode updates an existing node.
|
||||||
|
func (s *NodeService) UpdateNode(node *model.Node) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Save(node).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNode deletes a node by ID.
|
||||||
|
// This will cascade delete all InboundNodeMapping entries for this node.
|
||||||
|
func (s *NodeService) DeleteNode(id int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// Delete all node mappings for this node (cascade delete)
|
||||||
|
err := db.Where("node_id = ?", id).Delete(&model.InboundNodeMapping{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the node itself
|
||||||
|
return db.Delete(&model.Node{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNodeHealth checks if a node is online and updates its status.
|
||||||
|
func (s *NodeService) CheckNodeHealth(node *model.Node) error {
|
||||||
|
status, err := s.CheckNodeStatus(node)
|
||||||
|
if err != nil {
|
||||||
|
node.Status = "error"
|
||||||
|
node.LastCheck = time.Now().Unix()
|
||||||
|
s.UpdateNode(node)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
node.Status = status
|
||||||
|
node.LastCheck = time.Now().Unix()
|
||||||
|
return s.UpdateNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNodeStatus performs a health check on a given node.
|
||||||
|
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/health", node.Address)
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "offline", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return "online", nil
|
||||||
|
}
|
||||||
|
return "error", fmt.Errorf("node returned status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAllNodesHealth checks health of all nodes.
|
||||||
|
func (s *NodeService) CheckAllNodesHealth() {
|
||||||
|
nodes, err := s.GetAllNodes()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get nodes for health check: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
go s.CheckNodeHealth(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeForInbound returns the node assigned to an inbound, or nil if not assigned.
|
||||||
|
// Deprecated: Use GetNodesForInbound for multi-node support.
|
||||||
|
func (s *NodeService) GetNodeForInbound(inboundId int) (*model.Node, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var mapping model.InboundNodeMapping
|
||||||
|
err := db.Where("inbound_id = ?", inboundId).First(&mapping).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // Not found is OK, means inbound is not assigned to any node
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetNode(mapping.NodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodesForInbound returns all nodes assigned to an inbound.
|
||||||
|
func (s *NodeService) GetNodesForInbound(inboundId int) ([]*model.Node, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var mappings []model.InboundNodeMapping
|
||||||
|
err := db.Where("inbound_id = ?", inboundId).Find(&mappings).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := make([]*model.Node, 0, len(mappings))
|
||||||
|
for _, mapping := range mappings {
|
||||||
|
node, err := s.GetNode(mapping.NodeId)
|
||||||
|
if err == nil && node != nil {
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInboundsForNode returns all inbounds assigned to a node.
|
||||||
|
func (s *NodeService) GetInboundsForNode(nodeId int) ([]*model.Inbound, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var mappings []model.InboundNodeMapping
|
||||||
|
err := db.Where("node_id = ?", nodeId).Find(&mappings).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inbounds := make([]*model.Inbound, 0, len(mappings))
|
||||||
|
for _, mapping := range mappings {
|
||||||
|
var inbound model.Inbound
|
||||||
|
err := db.First(&inbound, mapping.InboundId).Error
|
||||||
|
if err == nil {
|
||||||
|
inbounds = append(inbounds, &inbound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inbounds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignInboundToNode assigns an inbound to a node.
|
||||||
|
func (s *NodeService) AssignInboundToNode(inboundId, nodeId int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
mapping := &model.InboundNodeMapping{
|
||||||
|
InboundId: inboundId,
|
||||||
|
NodeId: nodeId,
|
||||||
|
}
|
||||||
|
return db.Save(mapping).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignInboundToNodes assigns an inbound to multiple nodes.
|
||||||
|
func (s *NodeService) AssignInboundToNodes(inboundId int, nodeIds []int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
// First, remove all existing assignments
|
||||||
|
if err := db.Where("inbound_id = ?", inboundId).Delete(&model.InboundNodeMapping{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, create new assignments
|
||||||
|
for _, nodeId := range nodeIds {
|
||||||
|
if nodeId > 0 {
|
||||||
|
mapping := &model.InboundNodeMapping{
|
||||||
|
InboundId: inboundId,
|
||||||
|
NodeId: nodeId,
|
||||||
|
}
|
||||||
|
if err := db.Create(mapping).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignInboundFromNode removes the assignment of an inbound from its node.
|
||||||
|
func (s *NodeService) UnassignInboundFromNode(inboundId int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Where("inbound_id = ?", inboundId).Delete(&model.InboundNodeMapping{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyConfigToNode sends XRAY configuration to a node.
|
||||||
|
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(xrayConfig))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateApiKey validates the API key by making a test request to the node.
|
||||||
|
func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if node is reachable via health endpoint
|
||||||
|
healthURL := fmt.Sprintf("%s/health", node.Address)
|
||||||
|
healthResp, err := client.Get(healthURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to connect to node %s at %s: %v", node.Address, healthURL, err)
|
||||||
|
return fmt.Errorf("failed to connect to node: %v", err)
|
||||||
|
}
|
||||||
|
healthResp.Body.Close()
|
||||||
|
|
||||||
|
if healthResp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("node health check failed with status %d", healthResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get node status - this will validate the API key
|
||||||
|
url := fmt.Sprintf("%s/api/v1/status", node.Address)
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := fmt.Sprintf("Bearer %s", node.ApiKey)
|
||||||
|
req.Header.Set("Authorization", authHeader)
|
||||||
|
|
||||||
|
logger.Debugf("Validating API key for node %s at %s (key: %s)", node.Name, url, node.ApiKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to connect to node %s: %v", node.Address, err)
|
||||||
|
return fmt.Errorf("failed to connect to node: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
logger.Warningf("Invalid API key for node %s (sent: %s): %s", node.Address, authHeader, string(body))
|
||||||
|
return fmt.Errorf("invalid API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.Errorf("Node %s returned status %d: %s", node.Address, resp.StatusCode, string(body))
|
||||||
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("API key validated successfully for node %s", node.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeStatus retrieves the status of a node.
|
||||||
|
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/v1/status", node.Address)
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("node returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
@ -774,6 +774,14 @@ func (s *ServerService) GetXrayLogs(
|
||||||
countInt, _ := strconv.Atoi(count)
|
countInt, _ := strconv.Atoi(count)
|
||||||
var entries []LogEntry
|
var entries []LogEntry
|
||||||
|
|
||||||
|
// Check if multi-node mode is enabled
|
||||||
|
settingService := SettingService{}
|
||||||
|
multiMode, err := settingService.GetMultiNodeMode()
|
||||||
|
if err == nil && multiMode {
|
||||||
|
// In multi-node mode, logs are on nodes, not locally
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
pathToAccessLog, err := xray.GetAccessLogPath()
|
pathToAccessLog, err := xray.GetAccessLogPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ var defaultValueMap = map[string]string{
|
||||||
"ldapDefaultTotalGB": "0",
|
"ldapDefaultTotalGB": "0",
|
||||||
"ldapDefaultExpiryDays": "0",
|
"ldapDefaultExpiryDays": "0",
|
||||||
"ldapDefaultLimitIP": "0",
|
"ldapDefaultLimitIP": "0",
|
||||||
|
// Multi-node mode
|
||||||
|
"multiNodeMode": "false", // "true" for multi-mode, "false" for single-mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingService provides business logic for application settings management.
|
// SettingService provides business logic for application settings management.
|
||||||
|
|
@ -564,6 +566,13 @@ func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
||||||
|
// Check if multi-node mode is enabled
|
||||||
|
multiMode, err := s.GetMultiNodeMode()
|
||||||
|
if err == nil && multiMode {
|
||||||
|
// In multi-node mode, IP limiting is handled by nodes
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
accessLogPath, err := xray.GetAccessLogPath()
|
accessLogPath, err := xray.GetAccessLogPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -652,6 +661,16 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
|
||||||
return s.getInt("ldapDefaultLimitIP")
|
return s.getInt("ldapDefaultLimitIP")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMultiNodeMode returns whether multi-node mode is enabled.
|
||||||
|
func (s *SettingService) GetMultiNodeMode() (bool, error) {
|
||||||
|
return s.getBool("multiNodeMode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMultiNodeMode sets the multi-node mode setting.
|
||||||
|
func (s *SettingService) SetMultiNodeMode(enabled bool) error {
|
||||||
|
return s.setBool("multiNodeMode", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||||
if err := allSetting.CheckValid(); err != nil {
|
if err := allSetting.CheckValid(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -3554,18 +3554,24 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
||||||
logger.Error("Error in opening db file for backup: ", err)
|
logger.Error("Error in opening db file for backup: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err = os.Open(xray.GetConfigPath())
|
// Check if multi-node mode is enabled before trying to open config.json
|
||||||
if err == nil {
|
multiMode, err := t.settingService.GetMultiNodeMode()
|
||||||
document := tu.Document(
|
if err == nil && !multiMode {
|
||||||
tu.ID(chatId),
|
file, err = os.Open(xray.GetConfigPath())
|
||||||
tu.File(file),
|
if err == nil {
|
||||||
)
|
document := tu.Document(
|
||||||
_, err = bot.SendDocument(context.Background(), document)
|
tu.ID(chatId),
|
||||||
if err != nil {
|
tu.File(file),
|
||||||
logger.Error("Error in uploading config.json: ", err)
|
)
|
||||||
|
_, err = bot.SendDocument(context.Background(), document)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error in uploading config.json: ", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Error("Error in opening config.json file for backup: ", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else if multiMode {
|
||||||
logger.Error("Error in opening config.json file for backup: ", err)
|
logger.Debug("Skipping config.json backup in multi-node mode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package service
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
|
||||||
|
|
@ -22,9 +24,11 @@ var (
|
||||||
|
|
||||||
// XrayService provides business logic for Xray process management.
|
// XrayService provides business logic for Xray process management.
|
||||||
// It handles starting, stopping, restarting Xray, and managing its configuration.
|
// It handles starting, stopping, restarting Xray, and managing its configuration.
|
||||||
|
// In multi-node mode, it sends configurations to nodes instead of running Xray locally.
|
||||||
type XrayService struct {
|
type XrayService struct {
|
||||||
inboundService InboundService
|
inboundService InboundService
|
||||||
settingService SettingService
|
settingService SettingService
|
||||||
|
nodeService NodeService
|
||||||
xrayAPI xray.XrayAPI
|
xrayAPI xray.XrayAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,12 +215,24 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
|
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
|
||||||
|
// In multi-node mode, it sends configurations to nodes instead of restarting local Xray.
|
||||||
func (s *XrayService) RestartXray(isForce bool) error {
|
func (s *XrayService) RestartXray(isForce bool) error {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
logger.Debug("restart Xray, force:", isForce)
|
logger.Debug("restart Xray, force:", isForce)
|
||||||
isManuallyStopped.Store(false)
|
isManuallyStopped.Store(false)
|
||||||
|
|
||||||
|
// Check if multi-node mode is enabled
|
||||||
|
multiMode, err := s.settingService.GetMultiNodeMode()
|
||||||
|
if err != nil {
|
||||||
|
multiMode = false // Default to single mode on error
|
||||||
|
}
|
||||||
|
|
||||||
|
if multiMode {
|
||||||
|
return s.restartXrayMultiMode(isForce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single mode: use local Xray
|
||||||
xrayConfig, err := s.GetXrayConfig()
|
xrayConfig, err := s.GetXrayConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -240,6 +256,153 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restartXrayMultiMode handles Xray restart in multi-node mode by sending configs to nodes.
|
||||||
|
func (s *XrayService) restartXrayMultiMode(isForce bool) error {
|
||||||
|
// Initialize nodeService if not already initialized
|
||||||
|
if s.nodeService == (NodeService{}) {
|
||||||
|
s.nodeService = NodeService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all nodes
|
||||||
|
nodes, err := s.nodeService.GetAllNodes()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get nodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group inbounds by node
|
||||||
|
nodeInbounds := make(map[int][]*model.Inbound)
|
||||||
|
allInbounds, err := s.inboundService.GetAllInbounds()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get inbounds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template config
|
||||||
|
templateConfig, err := s.settingService.GetXrayConfigTemplate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
baseConfig := &xray.Config{}
|
||||||
|
if err := json.Unmarshal([]byte(templateConfig), baseConfig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group inbounds by their assigned nodes
|
||||||
|
for _, inbound := range allInbounds {
|
||||||
|
if !inbound.Enable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all nodes assigned to this inbound (multi-node support)
|
||||||
|
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
|
||||||
|
if err != nil || len(nodes) == 0 {
|
||||||
|
// Inbound not assigned to any node, skip it (this is normal - not all inbounds need to be assigned)
|
||||||
|
logger.Debugf("Inbound %d is not assigned to any node, skipping", inbound.Id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inbound to all assigned nodes
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeInbounds[node.Id] = append(nodeInbounds[node.Id], inbound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send config to each node
|
||||||
|
for _, node := range nodes {
|
||||||
|
inbounds, ok := nodeInbounds[node.Id]
|
||||||
|
if !ok {
|
||||||
|
// No inbounds assigned to this node, skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config for this node
|
||||||
|
nodeConfig := *baseConfig
|
||||||
|
nodeConfig.InboundConfigs = []xray.InboundConfig{}
|
||||||
|
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
// Process clients (same logic as GetXrayConfig)
|
||||||
|
settings := map[string]any{}
|
||||||
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
|
clients, ok := settings["clients"].([]any)
|
||||||
|
if ok {
|
||||||
|
clientStats := inbound.ClientStats
|
||||||
|
for _, clientTraffic := range clientStats {
|
||||||
|
indexDecrease := 0
|
||||||
|
for index, client := range clients {
|
||||||
|
c := client.(map[string]any)
|
||||||
|
if c["email"] == clientTraffic.Email {
|
||||||
|
if !clientTraffic.Enable {
|
||||||
|
clients = RemoveIndex(clients, index-indexDecrease)
|
||||||
|
indexDecrease++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var final_clients []any
|
||||||
|
for _, client := range clients {
|
||||||
|
c := client.(map[string]any)
|
||||||
|
if c["enable"] != nil {
|
||||||
|
if enable, ok := c["enable"].(bool); ok && !enable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range c {
|
||||||
|
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
|
||||||
|
delete(c, key)
|
||||||
|
}
|
||||||
|
if c["flow"] == "xtls-rprx-vision-udp443" {
|
||||||
|
c["flow"] = "xtls-rprx-vision"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final_clients = append(final_clients, any(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
settings["clients"] = final_clients
|
||||||
|
modifiedSettings, _ := json.MarshalIndent(settings, "", " ")
|
||||||
|
inbound.Settings = string(modifiedSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(inbound.StreamSettings) > 0 {
|
||||||
|
var stream map[string]any
|
||||||
|
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||||
|
tlsSettings, ok1 := stream["tlsSettings"].(map[string]any)
|
||||||
|
realitySettings, ok2 := stream["realitySettings"].(map[string]any)
|
||||||
|
if ok1 || ok2 {
|
||||||
|
if ok1 {
|
||||||
|
delete(tlsSettings, "settings")
|
||||||
|
} else if ok2 {
|
||||||
|
delete(realitySettings, "settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(stream, "externalProxy")
|
||||||
|
newStream, _ := json.MarshalIndent(stream, "", " ")
|
||||||
|
inbound.StreamSettings = string(newStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
inboundConfig := inbound.GenXrayInboundConfig()
|
||||||
|
nodeConfig.InboundConfigs = append(nodeConfig.InboundConfigs, *inboundConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal config to JSON
|
||||||
|
configJSON, err := json.MarshalIndent(&nodeConfig, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to marshal config for node %d: %v", node.Id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to node
|
||||||
|
if err := s.nodeService.ApplyConfigToNode(node, configJSON); err != nil {
|
||||||
|
logger.Errorf("Failed to apply config to node %d (%s): %v", node.Id, node.Name, err)
|
||||||
|
// Continue with other nodes even if one fails
|
||||||
|
} else {
|
||||||
|
logger.Infof("Successfully applied config to node %d (%s)", node.Id, node.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// StopXray stops the running Xray process.
|
// StopXray stops the running Xray process.
|
||||||
func (s *XrayService) StopXray() error {
|
func (s *XrayService) StopXray() error {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@
|
||||||
"inbounds" = "Inbounds"
|
"inbounds" = "Inbounds"
|
||||||
"settings" = "Panel Settings"
|
"settings" = "Panel Settings"
|
||||||
"xray" = "Xray Configs"
|
"xray" = "Xray Configs"
|
||||||
|
"nodes" = "Nodes"
|
||||||
"logout" = "Log Out"
|
"logout" = "Log Out"
|
||||||
"link" = "Manage"
|
"link" = "Manage"
|
||||||
|
|
||||||
|
|
@ -582,6 +583,46 @@
|
||||||
"twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted"
|
"twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted"
|
||||||
"twoFactorModalError" = "Wrong code"
|
"twoFactorModalError" = "Wrong code"
|
||||||
|
|
||||||
|
[pages.nodes]
|
||||||
|
"title" = "Nodes Management"
|
||||||
|
"addNode" = "Add Node"
|
||||||
|
"editNode" = "Edit Node"
|
||||||
|
"deleteNode" = "Delete Node"
|
||||||
|
"checkNode" = "Check Node"
|
||||||
|
"checkAllNodes" = "Check All Nodes"
|
||||||
|
"nodeName" = "Node Name"
|
||||||
|
"nodeAddress" = "Node Address"
|
||||||
|
"nodeApiKey" = "API Key"
|
||||||
|
"nodeStatus" = "Status"
|
||||||
|
"lastCheck" = "Last Check"
|
||||||
|
"actions" = "Actions"
|
||||||
|
"online" = "Online"
|
||||||
|
"offline" = "Offline"
|
||||||
|
"error" = "Error"
|
||||||
|
"unknown" = "Unknown"
|
||||||
|
"enterNodeName" = "Please enter node name"
|
||||||
|
"enterNodeAddress" = "Please enter node address"
|
||||||
|
"validUrl" = "Must be a valid URL (http:// or https://)"
|
||||||
|
"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100:8080)"
|
||||||
|
"enterApiKey" = "Please enter API key"
|
||||||
|
"apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)"
|
||||||
|
|
||||||
|
[pages.nodes.toasts]
|
||||||
|
"createSuccess" = "Node created successfully"
|
||||||
|
"createError" = "Failed to create node"
|
||||||
|
"updateSuccess" = "Node updated successfully"
|
||||||
|
"updateError" = "Failed to update node"
|
||||||
|
"deleteSuccess" = "Node deleted successfully"
|
||||||
|
"deleteError" = "Failed to delete node"
|
||||||
|
"checkStatusSuccess" = "Node health check completed"
|
||||||
|
"checkStatusError" = "Failed to check node status"
|
||||||
|
"obtainError" = "Failed to get nodes"
|
||||||
|
"invalidId" = "Invalid node ID"
|
||||||
|
"assignSuccess" = "Inbound assigned to node successfully"
|
||||||
|
"assignError" = "Failed to assign inbound to node"
|
||||||
|
"mappingError" = "Failed to get node mapping"
|
||||||
|
"invalidInboundId" = "Invalid inbound ID"
|
||||||
|
|
||||||
[pages.settings.toasts]
|
[pages.settings.toasts]
|
||||||
"modifySettings" = "The parameters have been changed."
|
"modifySettings" = "The parameters have been changed."
|
||||||
"getSettings" = "An error occurred while retrieving parameters."
|
"getSettings" = "An error occurred while retrieving parameters."
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@
|
||||||
"inbounds" = "Подключения"
|
"inbounds" = "Подключения"
|
||||||
"settings" = "Настройки"
|
"settings" = "Настройки"
|
||||||
"xray" = "Настройки Xray"
|
"xray" = "Настройки Xray"
|
||||||
|
"nodes" = "Ноды"
|
||||||
"logout" = "Выход"
|
"logout" = "Выход"
|
||||||
"link" = "Управление"
|
"link" = "Управление"
|
||||||
|
|
||||||
|
|
@ -582,6 +583,46 @@
|
||||||
"twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена"
|
"twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена"
|
||||||
"twoFactorModalError" = "Неверный код"
|
"twoFactorModalError" = "Неверный код"
|
||||||
|
|
||||||
|
[pages.nodes]
|
||||||
|
"title" = "Управление нодами"
|
||||||
|
"addNode" = "Добавить ноду"
|
||||||
|
"editNode" = "Редактировать ноду"
|
||||||
|
"deleteNode" = "Удалить ноду"
|
||||||
|
"checkNode" = "Проверить ноду"
|
||||||
|
"checkAllNodes" = "Проверить все ноды"
|
||||||
|
"nodeName" = "Имя ноды"
|
||||||
|
"nodeAddress" = "Адрес ноды"
|
||||||
|
"nodeApiKey" = "API ключ"
|
||||||
|
"nodeStatus" = "Статус"
|
||||||
|
"lastCheck" = "Последняя проверка"
|
||||||
|
"actions" = "Действия"
|
||||||
|
"online" = "Онлайн"
|
||||||
|
"offline" = "Офлайн"
|
||||||
|
"error" = "Ошибка"
|
||||||
|
"unknown" = "Неизвестно"
|
||||||
|
"enterNodeName" = "Пожалуйста, введите имя ноды"
|
||||||
|
"enterNodeAddress" = "Пожалуйста, введите адрес ноды"
|
||||||
|
"validUrl" = "Должен быть действительным URL (http:// или https://)"
|
||||||
|
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)"
|
||||||
|
"enterApiKey" = "Пожалуйста, введите API ключ"
|
||||||
|
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
|
||||||
|
|
||||||
|
[pages.nodes.toasts]
|
||||||
|
"createSuccess" = "Нода успешно создана"
|
||||||
|
"createError" = "Не удалось создать ноду"
|
||||||
|
"updateSuccess" = "Нода успешно обновлена"
|
||||||
|
"updateError" = "Не удалось обновить ноду"
|
||||||
|
"deleteSuccess" = "Нода успешно удалена"
|
||||||
|
"deleteError" = "Не удалось удалить ноду"
|
||||||
|
"checkStatusSuccess" = "Проверка здоровья ноды завершена"
|
||||||
|
"checkStatusError" = "Не удалось проверить статус ноды"
|
||||||
|
"obtainError" = "Не удалось получить список нод"
|
||||||
|
"invalidId" = "Неверный ID ноды"
|
||||||
|
"assignSuccess" = "Подключение успешно назначено на ноду"
|
||||||
|
"assignError" = "Не удалось назначить подключение на ноду"
|
||||||
|
"mappingError" = "Не удалось получить привязку ноды"
|
||||||
|
"invalidInboundId" = "Неверный ID подключения"
|
||||||
|
|
||||||
[pages.settings.toasts]
|
[pages.settings.toasts]
|
||||||
"modifySettings" = "Настройки изменены"
|
"modifySettings" = "Настройки изменены"
|
||||||
"getSettings" = "Произошла ошибка при получении параметров."
|
"getSettings" = "Произошла ошибка при получении параметров."
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,9 @@ func (s *Server) startTask() {
|
||||||
s.cron.AddJob(runtime, j)
|
s.cron.AddJob(runtime, j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Node health check job (every 5 minutes)
|
||||||
|
s.cron.AddJob("@every 5m", job.NewCheckNodeHealthJob())
|
||||||
|
|
||||||
// Make a traffic condition every day, 8:30
|
// Make a traffic condition every day, 8:30
|
||||||
var entry cron.EntryID
|
var entry cron.EntryID
|
||||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,15 @@ func GetAccessPersistentPrevLogPath() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessLogPath reads the Xray config and returns the access log file path.
|
// GetAccessLogPath reads the Xray config and returns the access log file path.
|
||||||
|
// Returns an error if the config file doesn't exist (e.g., in multi-node mode).
|
||||||
func GetAccessLogPath() (string, error) {
|
func GetAccessLogPath() (string, error) {
|
||||||
config, err := os.ReadFile(GetConfigPath())
|
configPath := GetConfigPath()
|
||||||
|
config, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Don't log warning if file doesn't exist - this is normal in multi-node mode
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
logger.Warningf("Failed to read configuration file: %s", err)
|
logger.Warningf("Failed to read configuration file: %s", err)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue