From d7175e7803b521b99d039323e20df7e0c2646943 Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 00:12:53 +0300 Subject: [PATCH 01/16] multi-node support --- CHANGES_SUMMARY.md | 84 ++++++ MULTI_NODE_ARCHITECTURE.md | 264 ++++++++++++++++ database/db.go | 2 + database/model/model.go | 21 ++ docker-compose.yml | 5 +- go.mod | 9 + logger/logger.go | 17 +- node/Dockerfile | 120 ++++++++ node/README.md | 70 +++++ node/api/server.go | 149 +++++++++ node/docker-compose.yml | 62 ++++ node/main.go | 52 ++++ node/xray/manager.go | 126 ++++++++ sub/subController.go | 3 + sub/subService.go | 386 +++++++++++++++++------- web/assets/js/model/dbinbound.js | 46 ++- web/assets/js/model/inbound.js | 126 +++++++- web/assets/js/model/node.js | 82 +++++ web/assets/js/model/setting.js | 14 + web/controller/inbound.go | 114 ++++++- web/controller/node.go | 225 ++++++++++++++ web/controller/xui.go | 8 + web/entity/entity.go | 3 + web/html/component/aSidebar.html | 65 +++- web/html/form/inbound.html | 20 ++ web/html/inbounds.html | 105 ++++++- web/html/modals/inbound_info_modal.html | 34 ++- web/html/modals/inbound_modal.html | 36 +++ web/html/modals/node_modal.html | 232 ++++++++++++++ web/html/nodes.html | 221 ++++++++++++++ web/html/settings.html | 57 +++- web/html/settings/panel/general.html | 28 +- web/job/check_client_ip_job.go | 9 + web/job/check_node_health_job.go | 51 ++++ web/job/check_xray_running_job.go | 7 + web/service/inbound.go | 51 +++- web/service/node.go | 316 +++++++++++++++++++ web/service/server.go | 8 + web/service/setting.go | 19 ++ web/service/tgbot.go | 28 +- web/service/xray.go | 163 ++++++++++ web/translation/translate.en_US.toml | 41 +++ web/translation/translate.ru_RU.toml | 41 +++ web/web.go | 3 + xray/process.go | 8 +- 45 files changed, 3369 insertions(+), 162 deletions(-) create mode 100644 CHANGES_SUMMARY.md create mode 100644 MULTI_NODE_ARCHITECTURE.md create mode 100644 node/Dockerfile create mode 100644 node/README.md create mode 100644 node/api/server.go create mode 100644 node/docker-compose.yml create mode 100644 node/main.go create mode 100644 node/xray/manager.go create mode 100644 web/assets/js/model/node.js create mode 100644 web/controller/node.go create mode 100644 web/html/modals/node_modal.html create mode 100644 web/html/nodes.html create mode 100644 web/job/check_node_health_job.go create mode 100644 web/service/node.go diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md new file mode 100644 index 00000000..4ff51d45 --- /dev/null +++ b/CHANGES_SUMMARY.md @@ -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-сервиса diff --git a/MULTI_NODE_ARCHITECTURE.md b/MULTI_NODE_ARCHITECTURE.md new file mode 100644 index 00000000..f66ed2ee --- /dev/null +++ b/MULTI_NODE_ARCHITECTURE.md @@ -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 +``` + +#### 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. Проверьте, что порт инбаунда открыт на ноде diff --git a/database/db.go b/database/db.go index 6b579dd9..b33a0621 100644 --- a/database/db.go +++ b/database/db.go @@ -38,6 +38,8 @@ func initModels() error { &model.InboundClientIps{}, &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, + &model.Node{}, + &model.InboundNodeMapping{}, } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 4ca39d87..51203a43 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -53,6 +53,8 @@ type Inbound struct { StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` 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. @@ -119,3 +121,22 @@ type Client struct { CreatedAt int64 `json:"created_at,omitempty"` // Creation 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 +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 198df198..8e146db7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: dockerfile: ./Dockerfile container_name: 3xui_app # hostname: yourhostname <- optional + ports: + - "2053:2053" + - "2096:2096" volumes: - $PWD/db/:/etc/x-ui/ - $PWD/cert/:/root/cert/ @@ -12,5 +15,5 @@ services: XRAY_VMESS_AEAD_FORCED: "false" XUI_ENABLE_FAIL2BAN: "true" tty: true - network_mode: host + # network_mode: host restart: unless-stopped diff --git a/go.mod b/go.mod index 494e5890..d8f7789b 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,9 @@ 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 require ( @@ -101,3 +105,8 @@ require ( gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // 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 => ./ diff --git a/logger/logger.go b/logger/logger.go index 7d26dcd0..4ab3b4cf 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -69,12 +69,19 @@ func initDefaultBackend() logging.Backend { includeTime = true } else { // Unix-like: Try syslog, fallback to stderr - if syslogBackend, err := logging.NewSyslogBackend(""); err != nil { - fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err) - backend = logging.NewLogBackend(os.Stderr, "", 0) - includeTime = os.Getppid() > 0 - } else { + // Try syslog with "x-ui" tag first + if syslogBackend, err := logging.NewSyslogBackend("x-ui"); err == nil { 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 + } } } diff --git a/node/Dockerfile b/node/Dockerfile new file mode 100644 index 00000000..d6d3f8ba --- /dev/null +++ b/node/Dockerfile @@ -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"] diff --git a/node/README.md b/node/README.md new file mode 100644 index 00000000..2f4cc182 --- /dev/null +++ b/node/README.md @@ -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 ` +- **Body**: JSON конфигурация XRAY + +### `POST /api/v1/reload` +Перезагрузить XRAY +- **Headers**: `Authorization: Bearer ` + +### `GET /api/v1/status` +Получить статус XRAY +- **Headers**: `Authorization: Bearer ` + +## Запуск + +### 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 +``` diff --git a/node/api/server.go b/node/api/server.go new file mode 100644 index 00000000..42fbf519 --- /dev/null +++ b/node/api/server.go @@ -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 " 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) +} diff --git a/node/docker-compose.yml b/node/docker-compose.yml new file mode 100644 index 00000000..5795a680 --- /dev/null +++ b/node/docker-compose.yml @@ -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 diff --git a/node/main.go b/node/main.go new file mode 100644 index 00000000..617981f2 --- /dev/null +++ b/node/main.go @@ -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") +} diff --git a/node/xray/manager.go b/node/xray/manager.go new file mode 100644 index 00000000..1314fff8 --- /dev/null +++ b/node/xray/manager.go @@ -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() +} diff --git a/sub/subController.go b/sub/subController.go index ec574d6e..a219dd63 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v2/config" + service "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/gin-gonic/gin" ) @@ -40,6 +41,8 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) + // Initialize NodeService for multi-node support + sub.nodeService = service.NodeService{} a := &SUBController{ subTitle: subTitle, subPath: subPath, diff --git a/sub/subService.go b/sub/subService.go index ade871df..41c7d67d 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -28,6 +28,7 @@ type SubService struct { datepicker string inboundService service.InboundService settingService service.SettingService + nodeService service.NodeService } // 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 { if client.Enable && client.SubID == subId { 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) clientTraffics = append(clientTraffics, ct) if ct.LastOnline > lastOnline { @@ -179,78 +187,99 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VMESS { return "" } - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen + + // 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) + } + } + } } - 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", - "add": address, "port": inbound.Port, "type": "none", } var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) network, _ := stream["network"].(string) - obj["net"] = network + baseObj["net"] = network switch network { case "tcp": tcp, _ := stream["tcpSettings"].(map[string]any) header, _ := tcp["header"].(map[string]any) typeStr, _ := header["type"].(string) - obj["type"] = typeStr + baseObj["type"] = typeStr if typeStr == "http" { request := header["request"].(map[string]any) requestPath, _ := request["path"].([]any) - obj["path"] = requestPath[0].(string) + baseObj["path"] = requestPath[0].(string) headers, _ := request["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "kcp": kcp, _ := stream["kcpSettings"].(map[string]any) header, _ := kcp["header"].(map[string]any) - obj["type"], _ = header["type"].(string) - obj["path"], _ = kcp["seed"].(string) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) case "ws": 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 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := ws["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "grpc": grpc, _ := stream["grpcSettings"].(map[string]any) - obj["path"] = grpc["serviceName"].(string) - obj["authority"] = grpc["authority"].(string) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) if grpc["multiMode"].(bool) { - obj["type"] = "multi" + baseObj["type"] = "multi" } case "httpupgrade": 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 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := httpupgrade["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "xhttp": 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 { - obj["host"] = host + baseObj["host"] = host } else { 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) - obj["tls"] = security + baseObj["tls"] = security if security == "tls" { tlsSetting, _ := stream["tlsSettings"].(map[string]any) alpns, _ := tlsSetting["alpn"].([]any) @@ -259,19 +288,19 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, a := range alpns { alpn = append(alpn, a.(string)) } - obj["alpn"] = strings.Join(alpn, ",") + baseObj["alpn"] = strings.Join(alpn, ",") } if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - obj["sni"], _ = sniValue.(string) + baseObj["sni"], _ = sniValue.(string) } tlsSettings, _ := searchKey(tlsSetting, "settings") if tlsSetting != nil { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - obj["fp"], _ = fpValue.(string) + baseObj["fp"], _ = fpValue.(string) } 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 } } - obj["id"] = clients[clientIndex].ID - obj["scy"] = clients[clientIndex].Security + baseObj["id"] = clients[clientIndex].ID + baseObj["scy"] = clients[clientIndex].Security 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 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) 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")) { newObj[key] = value } @@ -307,32 +340,67 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if newSecurity != "same" { newObj["tls"] = newSecurity } - if index > 0 { + if linkIndex > 0 { links += "\n" } jsonStr, _ := json.MarshalIndent(newObj, "", " ") links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ } 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, "", " ") - return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + if linkIndex > 0 { + 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 { - 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 { 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 json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -483,14 +551,18 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { 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 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) if newSecurity != "same" { 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)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } return links } - link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port) - url, _ := url.Parse(link) - q := url.Query() + // Generate links for each node address + for _, nodeAddr := range nodeAddresses { + link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port) + url, _ := url.Parse(link) + q := url.Query() - for k, v := range params { - q.Add(k, v) + for k, v := range params { + 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 - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } 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 { 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 json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -683,14 +787,18 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string 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 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) if newSecurity != "same" { 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)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } 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) - q := url.Query() + for k, v := range params { + q.Add(k, v) + } - for k, v := range params { - 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 - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } 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 { 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 json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -855,14 +994,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st 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 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) if newSecurity != "same" { 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)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } return links } - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) - url, _ := url.Parse(link) - q := url.Query() + // Generate links for each node address + for _, nodeAddr := range nodeAddresses { + 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 { - q.Add(k, v) + for k, v := range params { + 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 - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } 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 } + +// 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 +} diff --git a/web/assets/js/model/dbinbound.js b/web/assets/js/model/dbinbound.js index befc618e..195eb95a 100644 --- a/web/assets/js/model/dbinbound.js +++ b/web/assets/js/model/dbinbound.js @@ -20,11 +20,48 @@ class DBInbound { this.streamSettings = ""; this.tag = ""; 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) { return; } 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() { @@ -116,6 +153,13 @@ class DBInbound { sniffing: sniffing, 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); } diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index ba2304ef..1c619d23 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1075,6 +1075,8 @@ class Inbound extends XrayCommonClass { this.tag = tag; this.sniffing = sniffing; this.clientStats = clientStats; + this.nodeIds = []; // Node IDs array for multi-node mode + this.nodeId = null; // Backward compatibility } getClientStats() { 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) { let result = []; 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; const separationChar = remarkModel.charAt(0); const orderChars = remarkModel.slice(1); @@ -1650,13 +1715,18 @@ class Inbound extends XrayCommonClass { 'e': email, 'o': '', }; + if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) { - let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); - result.push({ - remark: r, - link: this.genLink(addr, port, 'same', r, client) + // Generate links for each node address + addresses.forEach((addr) => { + let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); + result.push({ + remark: r, + link: this.genLink(addr, port, 'same', r, client) + }); }); } else { + // External proxy takes precedence this.stream.externalProxy.forEach((ep) => { orders['o'] = ep.remark; 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') { - 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) { let links = []; this.clients.forEach((client) => { @@ -1680,11 +1761,20 @@ class Inbound extends XrayCommonClass { }); return links.join('\r\n'); } 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) { let links = []; - this.settings.peers.forEach((p, index) => { - links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); + addresses.forEach((addr) => { + 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'); } @@ -1693,7 +1783,7 @@ class Inbound extends XrayCommonClass { } static fromJson(json = {}) { - return new Inbound( + const inbound = new Inbound( json.port, json.listen, json.protocol, @@ -1702,7 +1792,14 @@ class Inbound extends XrayCommonClass { json.tag, Sniffing.fromJson(json.sniffing), 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() { @@ -1710,7 +1807,7 @@ class Inbound extends XrayCommonClass { if (this.canEnableStream() || this.stream?.sockopt) { streamSettings = this.stream.toJson(); } - return { + const result = { port: this.port, listen: this.listen, protocol: this.protocol, @@ -1720,6 +1817,11 @@ class Inbound extends XrayCommonClass { sniffing: this.sniffing.toJson(), clientStats: this.clientStats }; + // Include nodeIds if present + if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) { + result.nodeIds = this.nodeIds; + } + return result; } } diff --git a/web/assets/js/model/node.js b/web/assets/js/model/node.js new file mode 100644 index 00000000..41722157 --- /dev/null +++ b/web/assets/js/model/node.js @@ -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); + } +} diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index 53ffae1a..3446832d 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -72,10 +72,24 @@ class AllSetting { this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + // Multi-node mode settings + this.multiNodeMode = false; // Multi-node mode setting + if (data == null) { return } 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) { diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 8317de31..0ba7b7d5 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -6,6 +6,7 @@ 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/mhsanaei/3x-ui/v2/web/session" "github.com/mhsanaei/3x-ui/v2/web/websocket" @@ -106,9 +107,11 @@ func (a *InboundController) addInbound(c *gin.Context) { inbound := &model.Inbound{} err := c.ShouldBind(inbound) if err != nil { + logger.Errorf("Failed to bind inbound data: %v", err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err) return } + user := session.GetLoginUser(c) inbound.UserId = user.Id 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) if err != nil { + logger.Errorf("Failed to add inbound: %v", err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) 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) if needRestart { a.xrayService.SetToNeedRestart() @@ -160,19 +203,87 @@ func (a *InboundController) updateInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) 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{ Id: id, } + // Bind inbound data (nodeIds will be ignored since we handle it separately) err = c.ShouldBind(inbound) if err != nil { + logger.Errorf("Failed to bind inbound data: %v", err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) if err != nil { + logger.Errorf("Failed to update inbound: %v", err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) 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) if needRestart { a.xrayService.SetToNeedRestart() @@ -367,7 +478,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) { // onlines retrieves the list of currently online clients. 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. diff --git a/web/controller/node.go b/web/controller/node.go new file mode 100644 index 00000000..16eefa2d --- /dev/null +++ b/web/controller/node.go @@ -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) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index 51502900..f11a0422 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -10,6 +10,7 @@ type XUIController struct { settingController *SettingController xraySettingController *XraySettingController + nodeController *NodeController } // 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("/settings", a.settings) g.GET("/xray", a.xraySettings) + g.GET("/nodes", a.nodes) a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) + a.nodeController = NewNodeController(g.Group("/node")) } // 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) { 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) +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 42e2df85..030da972 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -98,6 +98,9 @@ type AllSetting struct { LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` 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 } diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index b69c8f3f..8f0b1ee5 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -43,7 +43,29 @@ Vue.component('a-sidebar', { data() { 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/', icon: 'dashboard', @@ -63,21 +85,24 @@ key: '{{ .base_path }}panel/xray', icon: 'tool', title: '{{ i18n "menu.xray"}}' - }, - { - key: '{{ .base_path }}logout/', - icon: 'logout', - title: '{{ i18n "menu.logout"}}' - }, - ], - activeTab: [ - '{{ .request_uri }}' - ], - visible: false, - collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)), - } - }, - methods: { + } + ]; + + // Add Nodes menu item if multi-node mode is enabled + if (this.multiNodeMode) { + this.tabs.splice(3, 0, { + key: '{{ .base_path }}panel/nodes', + icon: 'cluster', + title: '{{ i18n "menu.nodes"}}' + }); + } + + this.tabs.push({ + key: '{{ .base_path }}logout/', + icon: 'logout', + title: '{{ i18n "menu.logout"}}' + }); + }, openLink(key) { return key.startsWith('http') ? 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"}}`, }); diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html index fdd381b0..b7b34cd3 100644 --- a/web/html/form/inbound.html +++ b/web/html/form/inbound.html @@ -31,6 +31,26 @@ + + + + + [[ node.name ]] [[ node.status ]] + + +
+ No nodes available. Please add nodes first. +
+
+ @@ -706,6 +719,8 @@ el: '#app', mixins: [MediaQueryMixin], data: { + availableNodes: [], + multiNodeMode: false, themeSwitcher, persianDatepicker, loadingStates: { @@ -746,6 +761,44 @@ loading(spinning = true) { 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() { this.refreshing = true; const msg = await HttpUtil.get('/panel/api/inbounds/list'); @@ -804,6 +857,11 @@ this.clientCount.splice(0); for (const inbound of dbInbounds) { 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() this.inbounds.push(to_inbound); this.dbInbounds.push(dbInbound); @@ -1041,6 +1099,20 @@ openEditInbound(dbInboundId) { dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); 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({ title: '{{ i18n "pages.inbounds.modifyInbound"}}', okText: '{{ i18n "update"}}', @@ -1075,6 +1147,14 @@ data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); } 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); }, @@ -1100,6 +1180,21 @@ data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); } 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); }, @@ -1252,6 +1347,10 @@ }, checkFallback(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("@")) { rootInbound = this.inbounds.find((i) => i.isTcp && @@ -1312,7 +1411,10 @@ async submit(url, data, modal) { const msg = await HttpUtil.postWithModal(url, data, modal); if (msg.success) { + // Force reload inbounds to get updated nodeIds from server await this.getDBInbounds(); + // Force Vue to update the view + this.$forceUpdate(); } }, getInboundClients(dbInbound) { @@ -1581,7 +1683,8 @@ this.searchInbounds(newVal); }, 500) }, - mounted() { + async mounted() { + await this.loadMultiNodeMode(); if (window.location.protocol !== "https:") { this.showAlert = true; } diff --git a/web/html/modals/inbound_info_modal.html b/web/html/modals/inbound_info_modal.html index 72023e75..14673c48 100644 --- a/web/html/modals/inbound_info_modal.html +++ b/web/html/modals/inbound_info_modal.html @@ -23,6 +23,19 @@ [[ dbInbound.port ]] + + Nodes + + + + @@ -508,8 +521,17 @@ clientIps: '', show(dbInbound, index) { this.index = index; - this.inbound = dbInbound.toInbound(); + // Create DBInbound first to ensure nodeIds are properly processed 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.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; @@ -563,6 +585,12 @@ get 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() { if (infoModal.clientStats) { return infoModal.clientStats.enable; @@ -629,6 +657,10 @@ }) .catch(() => {}); }, + getNodeName(nodeId) { + const node = this.availableNodes.find(n => n.id === nodeId); + return node ? node.name : null; + }, }, }); diff --git a/web/html/modals/inbound_modal.html b/web/html/modals/inbound_modal.html index c3883285..5833aa7c 100644 --- a/web/html/modals/inbound_modal.html +++ b/web/html/modals/inbound_modal.html @@ -22,11 +22,13 @@ show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) { this.title = title; this.okText = okText; + if (inbound) { this.inbound = Inbound.fromJson(inbound.toJson()); } else { this.inbound = new Inbound(); } + // Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet) // This ensures Vue reactivity works properly if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) { @@ -35,14 +37,42 @@ this.inbound.settings.testseed = [900, 500, 900, 256].slice(); } } + if (dbInbound) { this.dbInbound = new DBInbound(dbInbound); } else { 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.visible = true; 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() { inModal.visible = false; @@ -108,6 +138,12 @@ get datepicker() { return app.datepicker; }, + get multiNodeMode() { + return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false; + }, + get availableNodes() { + return app && app.availableNodes || []; + }, get delayedExpireDays() { return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0; }, diff --git a/web/html/modals/node_modal.html b/web/html/modals/node_modal.html new file mode 100644 index 00000000..8822967d --- /dev/null +++ b/web/html/modals/node_modal.html @@ -0,0 +1,232 @@ +{{define "modals/nodeModal"}} + + + + + + + +
+ {{ i18n "pages.nodes.fullUrlHint" }} +
+
+ + +
+ {{ i18n "pages.nodes.apiKeyHint" }} +
+
+
+
+ +{{end}} diff --git a/web/html/nodes.html b/web/html/nodes.html new file mode 100644 index 00000000..6a7172aa --- /dev/null +++ b/web/html/nodes.html @@ -0,0 +1,221 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + +

Nodes Management

+ +
+

Add New Node

+ + + + Add Node +
+ +
+ Refresh + Check All +
+ +
+ +
+
+
+
+
+{{template "page/body_scripts" .}} + +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} + +{{template "page/body_end" .}} diff --git a/web/html/settings.html b/web/html/settings.html index 21294da7..acccb1e8 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -231,6 +231,44 @@ sample = [] this.remarkModel.forEach(r => sample.push(this.remarkModels[r])); 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: { @@ -271,7 +309,21 @@ } 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(); this.saveBtnDisable = true; } @@ -292,7 +344,10 @@ const msg = await HttpUtil.post("/panel/setting/update", this.allSetting); this.loading(false); if (msg.success) { + Vue.prototype.$message.success('{{ i18n "pages.settings.toasts.modifySettings" }}'); await this.getAllSetting(); + } else { + Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.settings.toasts.getSettings" }}'); } }, async updateUser() { diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html index 6969a1b4..fc4bf68c 100644 --- a/web/html/settings/panel/general.html +++ b/web/html/settings/panel/general.html @@ -146,7 +146,33 @@ - + + + + + + + + + + + + + @@ -78,6 +103,8 @@ + + {{template "page/body_scripts" .}} {{template "component/aSidebar" .}} @@ -99,6 +126,7 @@ align: 'left', width: 120, dataIndex: "name", + scopedSlots: { customRender: 'name' }, }, { title: '{{ i18n "pages.nodes.address" }}', align: 'left', @@ -132,6 +160,7 @@ align: 'left', width: 100, dataIndex: "name", + scopedSlots: { customRender: 'name' }, }, { title: '{{ i18n "pages.nodes.status" }}', align: 'center', @@ -152,6 +181,9 @@ nodes: [], refreshing: false, checkingAll: false, + reloadingAll: false, + editingNodeId: null, + editingNodeName: '', }, methods: { async loadNodes() { @@ -192,8 +224,11 @@ case 'check': this.checkNode(node.id); break; + case 'reload': + this.reloadNode(node.id); + break; case 'edit': - this.editNode(node); + this.startEditNodeName(node); break; case 'delete': this.deleteNode(node.id); @@ -256,83 +291,56 @@ } }); }, - editNode(node) { - // Parse existing address - let existingAddress = node.address || ''; - let existingPort = ''; - try { - const url = new URL(existingAddress); - existingAddress = `${url.protocol}//${url.hostname}`; - existingPort = url.port || ''; - } catch (e) { - // If parsing fails, try to extract manually - const match = existingAddress.match(/^(https?:\/\/[^:]+)(?::(\d+))?/); - if (match) { - existingAddress = match[1]; - existingPort = match[2] || ''; - } - } - - const newName = prompt('{{ i18n "pages.nodes.nodeName" }}:', node.name || ''); - if (newName === null) return; - - const newAddress = prompt('{{ i18n "pages.nodes.nodeAddress" }}:', existingAddress); - if (newAddress === null) return; - - const newPort = prompt('{{ i18n "pages.nodes.nodePort" }}:', existingPort); - if (newPort === null) return; - - const newApiKey = prompt('{{ i18n "pages.nodes.nodeApiKey" }} ({{ i18n "pages.nodes.leaveEmptyToKeep" }}):', ''); - - // Validate address format - if (!newAddress.match(/^https?:\/\//)) { - app.$message.error('{{ i18n "pages.nodes.validUrl" }}'); - return; - } - - // Validate port - const portNum = parseInt(newPort); - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { - app.$message.error('{{ i18n "pages.nodes.validPort" }}'); - return; - } - - // Construct full address - const fullAddress = `${newAddress}:${newPort}`; - - // Check for duplicate nodes (excluding current node) - const existingNodes = this.nodes || []; - const duplicate = existingNodes.find(n => { - if (n.id === node.id) return false; // Skip current node - try { - const nodeUrl = new URL(n.address); - const newUrl = new URL(fullAddress); - // Compare protocol, hostname, and port - return nodeUrl.protocol === newUrl.protocol && - nodeUrl.hostname === newUrl.hostname && - (nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80')) === - (newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80')); - } catch (e) { - // If URL parsing fails, do simple string comparison - return n.address === fullAddress; + startEditNodeName(node) { + this.editingNodeId = node.id; + this.editingNodeName = node.name || ''; + // Focus input after Vue updates DOM + this.$nextTick(() => { + const inputId = `node-name-input-${node.id}`; + const input = document.getElementById(inputId); + if (input) { + input.focus(); + input.select(); } }); + }, + cancelEditNodeName() { + this.editingNodeId = null; + this.editingNodeName = ''; + }, + async saveNodeName(nodeId) { + if (this.editingNodeId !== nodeId) { + return; // Not editing this node + } - if (duplicate) { - app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}'); + const newName = (this.editingNodeName || '').trim(); + + if (!newName) { + this.$message.error('{{ i18n "pages.nodes.enterNodeName" }}'); return; } - const nodeData = { - name: newName.trim(), - address: fullAddress - }; - - if (newApiKey !== null && newApiKey.trim() !== '') { - nodeData.apiKey = newApiKey.trim(); + // Check if name changed + const node = this.nodes.find(n => n.id === nodeId); + if (node && node.name === newName) { + // No change, just cancel editing + this.cancelEditNodeName(); + return; } - this.updateNode(node.id, nodeData); + try { + const msg = await HttpUtil.post(`/panel/node/update/${nodeId}`, { name: newName }); + if (msg && msg.success) { + this.$message.success('{{ i18n "pages.nodes.updateSuccess" }}'); + this.cancelEditNodeName(); + await this.loadNodes(); + } else { + this.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.updateError" }}'); + } + } catch (e) { + console.error("Failed to update node name:", e); + this.$message.error('{{ i18n "pages.nodes.updateError" }}'); + } }, async updateNode(id, nodeData) { try { @@ -347,6 +355,39 @@ console.error("Failed to update node:", e); app.$message.error('{{ i18n "pages.nodes.updateError" }}'); } + }, + async reloadNode(id) { + try { + const msg = await HttpUtil.post(`/panel/node/reload/${id}`); + if (msg.success) { + app.$message.success('{{ i18n "pages.nodes.reloadSuccess" }}'); + await this.loadNodes(); + } else { + app.$message.error(msg.msg || '{{ i18n "pages.nodes.reloadError" }}'); + } + } catch (e) { + console.error("Failed to reload node:", e); + app.$message.error('{{ i18n "pages.nodes.reloadError" }}'); + } + }, + async reloadAllNodes() { + this.reloadingAll = true; + try { + const msg = await HttpUtil.post('/panel/node/reloadAll'); + if (msg.success) { + app.$message.success('{{ i18n "pages.nodes.reloadAllSuccess" }}'); + setTimeout(() => { + this.loadNodes(); + }, 2000); + } else { + app.$message.error(msg.msg || '{{ i18n "pages.nodes.reloadAllError" }}'); + } + } catch (e) { + console.error("Failed to reload all nodes:", e); + app.$message.error('{{ i18n "pages.nodes.reloadAllError" }}'); + } finally { + this.reloadingAll = false; + } } }, async mounted() { diff --git a/web/html/settings.html b/web/html/settings.html index acccb1e8..e517853d 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -247,8 +247,8 @@ 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?', + title: '{{ i18n "pages.settings.enableMultiNodeMode" }}', + content: '{{ i18n "pages.settings.enableMultiNodeModeConfirm" }}', class: themeSwitcher.currentTheme, okText: '{{ i18n "sure" }}', cancelText: '{{ i18n "cancel" }}', diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html index fc4bf68c..867d3156 100644 --- a/web/html/settings/panel/general.html +++ b/web/html/settings/panel/general.html @@ -146,28 +146,28 @@ - + - - + + diff --git a/web/service/node.go b/web/service/node.go index e0ca3dac..01d773d9 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -45,9 +45,47 @@ func (s *NodeService) AddNode(node *model.Node) error { } // UpdateNode updates an existing node. +// Only updates fields that are provided (non-empty for strings, non-zero for integers). func (s *NodeService) UpdateNode(node *model.Node) error { db := database.GetDB() - return db.Save(node).Error + + // Get existing node to preserve fields that are not being updated + existingNode, err := s.GetNode(node.Id) + if err != nil { + return fmt.Errorf("failed to get existing node: %w", err) + } + + // Update only provided fields + updates := make(map[string]interface{}) + + if node.Name != "" { + updates["name"] = node.Name + } + + if node.Address != "" { + updates["address"] = node.Address + } + + if node.ApiKey != "" { + updates["api_key"] = node.ApiKey + } + + // Update status and last_check if provided (these are usually set by health checks, not user edits) + if node.Status != "" && node.Status != existingNode.Status { + updates["status"] = node.Status + } + + if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck { + updates["last_check"] = node.LastCheck + } + + // If no fields to update, return early + if len(updates) == 0 { + return nil + } + + // Update only the specified fields + return db.Model(existingNode).Updates(updates).Error } // DeleteNode deletes a node by ID. @@ -408,6 +446,97 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err return nil } +// ReloadNode reloads XRAY on a specific node. +func (s *NodeService) ReloadNode(node *model.Node) error { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + url := fmt.Sprintf("%s/api/v1/reload", node.Address) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + + 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 +} + +// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung). +func (s *NodeService) ForceReloadNode(node *model.Node) error { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + url := fmt.Sprintf("%s/api/v1/force-reload", node.Address) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + + 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 +} + +// ReloadAllNodes reloads XRAY on all nodes. +func (s *NodeService) ReloadAllNodes() error { + nodes, err := s.GetAllNodes() + if err != nil { + return fmt.Errorf("failed to get nodes: %w", err) + } + + type reloadResult struct { + node *model.Node + err error + } + + results := make(chan reloadResult, len(nodes)) + for _, node := range nodes { + go func(n *model.Node) { + err := s.ForceReloadNode(n) // Use force reload to handle hung nodes + results <- reloadResult{node: n, err: err} + }(node) + } + + var errors []string + for i := 0; i < len(nodes); i++ { + result := <-results + if result.err != nil { + errors = append(errors, fmt.Sprintf("node %d (%s): %v", result.node.Id, result.node.Name, result.err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("failed to reload some nodes: %s", strings.Join(errors, "; ")) + } + + 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{ diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 7ee826b6..f09169d5 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -410,6 +410,17 @@ "muxDesc" = "Transmit multiple independent data streams within an established data stream." "muxSett" = "Mux Settings" "direct" = "Direct Connection" +"multiNodeMode" = "Multi-Node Mode" +"multiNodeModeDesc" = "Enable distributed architecture with separate worker nodes. When enabled, XRAY Core runs on nodes instead of locally." +"multiNodeModeEnabled" = "Multi-Node Mode Enabled" +"multiNodeModeInThisMode" = "In this mode:" +"multiNodeModePoint1" = "XRAY Core will not run locally" +"multiNodeModePoint2" = "Configurations will be sent to worker nodes" +"multiNodeModePoint3" = "You need to assign inbounds to nodes" +"multiNodeModePoint4" = "Subscriptions will use node endpoints" +"goToNodesManagement" = "Go to Nodes Management" +"enableMultiNodeMode" = "Enable Multi-Node Mode" +"enableMultiNodeModeConfirm" = "Enabling multi-node mode will stop local XRAY Core. Make sure you have configured worker nodes before enabling this mode. Continue?" "directDesc" = "Directly establishes connections with domains or IP ranges of a specific country." "notifications" = "Notifications" "certs" = "Certificaties" @@ -587,6 +598,7 @@ [pages.nodes] "title" = "Nodes Management" +"addNewNode" = "Add New Node" "addNode" = "Add Node" "editNode" = "Edit Node" "deleteNode" = "Delete Node" @@ -631,6 +643,12 @@ "updateError" = "Failed to update node" "addSuccess" = "Node added successfully" "addError" = "Failed to add node" +"reload" = "Reload" +"reloadAll" = "Reload All Nodes" +"reloadSuccess" = "Node reloaded successfully" +"reloadError" = "Failed to reload node" +"reloadAllSuccess" = "All nodes reloaded successfully" +"reloadAllError" = "Failed to reload some nodes" [pages.nodes.toasts] "createSuccess" = "Node created successfully" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 4afed6bf..88c421e3 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -411,6 +411,17 @@ "muxSett" = "Настройки Mux" "direct" = "Прямое подключение" "directDesc" = "Устанавливает прямые соединения с доменами или IP-адресами определённой страны." +"multiNodeMode" = "Режим Multi-Node" +"multiNodeModeDesc" = "Включить распределенную архитектуру с отдельными рабочими нодами. При включении XRAY Core будет работать на нодах, а не локально." +"multiNodeModeEnabled" = "Режим Multi-Node включен" +"multiNodeModeInThisMode" = "В этом режиме:" +"multiNodeModePoint1" = "XRAY Core не будет работать локально" +"multiNodeModePoint2" = "Конфигурации будут отправляться на рабочие ноды" +"multiNodeModePoint3" = "Необходимо назначить инбаунды на ноды" +"multiNodeModePoint4" = "Подписки будут использовать адреса нод" +"goToNodesManagement" = "Перейти к управлению нодами" +"enableMultiNodeMode" = "Включить режим Multi-Node" +"enableMultiNodeModeConfirm" = "Включение режима Multi-Node остановит локальный XRAY Core. Убедитесь, что вы настроили рабочие ноды перед включением этого режима. Продолжить?" "notifications" = "Уведомления" "certs" = "Сертификаты" "externalTraffic" = "Внешний трафик" @@ -587,6 +598,7 @@ [pages.nodes] "title" = "Управление нодами" +"addNewNode" = "Добавить новую ноду" "addNode" = "Добавить ноду" "editNode" = "Редактировать ноду" "deleteNode" = "Удалить ноду" @@ -631,6 +643,12 @@ "updateError" = "Не удалось обновить ноду" "addSuccess" = "Нода успешно добавлена" "addError" = "Не удалось добавить ноду" +"reload" = "Перезагрузить" +"reloadAll" = "Перезагрузить все ноды" +"reloadSuccess" = "Нода успешно перезагружена" +"reloadError" = "Не удалось перезагрузить ноду" +"reloadAllSuccess" = "Все ноды успешно перезагружены" +"reloadAllError" = "Не удалось перезагрузить некоторые ноды" [pages.nodes.toasts] "createSuccess" = "Нода успешно создана" From 37906b73120e387a0c6c06a3dc67393c6698fc2a Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 03:20:58 +0300 Subject: [PATCH 04/16] edit nodes config,api,checks,dash --- sub/subService.go | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/sub/subService.go b/sub/subService.go index 41c7d67d..0bc291bc 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -552,8 +552,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { externalProxies, _ := stream["externalProxy"].([]any) // Generate links for each node address (or external proxy) - links := "" - linkIndex := 0 + links := make([]string, 0) // First, handle external proxies if any if len(externalProxies) > 0 { @@ -583,13 +582,9 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - if linkIndex > 0 { - links += "\n" - } - links += url.String() - linkIndex++ + links = append(links, url.String()) } - return links + return strings.Join(links, "\n") } // Generate links for each node address @@ -607,14 +602,10 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { url.Fragment = s.genRemark(inbound, email, "") - if linkIndex > 0 { - links += "\n" - } - links += url.String() - linkIndex++ + links = append(links, url.String()) } - return links + return strings.Join(links, "\n") } func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { From 3d3ad2358e1ad8070b735dc53780a27cb8a47910 Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 03:23:43 +0300 Subject: [PATCH 05/16] edit nodes config,api,checks,dash --- sub/subService.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sub/subService.go b/sub/subService.go index 0bc291bc..ab746a26 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -552,7 +552,14 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { externalProxies, _ := stream["externalProxy"].([]any) // Generate links for each node address (or external proxy) - links := make([]string, 0) + // Pre-allocate capacity based on external proxies or node addresses + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) // First, handle external proxies if any if len(externalProxies) > 0 { From 5d355f9c3681aa34de448a47b52409f78f4a138e Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 03:45:40 +0300 Subject: [PATCH 06/16] Remove unnecessary files from PR --- CHANGES_SUMMARY.md | 84 ------------ MULTI_NODE_ARCHITECTURE.md | 264 ------------------------------------- docker-compose.yml | 19 --- 3 files changed, 367 deletions(-) delete mode 100644 CHANGES_SUMMARY.md delete mode 100644 MULTI_NODE_ARCHITECTURE.md delete mode 100644 docker-compose.yml diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md deleted file mode 100644 index 4ff51d45..00000000 --- a/CHANGES_SUMMARY.md +++ /dev/null @@ -1,84 +0,0 @@ -# Сводка изменений для 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-сервиса diff --git a/MULTI_NODE_ARCHITECTURE.md b/MULTI_NODE_ARCHITECTURE.md deleted file mode 100644 index f66ed2ee..00000000 --- a/MULTI_NODE_ARCHITECTURE.md +++ /dev/null @@ -1,264 +0,0 @@ -# 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 -``` - -#### 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. Проверьте, что порт инбаунда открыт на ноде diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8e146db7..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -services: - 3xui: - build: - context: . - dockerfile: ./Dockerfile - container_name: 3xui_app - # hostname: yourhostname <- optional - ports: - - "2053:2053" - - "2096:2096" - volumes: - - $PWD/db/:/etc/x-ui/ - - $PWD/cert/:/root/cert/ - environment: - XRAY_VMESS_AEAD_FORCED: "false" - XUI_ENABLE_FAIL2BAN: "true" - tty: true - # network_mode: host - restart: unless-stopped From 42b28a59e4dc3eadaba141bf9bfa8a83e45ebbbb Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 03:51:41 +0300 Subject: [PATCH 07/16] Restore dokcer-compose.yml --- docker-compose.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..53784309 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + 3xui: + build: + context: . + dockerfile: ./Dockerfile + container_name: 3xui_app + # hostname: yourhostname <- optional + volumes: + - $PWD/db/:/etc/x-ui/ + - $PWD/cert/:/root/cert/ + environment: + XRAY_VMESS_AEAD_FORCED: "false" + XUI_ENABLE_FAIL2BAN: "true" + tty: true + network_mode: host + restart: unless-stopped \ No newline at end of file From b6f336a15c3851b84657b93e47136efd676d98fc Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 03:56:49 +0300 Subject: [PATCH 08/16] translate to eng --- node/README.md | 57 +++++++++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/node/README.md b/node/README.md index 2f4cc182..a672ee7b 100644 --- a/node/README.md +++ b/node/README.md @@ -1,37 +1,46 @@ # 3x-ui Node Service -Node service (worker) для multi-node архитектуры 3x-ui. +Node service (worker) for 3x-ui multi-node architecture. -## Описание +## Description -Этот сервис запускается на отдельных серверах и управляет XRAY Core инстансами. Панель 3x-ui (master) отправляет конфигурации на ноды через REST API. +This service runs on separate servers and manages XRAY Core instances. The 3x-ui panel (master) sends configurations to nodes via REST API. -## Функциональность +## Features -- REST API для управления XRAY Core -- Применение конфигураций от панели -- Перезагрузка XRAY без остановки контейнера -- Проверка статуса и здоровья +- REST API for XRAY Core management +- Apply configurations from the panel +- Reload XRAY without stopping the container +- Status and health checks ## API Endpoints ### `GET /health` -Проверка здоровья сервиса (без аутентификации) +Health check endpoint (no authentication required) -### `POST /api/v1/apply-config` -Применить новую конфигурацию XRAY +### `POST /api/v1/apply` +Apply new XRAY configuration - **Headers**: `Authorization: Bearer ` -- **Body**: JSON конфигурация XRAY +- **Body**: XRAY JSON configuration ### `POST /api/v1/reload` -Перезагрузить XRAY +Reload XRAY +- **Headers**: `Authorization: Bearer ` + +### `POST /api/v1/force-reload` +Force reload XRAY (stops and restarts) - **Headers**: `Authorization: Bearer ` ### `GET /api/v1/status` -Получить статус XRAY +Get XRAY status - **Headers**: `Authorization: Bearer ` -## Запуск +### `GET /api/v1/stats` +Get traffic statistics and online clients +- **Headers**: `Authorization: Bearer ` +- **Query Parameters**: `reset=true` to reset statistics after reading + +## Running ### Docker Compose @@ -40,31 +49,31 @@ cd node NODE_API_KEY=your-secure-api-key docker-compose up -d --build ``` -**Примечание:** XRAY Core автоматически скачивается во время сборки Docker-образа для вашей архитектуры. Docker BuildKit автоматически определяет архитектуру хоста. Для явного указания архитектуры используйте: +**Note:** XRAY Core is automatically downloaded during Docker image build for your architecture. Docker BuildKit automatically detects the host architecture. To explicitly specify the architecture, use: ```bash DOCKER_BUILDKIT=1 docker build --build-arg TARGETARCH=arm64 -t 3x-ui-node -f node/Dockerfile .. ``` -### Вручную +### Manual ```bash go run node/main.go -port 8080 -api-key your-secure-api-key ``` -## Переменные окружения +## Environment Variables -- `NODE_API_KEY` - API ключ для аутентификации (обязательно) +- `NODE_API_KEY` - API key for authentication (required) -## Структура +## Structure ``` node/ -├── main.go # Точка входа +├── main.go # Entry point ├── api/ -│ └── server.go # REST API сервер +│ └── server.go # REST API server ├── xray/ -│ └── manager.go # Управление XRAY процессом -├── Dockerfile # Docker образ +│ └── manager.go # XRAY process management +├── Dockerfile # Docker image └── docker-compose.yml ``` From 66662afa4de9583c372f9f92fdb20ed120958f75 Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Wed, 7 Jan 2026 22:05:04 +0300 Subject: [PATCH 09/16] feat: add geo files to nodes and fix func inbounds --- node/Dockerfile | 4 + node/docker-compose.yml | 38 ------- node/xray/manager.go | 84 ++++++++++++++++ web/controller/inbound.go | 203 +++++++++++++++++++++++++++++++------- 4 files changed, 254 insertions(+), 75 deletions(-) diff --git a/node/Dockerfile b/node/Dockerfile index d6d3f8ba..31a73050 100644 --- a/node/Dockerfile +++ b/node/Dockerfile @@ -65,6 +65,10 @@ RUN mkdir -p bin && \ 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 && \ + curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat && \ + curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat && \ + curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat && \ + curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat && \ echo "Final files in bin:" && \ ls -lah && \ echo "File sizes:" && \ diff --git a/node/docker-compose.yml b/node/docker-compose.yml index ea9454dd..d72d6407 100644 --- a/node/docker-compose.yml +++ b/node/docker-compose.yml @@ -18,45 +18,7 @@ services: # If the file doesn't exist, it will be created when XRAY config is first applied 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: - - ./bin/config.json:/app/bin/config.json - - ./logs:/app/logs - # Note: config.json is mounted directly for persistence - # If the file doesn't exist, it will be created when XRAY config is first applied - 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: - - ./bin/config.json:/app/bin/config.json - - ./logs:/app/logs - # Note: config.json is mounted directly for persistence - # If the file doesn't exist, it will be created when XRAY config is first applied - networks: - - xray-network networks: xray-network: driver: bridge diff --git a/node/xray/manager.go b/node/xray/manager.go index 31a92326..a8522275 100644 --- a/node/xray/manager.go +++ b/node/xray/manager.go @@ -5,7 +5,10 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" "os" + "path/filepath" "sync" "time" @@ -31,11 +34,92 @@ type Manager struct { // NewManager creates a new XRAY manager instance. func NewManager() *Manager { m := &Manager{} + // Download geo files if missing + m.downloadGeoFiles() // Try to load config from file on startup m.LoadConfigFromFile() return m } +// downloadGeoFiles downloads geo data files if they are missing. +// These files are required for routing rules that use geoip/geosite matching. +func (m *Manager) downloadGeoFiles() { + // Possible bin folder paths (in order of priority) + binPaths := []string{ + "bin", + "/app/bin", + "./bin", + } + + var binPath string + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + binPath = path + break + } + } + + if binPath == "" { + logger.Debug("No bin folder found, skipping geo files download") + return + } + + // List of geo files to download + geoFiles := []struct { + URL string + FileName string + }{ + {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"}, + {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"}, + {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"}, + {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"}, + {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"}, + {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"}, + } + + downloadFile := func(url, destPath string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %d", resp.StatusCode) + } + + file, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil + } + + for _, file := range geoFiles { + destPath := filepath.Join(binPath, file.FileName) + + // Check if file already exists + if _, err := os.Stat(destPath); err == nil { + logger.Debugf("Geo file %s already exists, skipping download", file.FileName) + continue + } + + logger.Infof("Downloading geo file: %s", file.FileName) + if err := downloadFile(file.URL, destPath); err != nil { + logger.Warningf("Failed to download %s: %v", file.FileName, err) + } else { + logger.Infof("Successfully downloaded %s", file.FileName) + } + } +} + // LoadConfigFromFile attempts to load XRAY configuration from config.json file. // It checks multiple possible locations: bin/config.json, config/config.json, and ./config.json func (m *Manager) LoadConfigFromFile() error { diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 0ba7b7d5..a1b8be40 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -1,8 +1,10 @@ package controller import ( + "bytes" "encoding/json" "fmt" + "io" "strconv" "github.com/mhsanaei/3x-ui/v2/database/model" @@ -104,6 +106,53 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) { // addInbound creates a new inbound configuration. func (a *InboundController) addInbound(c *gin.Context) { + // Try to get nodeIds from JSON body first (if Content-Type is application/json) + // This must be done BEFORE ShouldBind, which reads the body + var nodeIdsFromJSON []int + var nodeIdFromJSON *int + var hasNodeIdsInJSON, hasNodeIdInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract nodeIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract nodeIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for nodeIds array + if nodeIdsVal, ok := jsonData["nodeIds"]; ok { + hasNodeIdsInJSON = true + if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok { + for _, val := range nodeIdsArray { + if num, ok := val.(float64); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + } else if num, ok := nodeIdsVal.(float64); ok { + // Single number instead of array + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := nodeIdsVal.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + // Check for nodeId (backward compatibility) + if nodeIdVal, ok := jsonData["nodeId"]; ok { + hasNodeIdInJSON = true + if num, ok := nodeIdVal.(float64); ok { + nodeId := int(num) + nodeIdFromJSON = &nodeId + } else if num, ok := nodeIdVal.(int); ok { + nodeIdFromJSON = &num + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + inbound := &model.Inbound{} err := c.ShouldBind(inbound) if err != nil { @@ -130,19 +179,38 @@ func (a *InboundController) addInbound(c *gin.Context) { // Handle node assignment in multi-node mode nodeService := service.NodeService{} - // Get nodeIds from form (array format: nodeIds=1&nodeIds=2) + // Get nodeIds from form (for form-encoded requests) 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) + + // Determine which source to use: JSON takes precedence over form data + useJSON := hasNodeIdsInJSON || hasNodeIdInJSON + useForm := (len(nodeIdsStr) > 0 || nodeIdStr != "") && !useJSON + + if useJSON || useForm { + var nodeIds []int + var nodeId *int + + if useJSON { + // Use data from JSON + nodeIds = nodeIdsFromJSON + nodeId = nodeIdFromJSON + } else { + // Parse nodeIds array from form + for _, idStr := range nodeIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + nodeIds = append(nodeIds, id) + } + } + } + // Parse single nodeId from form + if nodeIdStr != "" && nodeIdStr != "null" { + if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 { + nodeId = &parsedId } } } @@ -154,13 +222,10 @@ func (a *InboundController) addInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } - } else if nodeIdStr != "" && nodeIdStr != "null" { + } else if nodeId != nil && *nodeId > 0 { // 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) - } + if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil { + logger.Warningf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err) } } } @@ -204,8 +269,53 @@ func (a *InboundController) updateInbound(c *gin.Context) { return } - // Get nodeIds from form BEFORE binding to avoid conflict with ShouldBind - // Get nodeIds from form (array format: nodeIds=1&nodeIds=2) + // Try to get nodeIds from JSON body first (if Content-Type is application/json) + var nodeIdsFromJSON []int + var nodeIdFromJSON *int + var hasNodeIdsInJSON, hasNodeIdInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract nodeIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract nodeIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for nodeIds array + if nodeIdsVal, ok := jsonData["nodeIds"]; ok { + hasNodeIdsInJSON = true + if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok { + for _, val := range nodeIdsArray { + if num, ok := val.(float64); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + } else if num, ok := nodeIdsVal.(float64); ok { + // Single number instead of array + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := nodeIdsVal.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + // Check for nodeId (backward compatibility) + if nodeIdVal, ok := jsonData["nodeId"]; ok { + hasNodeIdInJSON = true + if num, ok := nodeIdVal.(float64); ok { + nodeId := int(num) + nodeIdFromJSON = &nodeId + } else if num, ok := nodeIdVal.(int); ok { + nodeIdFromJSON = &num + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + // Get nodeIds from form (for form-encoded requests) nodeIdsStr := c.PostFormArray("nodeIds") logger.Debugf("Received nodeIds from form: %v (count: %d)", nodeIdsStr, len(nodeIdsStr)) @@ -217,6 +327,7 @@ func (a *InboundController) updateInbound(c *gin.Context) { _, hasNodeIds := c.GetPostForm("nodeIds") _, hasNodeId := c.GetPostForm("nodeId") logger.Debugf("Form has nodeIds: %v, has nodeId: %v", hasNodeIds, hasNodeId) + logger.Debugf("JSON has nodeIds: %v (values: %v), has nodeId: %v (value: %v)", hasNodeIdsInJSON, nodeIdsFromJSON, hasNodeIdInJSON, nodeIdFromJSON) inbound := &model.Inbound{ Id: id, @@ -238,20 +349,42 @@ func (a *InboundController) updateInbound(c *gin.Context) { // 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) + // Determine which source to use: JSON takes precedence over form data + useJSON := hasNodeIdsInJSON || hasNodeIdInJSON + useForm := (hasNodeIds || hasNodeId) && !useJSON + + if useJSON || useForm { + var nodeIds []int + var nodeId *int + var hasNodeIdsFlag bool + + if useJSON { + // Use data from JSON + nodeIds = nodeIdsFromJSON + nodeId = nodeIdFromJSON + hasNodeIdsFlag = hasNodeIdsInJSON + } else { + // Use data from form + hasNodeIdsFlag = hasNodeIds + // Parse nodeIds array from form + 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) + } + } + } + // Parse single nodeId from form + if nodeIdStr != "" && nodeIdStr != "null" { + if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 { + nodeId = &parsedId } } } - logger.Debugf("Parsed nodeIds: %v", nodeIds) + logger.Debugf("Parsed nodeIds: %v, nodeId: %v", nodeIds, nodeId) if len(nodeIds) > 0 { // Assign to multiple nodes @@ -261,19 +394,15 @@ func (a *InboundController) updateInbound(c *gin.Context) { return } logger.Debugf("Successfully assigned inbound %d to nodes %v", inbound.Id, nodeIds) - } else if nodeIdStr != "" && nodeIdStr != "null" { + } else if nodeId != nil && *nodeId > 0 { // 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) + if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil { + logger.Errorf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return } - } else if hasNodeIds { + logger.Debugf("Successfully assigned inbound %d to node %d", inbound.Id, *nodeId) + } else if hasNodeIdsFlag { // 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) From 7e2f3fda032db1b3156a3a5a82ebffa462e0428d Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Fri, 9 Jan 2026 15:36:14 +0300 Subject: [PATCH 10/16] refactor panel new logic --- database/db.go | 5 + database/model/model.go | 103 ++ node/docker-compose.yml | 37 + sub/subController.go | 17 +- sub/subJsonService.go | 16 +- sub/subService.go | 1135 +++++++++++++++++++--- web/assets/js/model/inbound.js | 8 +- web/assets/js/model/setting.js | 18 + web/controller/client.go | 274 ++++++ web/controller/client_hwid.go | 224 +++++ web/controller/host.go | 253 +++++ web/controller/xui.go | 17 + web/entity/entity.go | 16 + web/html/clients.html | 730 ++++++++++++++ web/html/component/aSidebar.html | 14 +- web/html/form/protocol/shadowsocks.html | 7 +- web/html/form/protocol/trojan.html | 7 +- web/html/form/protocol/vless.html | 7 +- web/html/form/protocol/vmess.html | 7 +- web/html/hosts.html | 395 ++++++++ web/html/inbounds.html | 21 - web/html/modals/client_entity_modal.html | 307 ++++++ web/html/modals/client_modal.html | 7 + web/html/modals/host_modal.html | 153 +++ web/html/modals/inbound_modal.html | 9 - web/html/modals/qrcode_modal.html | 25 +- web/job/check_client_hwid_job.go | 155 +++ web/service/client.go | 602 ++++++++++++ web/service/client_hwid.go | 342 +++++++ web/service/host.go | 254 +++++ web/service/inbound.go | 192 +++- web/service/setting.go | 36 + web/translation/translate.en_US.toml | 103 +- web/translation/translate.ru_RU.toml | 103 +- web/web.go | 3 + 35 files changed, 5369 insertions(+), 233 deletions(-) create mode 100644 web/controller/client.go create mode 100644 web/controller/client_hwid.go create mode 100644 web/controller/host.go create mode 100644 web/html/clients.html create mode 100644 web/html/hosts.html create mode 100644 web/html/modals/client_entity_modal.html create mode 100644 web/html/modals/host_modal.html create mode 100644 web/job/check_client_hwid_job.go create mode 100644 web/service/client.go create mode 100644 web/service/client_hwid.go create mode 100644 web/service/host.go diff --git a/database/db.go b/database/db.go index b33a0621..f1bc99df 100644 --- a/database/db.go +++ b/database/db.go @@ -40,6 +40,11 @@ func initModels() error { &model.HistoryOfSeeders{}, &model.Node{}, &model.InboundNodeMapping{}, + &model.ClientEntity{}, + &model.ClientInboundMapping{}, + &model.Host{}, + &model.HostInboundMapping{}, + &model.ClientHWID{}, // HWID tracking for clients } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 51203a43..5ad12305 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -104,6 +104,8 @@ type Setting struct { } // Client represents a client configuration for Xray inbounds with traffic limits and settings. +// This is a legacy struct used for JSON parsing from inbound Settings. +// For database operations, use ClientEntity instead. type Client struct { ID string `json:"id"` // Unique client identifier Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") @@ -122,6 +124,42 @@ type Client struct { UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } +// ClientEntity represents a client as a separate database entity. +// Clients can be assigned to multiple inbounds. +type ClientEntity struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_user_email"` // Client email identifier (unique per user) + UUID string `json:"uuid" form:"uuid"` // UUID/ID for VMESS/VLESS + Security string `json:"security" form:"security"` // Security method (e.g., "auto", "aes-128-gcm") + Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks) + Flow string `json:"flow" form:"flow"` // Flow control (XTLS) + LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client + TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB + ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp + Enable bool `json:"enable" form:"enable"` // Whether the client is enabled + TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications + SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier + Comment string `json:"comment" form:"comment"` // Client comment + Reset int `json:"reset" form:"reset"` // Reset period in days + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to + + // Traffic statistics (loaded from client_traffics table, not stored in ClientEntity table) + Up int64 `json:"up,omitempty" form:"-" gorm:"-"` // Upload traffic in bytes + Down int64 `json:"down,omitempty" form:"-" gorm:"-"` // Download traffic in bytes + AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"-"` // All-time traffic usage + LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"-"` // Last online timestamp + + // HWID (Hardware ID) restrictions + HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client + MaxHWID int `json:"maxHwid" form:"maxHwid" gorm:"column:max_hwid;default:1"` // Maximum number of allowed HWID devices (0 = unlimited) + HWIDs []*ClientHWID `json:"hwids,omitempty" form:"-" gorm:"-"` // Registered HWIDs for this client (loaded from client_hwids table, not stored in ClientEntity table) +} + // Node represents a worker node in multi-node architecture. type Node struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier @@ -139,4 +177,69 @@ 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 +} + +// ClientInboundMapping maps clients to inbounds (many-to-many relationship). +type ClientInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"uniqueIndex:idx_client_inbound"` // Client ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_client_inbound"` // Inbound ID +} + +// Host represents a proxy/balancer host configuration for multi-node mode. +// Hosts can override the node address when generating subscription links. +type Host struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Name string `json:"name" form:"name"` // Host name/identifier + Address string `json:"address" form:"address"` // Host address (IP or domain) + Port int `json:"port" form:"port"` // Host port (0 means use inbound port) + Protocol string `json:"protocol" form:"protocol"` // Protocol override (optional) + Remark string `json:"remark" form:"remark"` // Host remark/description + Enable bool `json:"enable" form:"enable"` // Whether the host is enabled + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this host applies to +} + +// HostInboundMapping maps hosts to inbounds (many-to-many relationship). +type HostInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + HostId int `json:"hostId" form:"hostId" gorm:"uniqueIndex:idx_host_inbound"` // Host ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_host_inbound"` // Inbound ID +} + +// ClientHWID represents a hardware ID (HWID) associated with a client. +// HWID is provided explicitly by client applications via HTTP headers (x-hwid). +// Server MUST NOT generate or derive HWID from IP, User-Agent, or access logs. +type ClientHWID struct { + // TableName specifies the table name for GORM + // GORM by default would use "client_hwids" but the actual table is "client_hw_ids" + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"column:client_id;index:idx_client_hwid"` // Client ID + HWID string `json:"hwid" form:"hwid" gorm:"column:hwid;index:idx_client_hwid"` // Hardware ID (unique per client, provided by client via x-hwid header) + DeviceName string `json:"deviceName" form:"deviceName" gorm:"column:device_name"` // Optional device name/description (deprecated, use DeviceModel instead) + DeviceOS string `json:"deviceOs" form:"deviceOs" gorm:"column:device_os"` // Device operating system (from x-device-os header) + DeviceModel string `json:"deviceModel" form:"deviceModel" gorm:"column:device_model"` // Device model (from x-device-model header) + OSVersion string `json:"osVersion" form:"osVersion" gorm:"column:os_version"` // OS version (from x-ver-os header) + FirstSeenAt int64 `json:"firstSeenAt" gorm:"column:first_seen_at;autoCreateTime"` // First time this HWID was seen (timestamp) + LastSeenAt int64 `json:"lastSeenAt" gorm:"column:last_seen_at;autoUpdateTime"` // Last time this HWID was used (timestamp) + FirstSeenIP string `json:"firstSeenIp" form:"firstSeenIp" gorm:"column:first_seen_ip"` // IP address when first seen + IsActive bool `json:"isActive" form:"isActive" gorm:"column:is_active;default:true"` // Whether this HWID is currently active + IPAddress string `json:"ipAddress" form:"ipAddress" gorm:"column:ip_address"` // Last known IP address for this HWID + UserAgent string `json:"userAgent" form:"userAgent" gorm:"column:user_agent"` // User agent or client identifier (if available) + BlockedAt *int64 `json:"blockedAt,omitempty" form:"blockedAt" gorm:"column:blocked_at"` // Timestamp when HWID was blocked (null if not blocked) + BlockReason string `json:"blockReason,omitempty" form:"blockReason" gorm:"column:block_reason"` // Reason for blocking (e.g., "HWID limit exceeded") + + // Legacy fields (deprecated, kept for backward compatibility) + FirstSeen int64 `json:"firstSeen,omitempty" gorm:"-"` // Deprecated: use FirstSeenAt + LastSeen int64 `json:"lastSeen,omitempty" gorm:"-"` // Deprecated: use LastSeenAt +} + +// TableName specifies the table name for ClientHWID. +// GORM by default would use "client_hwids" but the actual table is "client_hw_ids" +func (ClientHWID) TableName() string { + return "client_hw_ids" } \ No newline at end of file diff --git a/node/docker-compose.yml b/node/docker-compose.yml index d72d6407..4d9b2803 100644 --- a/node/docker-compose.yml +++ b/node/docker-compose.yml @@ -18,7 +18,44 @@ services: # If the file doesn't exist, it will be created when XRAY config is first applied 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:44001" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network + node3: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node3 + restart: unless-stopped + environment: + - NODE_API_KEY=test-key + ports: + - "8082:8080" + - "44002:44002" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network networks: xray-network: driver: bridge diff --git a/sub/subController.go b/sub/subController.go index a219dd63..2ddf1fe4 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -41,8 +41,10 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) - // Initialize NodeService for multi-node support + // Initialize services for multi-node support and new architecture sub.nodeService = service.NodeService{} + sub.hostService = service.HostService{} + sub.clientService = service.ClientService{} a := &SUBController{ subTitle: subTitle, subPath: subPath, @@ -73,7 +75,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) subs(c *gin.Context) { subId := c.Param("subid") scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) - subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) + subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host, c) // Pass context for HWID registration if err != nil || len(subs) == 0 { c.String(400, "Error!") } else { @@ -130,7 +132,7 @@ func (a *SUBController) subs(c *gin.Context) { // Add headers header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) if a.subEncrypt { c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) @@ -144,21 +146,24 @@ func (a *SUBController) subs(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) { subId := c.Param("subid") _, host, _, _ := a.subService.ResolveRequest(c) - jsonSub, header, err := a.subJsonService.GetJson(subId, host) + jsonSub, header, err := a.subJsonService.GetJson(subId, host, c) // Pass context for HWID registration if err != nil || len(jsonSub) == 0 { c.String(400, "Error!") } else { // Add headers - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) c.String(200, jsonSub) } } // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. -func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) { +// Also adds X-Subscription-ID header so clients can use it as HWID if needed. +func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle, subId string) { c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Profile-Update-Interval", updateInterval) c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle))) + // Add subscription ID header so clients can use it as HWID identifier + c.Writer.Header().Set("X-Subscription-ID", subId) } diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 8222491a..ff043dc5 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -7,6 +7,8 @@ import ( "maps" "strings" + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/json_util" @@ -71,7 +73,19 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string, } // GetJson generates a JSON subscription configuration for the given subscription ID and host. -func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers. +func (s *SubJsonService) GetJson(subId string, host string, c *gin.Context) (string, string, error) { + // Register HWID from headers if context is provided + if c != nil { + // Try to find client by subId + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + if err == nil && clientEntity != nil { + s.SubService.registerHWIDFromRequest(c, clientEntity) + } + } + inbounds, err := s.SubService.getInboundsBySubId(subId) if err != nil || len(inbounds) == 0 { return "", "", err diff --git a/sub/subService.go b/sub/subService.go index ab746a26..7ed97f25 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -29,6 +29,9 @@ type SubService struct { inboundService service.InboundService settingService service.SettingService nodeService service.NodeService + hostService service.HostService + clientService service.ClientService + hwidService service.ClientHWIDService } // NewSubService creates a new subscription service with the given configuration. @@ -40,12 +43,34 @@ func NewSubService(showInfo bool, remarkModel string) *SubService { } // GetSubs retrieves subscription links for a given subscription ID and host. -func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers (x-hwid, x-device-os, etc.). +func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]string, int64, xray.ClientTraffic, error) { s.address = host var result []string var traffic xray.ClientTraffic var lastOnline int64 var clientTraffics []xray.ClientTraffic + + // Try to find client by subId in new architecture (ClientEntity) + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + useNewArchitecture := (err == nil && clientEntity != nil) + + if err != nil { + logger.Debugf("GetSubs: Client not found by subId '%s': %v", subId, err) + } else if clientEntity != nil { + logger.Debugf("GetSubs: Found client by subId '%s': clientId=%d, email=%s, hwidEnabled=%v", + subId, clientEntity.Id, clientEntity.Email, clientEntity.HWIDEnabled) + } + + // Register HWID from headers if context is provided and client is found + if c != nil && clientEntity != nil { + s.registerHWIDFromRequest(c, clientEntity) + } else if c != nil { + logger.Debugf("GetSubs: Skipping HWID registration - client not found or context is nil (subId: %s)", subId) + } + inbounds, err := s.getInboundsBySubId(subId) if err != nil { return nil, 0, traffic, err @@ -59,14 +84,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C if err != nil { s.datepicker = "gregorian" } + for _, inbound := range inbounds { - clients, err := s.inboundService.GetClients(inbound) - if err != nil { - logger.Error("SubService - GetClients: Unable to get clients from inbound") - } - if clients == nil { - continue - } if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings) if err == nil { @@ -75,21 +94,48 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C inbound.StreamSettings = streamSettings } } - for _, client := range clients { - if client.Enable && client.SubID == subId { - link := s.getLink(inbound, client.Email) - // 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) - } + + if useNewArchitecture { + // New architecture: use ClientEntity data directly + link := s.getLinkWithClient(inbound, clientEntity) + // 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) - clientTraffics = append(clientTraffics, ct) - if ct.LastOnline > lastOnline { - lastOnline = ct.LastOnline + } + ct := s.getClientTraffics(inbound.ClientStats, clientEntity.Email) + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } + } else { + // Old architecture: parse clients from Settings + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + logger.Error("SubService - GetClients: Unable to get clients from inbound") + } + if clients == nil { + continue + } + for _, client := range clients { + if client.Enable && client.SubID == subId { + link := s.getLink(inbound, client.Email) + // 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) + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } } } } @@ -120,10 +166,45 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C return result, lastOnline, traffic, nil } +// getInboundsBySubId retrieves all inbounds assigned to a client with the given subId. +// New architecture: Find client by subId, then find inbounds through ClientInboundMapping. func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { db := database.GetDB() + + // First, try to find client by subId in ClientEntity (new architecture) + var client model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&client).Error + if err == nil { + // Found client in new architecture, get inbounds through mapping + var mappings []model.ClientInboundMapping + err = db.Where("client_id = ?", client.Id).Find(&mappings).Error + if err != nil { + return nil, err + } + + if len(mappings) == 0 { + return []*model.Inbound{}, nil + } + + inboundIds := make([]int, len(mappings)) + for i, mapping := range mappings { + inboundIds[i] = mapping.InboundId + } + + var inbounds []*model.Inbound + err = db.Model(model.Inbound{}).Preload("ClientStats"). + Where("id IN ? AND enable = ? AND protocol IN ?", + inboundIds, true, []model.Protocol{model.VMESS, model.VLESS, model.Trojan, model.Shadowsocks}). + Find(&inbounds).Error + if err != nil { + return nil, err + } + return inbounds, nil + } + + // Fallback to old architecture: search in Settings JSON (for backward compatibility) var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( + err = db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( SELECT DISTINCT inbounds.id FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client @@ -183,13 +264,44 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string { return "" } -func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { - if inbound.Protocol != model.VMESS { - return "" +// getLinkWithClient generates a subscription link using ClientEntity data (new architecture) +func (s *SubService) getLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + switch inbound.Protocol { + case "vmess": + return s.genVmessLinkWithClient(inbound, client) + case "vless": + return s.genVlessLinkWithClient(inbound, client) + case "trojan": + return s.genTrojanLinkWithClient(inbound, client) + case "shadowsocks": + return s.genShadowsocksLinkWithClient(inbound, client) + } + return "" +} + +// AddressPort represents an address and port for subscription links +type AddressPort struct { + Address string + Port int // 0 means use inbound.Port +} + +// getAddressesForInbound returns addresses for subscription links. +// Priority: Host (if enabled) > Node addresses > default address +// Returns addresses and ports (0 means use inbound.Port) +func (s *SubService) getAddressesForInbound(inbound *model.Inbound) []AddressPort { + // First, check if there's a Host assigned to this inbound + host, err := s.hostService.GetHostForInbound(inbound.Id) + if err == nil && host != nil && host.Enable { + // Use host address and port + hostPort := host.Port + if hostPort > 0 { + return []AddressPort{{Address: host.Address, Port: hostPort}} + } + return []AddressPort{{Address: host.Address, Port: 0}} // 0 means use inbound.Port } - // Get all nodes for this inbound - var nodeAddresses []string + // Second, get node addresses if in multi-node mode + var nodeAddresses []AddressPort multiMode, _ := s.settingService.GetMultiNodeMode() if multiMode { nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) @@ -198,22 +310,33 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, node := range nodes { nodeAddr := s.extractNodeHost(node.Address) if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) + nodeAddresses = append(nodeAddresses, AddressPort{Address: nodeAddr, Port: 0}) } } } } // Fallback to default logic if no nodes found - var defaultAddress string if len(nodeAddresses) == 0 { + var defaultAddress string if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { defaultAddress = s.address } else { defaultAddress = inbound.Listen } - nodeAddresses = []string{defaultAddress} + nodeAddresses = []AddressPort{{Address: defaultAddress, Port: 0}} } + + return nodeAddresses +} + +func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) // Base object template (address will be set per node) baseObj := map[string]any{ "v": "2", @@ -351,12 +474,16 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { + for _, addrPort := range nodeAddresses { obj := make(map[string]any) for k, v := range baseObj { obj[k] = v } - obj["add"] = nodeAddr + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } obj["ps"] = s.genRemark(inbound, email, "") if linkIndex > 0 { @@ -370,37 +497,385 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { return links } +// genVmessLinkWithClient generates VMESS link using ClientEntity data (new architecture) +func (s *SubService) genVmessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + // Base object template (address will be set per node) + baseObj := map[string]any{ + "v": "2", + "port": inbound.Port, + "type": "none", + } + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + network, _ := stream["network"].(string) + baseObj["net"] = network + switch network { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + baseObj["type"] = typeStr + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + baseObj["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + baseObj["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + baseObj["type"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + baseObj["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + baseObj["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + baseObj["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + baseObj["tls"] = security + if security == "tls" { + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + if len(alpns) > 0 { + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + baseObj["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + baseObj["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + baseObj["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + baseObj["allowInsecure"], _ = insecure.(bool) + } + } + } + + // Use ClientEntity data directly + baseObj["id"] = client.UUID + baseObj["scy"] = client.Security + + 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 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + newObj := map[string]any{} + for key, value := range baseObj { + if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { + newObj[key] = value + } + } + newObj["ps"] = s.genRemark(inbound, client.Email, ep["remark"].(string)) + newObj["add"] = ep["dest"].(string) + newObj["port"] = int(ep["port"].(float64)) + + if newSecurity != "same" { + newObj["tls"] = newSecurity + } + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(newObj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + return links + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + obj := make(map[string]any) + for k, v := range baseObj { + obj[k] = v + } + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } + obj["ps"] = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(obj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + + return links +} + +// genVlessLinkWithClient generates VLESS link using ClientEntity data (new architecture) +func (s *SubService) genVlessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VLESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + uuid := client.UUID + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + // Add encryption parameter for VLESS from inbound settings + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + if encryption, ok := settings["encryption"].(string); ok { + params["encryption"] = encryption + } + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + links = append(links, url.String()) + } + return strings.Join(links, "\n") + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + links = append(links, url.String()) + } + + return strings.Join(links, "\n") +} + func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VLESS { 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} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -595,8 +1070,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -615,37 +1095,215 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { return strings.Join(links, "\n") } +// genTrojanLinkWithClient generates Trojan link using ClientEntity data (new architecture) +func (s *SubService) genTrojanLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.Trojan { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + password := client.Password + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Trojan { 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} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -827,8 +1485,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -851,37 +1514,186 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string return links } +// genShadowsocksLinkWithClient generates Shadowsocks link using ClientEntity data (new architecture) +func (s *SubService) genShadowsocksLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.Shadowsocks { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + inboundPassword := settings["password"].(string) + method := settings["method"].(string) + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + encPart := fmt.Sprintf("%s:%s", method, client.Password) + if method[0] == '2' { + encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, client.Password) + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Shadowsocks { 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} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -1034,8 +1846,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -1386,3 +2203,87 @@ func (s *SubService) extractNodeHost(nodeAddress string) string { } return host } + +// registerHWIDFromRequest registers HWID from HTTP headers in the request context. +// This method reads HWID and device metadata from headers and calls RegisterHWIDFromHeaders. +func (s *SubService) registerHWIDFromRequest(c *gin.Context, clientEntity *model.ClientEntity) { + logger.Debugf("registerHWIDFromRequest called for client %d (subId: %s, email: %s, hwidEnabled: %v)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, clientEntity.HWIDEnabled) + + // Check HWID mode - only register in client_header mode + settingService := service.SettingService{} + hwidMode, err := settingService.GetHwidMode() + if err != nil { + logger.Debugf("Failed to get hwidMode setting: %v", err) + return + } + logger.Debugf("Current hwidMode: %s", hwidMode) + + // Only register in client_header mode + if hwidMode != "client_header" { + logger.Debugf("HWID registration skipped: hwidMode is '%s' (not 'client_header') for client %d (subId: %s)", + hwidMode, clientEntity.Id, clientEntity.SubID) + return + } + + // Check if client has HWID tracking enabled + if !clientEntity.HWIDEnabled { + logger.Debugf("HWID registration skipped: HWID tracking disabled for client %d (subId: %s, email: %s)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read HWID from headers (required) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + // No HWID header - mark as "unknown" device, don't register + // In client_header mode, we don't auto-generate HWID + logger.Debugf("No x-hwid header provided for client %d (subId: %s, email: %s) - HWID not registered", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read device metadata from headers (optional) + deviceOS := c.GetHeader("x-device-os") + if deviceOS == "" { + deviceOS = c.GetHeader("X-Device-OS") + } + deviceModel := c.GetHeader("x-device-model") + if deviceModel == "" { + deviceModel = c.GetHeader("X-Device-Model") + } + osVersion := c.GetHeader("x-ver-os") + if osVersion == "" { + osVersion = c.GetHeader("X-Ver-OS") + } + userAgent := c.GetHeader("User-Agent") + ipAddress := c.ClientIP() + + // Register HWID + hwidService := service.ClientHWIDService{} + hwidRecord, err := hwidService.RegisterHWIDFromHeaders(clientEntity.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent) + if err != nil { + // Check if error is HWID limit exceeded + if strings.Contains(err.Error(), "HWID limit exceeded") { + // Log as warning - this is an expected error when limit is reached + logger.Warningf("HWID limit exceeded for client %d (subId: %s, email: %s): %v", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, err) + // Note: We still allow the subscription request to proceed + // The client application should handle this error and inform the user + // that they need to remove an existing device or contact admin to increase limit + } else { + // Other errors - log as warning but don't fail subscription + logger.Warningf("Failed to register HWID for client %d (subId: %s): %v", clientEntity.Id, clientEntity.SubID, err) + } + // HWID registration failure should not block subscription access + // The subscription will still be returned, but HWID won't be registered + } else if hwidRecord != nil { + // Successfully registered HWID + logger.Debugf("Successfully registered HWID for client %d (subId: %s, email: %s, hwid: %s, hwidId: %d)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, hwid, hwidRecord.Id) + } +} diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 9aa05ed3..16ee5d34 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1902,7 +1902,7 @@ Inbound.Settings = class extends XrayCommonClass { Inbound.VmessSettings = class extends Inbound.Settings { constructor(protocol, - vmesses = [new Inbound.VmessSettings.VMESS()]) { + vmesses = []) { super(protocol); this.vmesses = vmesses; } @@ -2018,7 +2018,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { Inbound.VLESSSettings = class extends Inbound.Settings { constructor( protocol, - vlesses = [new Inbound.VLESSSettings.VLESS()], + vlesses = [], decryption = "none", encryption = "none", fallbacks = [], @@ -2208,7 +2208,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass { Inbound.TrojanSettings = class extends Inbound.Settings { constructor(protocol, - trojans = [new Inbound.TrojanSettings.Trojan()], + trojans = [], fallbacks = [],) { super(protocol); this.trojans = trojans; @@ -2373,7 +2373,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings { method = SSMethods.BLAKE3_AES_256_GCM, password = RandomUtil.randomShadowsocksPassword(), network = 'tcp,udp', - shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()], + shadowsockses = [], ivCheck = false, ) { super(protocol); diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index 3446832d..fbf1233b 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -74,6 +74,12 @@ class AllSetting { // Multi-node mode settings this.multiNodeMode = false; // Multi-node mode setting + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + this.hwidMode = "client_header"; // HWID tracking mode if (data == null) { return @@ -90,6 +96,18 @@ class AllSetting { } else { this.multiNodeMode = false; } + + // Ensure hwidMode is valid string (default to "client_header" if invalid) + if (this.hwidMode === undefined || this.hwidMode === null) { + this.hwidMode = "client_header"; + } else if (typeof this.hwidMode !== 'string') { + this.hwidMode = String(this.hwidMode); + } + // Validate hwidMode value + const validHwidModes = ["off", "client_header", "legacy_fingerprint"]; + if (!validHwidModes.includes(this.hwidMode)) { + this.hwidMode = "client_header"; // Default to client_header if invalid + } } equals(other) { diff --git a/web/controller/client.go b/web/controller/client.go new file mode 100644 index 00000000..a1417c9d --- /dev/null +++ b/web/controller/client.go @@ -0,0 +1,274 @@ +// Package controller provides HTTP handlers for client management. +package controller + +import ( + "bytes" + "encoding/json" + "io" + "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/mhsanaei/3x-ui/v2/web/session" + + "github.com/gin-gonic/gin" +) + +// ClientController handles HTTP requests related to client management. +type ClientController struct { + clientService service.ClientService + xrayService service.XrayService +} + +// NewClientController creates a new ClientController and sets up its routes. +func NewClientController(g *gin.RouterGroup) *ClientController { + a := &ClientController{ + clientService: service.ClientService{}, + xrayService: service.XrayService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for client-related operations. +func (a *ClientController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getClients) + g.GET("/get/:id", a.getClient) + g.POST("/add", a.addClient) + g.POST("/update/:id", a.updateClient) + g.POST("/del/:id", a.deleteClient) +} + +// getClients retrieves the list of all clients for the current user. +func (a *ClientController) getClients(c *gin.Context) { + user := session.GetLoginUser(c) + clients, err := a.clientService.GetClients(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, clients, nil) +} + +// getClient retrieves a specific client by its ID. +func (a *ClientController) getClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + user := session.GetLoginUser(c) + client, err := a.clientService.GetClient(id) + if err != nil { + jsonMsg(c, "Failed to get client", err) + return + } + if client.UserId != user.Id { + jsonMsg(c, "Client not found or access denied", nil) + return + } + jsonObj(c, client, nil) +} + +// addClient creates a new client. +func (a *ClientController) addClient(c *gin.Context) { + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + client := &model.ClientEntity{} + err := c.ShouldBind(client) + if err != nil { + jsonMsg(c, "Invalid client data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + client.InboundIds = inboundIdsFromJSON + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + client.InboundIds = inboundIds + } + } + + needRestart, err := a.clientService.AddClient(user.Id, client) + if err != nil { + logger.Errorf("Failed to add client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientCreateSuccess"), client, nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client creation: %v", err) + } + } +} + +// updateClient updates an existing client. +func (a *ClientController) updateClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + client := &model.ClientEntity{} + err = c.ShouldBind(client) + if err != nil { + jsonMsg(c, "Invalid client data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + client.InboundIds = inboundIdsFromJSON + logger.Debugf("UpdateClient: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + client.InboundIds = inboundIds + logger.Debugf("UpdateClient: extracted inboundIds from form: %v", inboundIds) + } else { + logger.Debugf("UpdateClient: inboundIds not provided, keeping existing assignments") + } + } + + client.Id = id + logger.Debugf("UpdateClient: client.InboundIds = %v", client.InboundIds) + needRestart, err := a.clientService.UpdateClient(user.Id, client) + if err != nil { + logger.Errorf("Failed to update client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientUpdateSuccess"), client, nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client update: %v", err) + } + } +} + +// deleteClient deletes a client by ID. +func (a *ClientController) deleteClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + + user := session.GetLoginUser(c) + needRestart, err := a.clientService.DeleteClient(user.Id, id) + if err != nil { + logger.Errorf("Failed to delete client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.clients.toasts.clientDeleteSuccess"), nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client deletion: %v", err) + } + } +} diff --git a/web/controller/client_hwid.go b/web/controller/client_hwid.go new file mode 100644 index 00000000..29ac79b3 --- /dev/null +++ b/web/controller/client_hwid.go @@ -0,0 +1,224 @@ +// Package controller provides HTTP handlers for client HWID management. +package controller + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/web/service" +) + +// ClientHWIDController handles HTTP requests for client HWID management. +type ClientHWIDController struct { + clientHWIDService *service.ClientHWIDService + clientService *service.ClientService +} + +// NewClientHWIDController creates a new ClientHWIDController. +func NewClientHWIDController(g *gin.RouterGroup) *ClientHWIDController { + a := &ClientHWIDController{ + clientHWIDService: &service.ClientHWIDService{}, + clientService: &service.ClientService{}, + } + a.initRouter(g) + return a +} + +// initRouter sets up routes for client HWID management. +func (a *ClientHWIDController) initRouter(g *gin.RouterGroup) { + g = g.Group("/hwid") + { + g.GET("/list/:clientId", a.getHWIDs) + g.POST("/add", a.addHWID) + g.POST("/del/:id", a.removeHWID) // Changed to /del/:id to match API style + g.POST("/deactivate/:id", a.deactivateHWID) + g.POST("/check", a.checkHWID) + g.POST("/register", a.registerHWID) + } +} + +// getHWIDs retrieves all HWIDs for a specific client. +func (a *ClientHWIDController) getHWIDs(c *gin.Context) { + clientIdStr := c.Param("clientId") + clientId, err := strconv.Atoi(clientIdStr) + if err != nil { + jsonMsg(c, "Invalid client ID", nil) + return + } + + hwids, err := a.clientHWIDService.GetHWIDsForClient(clientId) + if err != nil { + jsonMsg(c, "Failed to get HWIDs", err) + return + } + + jsonObj(c, hwids, nil) +} + +// addHWID adds a new HWID for a client (manual addition by admin). +func (a *ClientHWIDController) addHWID(c *gin.Context) { + var req struct { + ClientId int `json:"clientId" form:"clientId" binding:"required"` + HWID string `json:"hwid" form:"hwid" binding:"required"` + DeviceOS string `json:"deviceOs" form:"deviceOs"` + DeviceModel string `json:"deviceModel" form:"deviceModel"` + OSVersion string `json:"osVersion" form:"osVersion"` + IPAddress string `json:"ipAddress" form:"ipAddress"` + UserAgent string `json:"userAgent" form:"userAgent"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + hwid, err := a.clientHWIDService.AddHWIDForClient(req.ClientId, req.HWID, req.DeviceOS, req.DeviceModel, req.OSVersion, req.IPAddress, req.UserAgent) + if err != nil { + jsonMsg(c, "Failed to add HWID", err) + return + } + + jsonObj(c, hwid, nil) +} + +// removeHWID removes a HWID from a client. +func (a *ClientHWIDController) removeHWID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + jsonMsg(c, "Invalid HWID ID", nil) + return + } + + err = a.clientHWIDService.RemoveHWID(id) + if err != nil { + jsonMsg(c, "Failed to remove HWID", err) + return + } + + jsonMsg(c, "HWID removed successfully", nil) +} + +// deactivateHWID deactivates a HWID (marks as inactive). +func (a *ClientHWIDController) deactivateHWID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + jsonMsg(c, "Invalid HWID ID", nil) + return + } + + err = a.clientHWIDService.DeactivateHWID(id) + if err != nil { + jsonMsg(c, "Failed to deactivate HWID", err) + return + } + + jsonMsg(c, "HWID deactivated successfully", nil) +} + +// checkHWID checks if a HWID is allowed for a client. +func (a *ClientHWIDController) checkHWID(c *gin.Context) { + var req struct { + ClientId int `json:"clientId" form:"clientId" binding:"required"` + HWID string `json:"hwid" form:"hwid" binding:"required"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + allowed, err := a.clientHWIDService.CheckHWIDAllowed(req.ClientId, req.HWID) + if err != nil { + jsonMsg(c, "Failed to check HWID", err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "obj": gin.H{ + "allowed": allowed, + }, + }) +} + +// registerHWID registers a HWID for a client (called by client applications). +// This endpoint reads HWID and device metadata from HTTP headers: +// - x-hwid (required): Hardware ID +// - x-device-os (optional): Device operating system +// - x-device-model (optional): Device model +// - x-ver-os (optional): OS version +// - user-agent (optional): User agent string +func (a *ClientHWIDController) registerHWID(c *gin.Context) { + var req struct { + Email string `json:"email" form:"email" binding:"required"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + // Read HWID from headers (primary method) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + jsonMsg(c, "HWID is required (x-hwid header missing)", nil) + return + } + + // Read device metadata from headers + deviceOS := c.GetHeader("x-device-os") + if deviceOS == "" { + deviceOS = c.GetHeader("X-Device-OS") + } + deviceModel := c.GetHeader("x-device-model") + if deviceModel == "" { + deviceModel = c.GetHeader("X-Device-Model") + } + osVersion := c.GetHeader("x-ver-os") + if osVersion == "" { + osVersion = c.GetHeader("X-Ver-OS") + } + userAgent := c.GetHeader("User-Agent") + ipAddress := c.ClientIP() + + // Get client by email + client, err := a.clientService.GetClientByEmail(1, req.Email) // TODO: Get userId from session + if err != nil { + jsonMsg(c, "Client not found", err) + return + } + + // Register HWID using RegisterHWIDFromHeaders + hwidRecord, err := a.clientHWIDService.RegisterHWIDFromHeaders(client.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent) + if err != nil { + // Check if error is HWID limit exceeded + if strings.Contains(err.Error(), "HWID limit exceeded") { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "msg": err.Error(), + }) + return + } + jsonMsg(c, "Failed to register HWID", err) + return + } + + if hwidRecord == nil { + // HWID tracking disabled (hwidMode = "off") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "msg": "HWID tracking is disabled", + }) + return + } + + jsonObj(c, hwidRecord, nil) +} diff --git a/web/controller/host.go b/web/controller/host.go new file mode 100644 index 00000000..d31a2303 --- /dev/null +++ b/web/controller/host.go @@ -0,0 +1,253 @@ +// Package controller provides HTTP handlers for host management in multi-node mode. +package controller + +import ( + "bytes" + "encoding/json" + "io" + "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/mhsanaei/3x-ui/v2/web/session" + + "github.com/gin-gonic/gin" +) + +// HostController handles HTTP requests related to host management. +type HostController struct { + hostService service.HostService +} + +// NewHostController creates a new HostController and sets up its routes. +func NewHostController(g *gin.RouterGroup) *HostController { + a := &HostController{ + hostService: service.HostService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for host-related operations. +func (a *HostController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getHosts) + g.GET("/get/:id", a.getHost) + g.POST("/add", a.addHost) + g.POST("/update/:id", a.updateHost) + g.POST("/del/:id", a.deleteHost) +} + +// getHosts retrieves the list of all hosts for the current user. +func (a *HostController) getHosts(c *gin.Context) { + user := session.GetLoginUser(c) + hosts, err := a.hostService.GetHosts(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, hosts, nil) +} + +// getHost retrieves a specific host by its ID. +func (a *HostController) getHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + user := session.GetLoginUser(c) + host, err := a.hostService.GetHost(id) + if err != nil { + jsonMsg(c, "Failed to get host", err) + return + } + if host.UserId != user.Id { + jsonMsg(c, "Host not found or access denied", nil) + return + } + jsonObj(c, host, nil) +} + +// addHost creates a new host. +func (a *HostController) addHost(c *gin.Context) { + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + host := &model.Host{} + err := c.ShouldBind(host) + if err != nil { + jsonMsg(c, "Invalid host data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + host.InboundIds = inboundIdsFromJSON + logger.Debugf("AddHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + host.InboundIds = inboundIds + logger.Debugf("AddHost: extracted inboundIds from form: %v", inboundIds) + } + } + + logger.Debugf("AddHost: host.InboundIds before service call: %v", host.InboundIds) + err = a.hostService.AddHost(user.Id, host) + if err != nil { + logger.Errorf("Failed to add host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostCreateSuccess"), host, nil) +} + +// updateHost updates an existing host. +func (a *HostController) updateHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + host := &model.Host{} + err = c.ShouldBind(host) + if err != nil { + jsonMsg(c, "Invalid host data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + host.InboundIds = inboundIdsFromJSON + logger.Debugf("UpdateHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + host.InboundIds = inboundIds + logger.Debugf("UpdateHost: extracted inboundIds from form: %v", inboundIds) + } else { + logger.Debugf("UpdateHost: inboundIds not provided, keeping existing assignments") + } + } + + host.Id = id + err = a.hostService.UpdateHost(user.Id, host) + if err != nil { + logger.Errorf("Failed to update host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostUpdateSuccess"), host, nil) +} + +// deleteHost deletes a host by ID. +func (a *HostController) deleteHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + + user := session.GetLoginUser(c) + err = a.hostService.DeleteHost(user.Id, id) + if err != nil { + logger.Errorf("Failed to delete host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.hostDeleteSuccess"), nil) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index f11a0422..137687eb 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -30,10 +30,17 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) g.GET("/nodes", a.nodes) + g.GET("/clients", a.clients) + g.GET("/hosts", a.hosts) a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) a.nodeController = NewNodeController(g.Group("/node")) + + // Register client and host controllers directly under /panel (not /panel/api) + NewClientController(g.Group("/client")) + NewHostController(g.Group("/host")) + NewClientHWIDController(g.Group("/client")) // Register HWID controller under /panel/client/hwid } // index renders the main panel index page. @@ -60,3 +67,13 @@ func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) nodes(c *gin.Context) { html(c, "nodes.html", "pages.nodes.title", nil) } + +// clients renders the clients management page. +func (a *XUIController) clients(c *gin.Context) { + html(c, "clients.html", "pages.clients.title", nil) +} + +// hosts renders the hosts management page (multi-node mode). +func (a *XUIController) hosts(c *gin.Context) { + html(c, "hosts.html", "pages.hosts.title", nil) +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 030da972..31eb3aeb 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -101,6 +101,12 @@ type AllSetting struct { // Multi-node mode setting MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + HwidMode string `json:"hwidMode" form:"hwidMode"` // HWID tracking mode // JSON subscription routing rules } @@ -171,5 +177,15 @@ func (s *AllSetting) CheckValid() error { return common.NewError("time location not exist:", s.TimeLocation) } + // Validate HWID mode + validHwidModes := map[string]bool{ + "off": true, + "client_header": true, + "legacy_fingerprint": true, + } + if s.HwidMode != "" && !validHwidModes[s.HwidMode] { + return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", s.HwidMode) + } + return nil } diff --git a/web/html/clients.html b/web/html/clients.html new file mode 100644 index 00000000..19e033f7 --- /dev/null +++ b/web/html/clients.html @@ -0,0 +1,730 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + + + + +

{{ i18n "pages.clients.title" }}

+ +
+ {{ i18n "pages.clients.addClient" }} + {{ i18n "refresh" }} + + + + + +
+ + + + + + + + + + +
+
+
+ + + + + +
+
+
+
+ +{{template "page/body_scripts" .}} + + + + +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} +{{template "modals/qrcodeModal"}} +{{template "modals/clientEntityModal"}} + +{{ template "page/body_end" .}} diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index 8f0b1ee5..1dbc0e3b 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -76,6 +76,11 @@ icon: 'user', title: '{{ i18n "menu.inbounds"}}' }, + { + key: '{{ .base_path }}panel/clients', + icon: 'team', + title: '{{ i18n "menu.clients"}}' + }, { key: '{{ .base_path }}panel/settings', icon: 'setting', @@ -88,13 +93,18 @@ } ]; - // Add Nodes menu item if multi-node mode is enabled + // Add Nodes and Hosts menu items if multi-node mode is enabled if (this.multiNodeMode) { - this.tabs.splice(3, 0, { + this.tabs.splice(4, 0, { key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' }); + this.tabs.splice(5, 0, { + key: '{{ .base_path }}panel/hosts', + icon: 'cloud-server', + title: '{{ i18n "menu.hosts"}}' + }); } this.tabs.push({ diff --git a/web/html/form/protocol/shadowsocks.html b/web/html/form/protocol/shadowsocks.html index 06e12075..2f3ec787 100644 --- a/web/html/form/protocol/shadowsocks.html +++ b/web/html/form/protocol/shadowsocks.html @@ -1,11 +1,6 @@ {{define "form/shadowsocks"}} -