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 @@ - + + + + + + + + + + + +