v2.2.0
This commit is contained in:
@@ -2,7 +2,7 @@ name: Docker Build & Publish
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main", "dp-new-release" ]
|
||||||
paths:
|
paths:
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||||
|
|
||||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||||
|
|
||||||
# 3DP-MANAGER
|
# 3DP-MANAGER
|
||||||
|
|
||||||
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной. Утилита, начиная с версии 2.0.0 имеет графический интерфейс и простые пользовательские настройки.
|
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единых подписок, управления несколькими 3x-ui нодами и настройки relay-перенаправления трафика с промежуточных серверов на основные. Начиная с версии 2.0.0 проект имеет графический интерфейс и простые пользовательские настройки.
|
||||||
|
|
||||||
**Поддержать проект**
|
**Поддержать проект**
|
||||||
|
|
||||||
@@ -25,18 +25,19 @@
|
|||||||
|
|
||||||
## Описание
|
## Описание
|
||||||
|
|
||||||
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. Бот генерирует по заданному интервалу 10 подключений с разными параметрами:
|
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. 3DP-MANAGER генерирует по заданному интервалу набор подключений с разными параметрами:
|
||||||
|
|
||||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
- протоколы: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||||
- порты: `443`, `8443` (фиксированные) и случайные из диапазона `10000-60000`;
|
- порты: `443`, `8443` (фиксированные) и случайные из диапазона `10000-60000`;
|
||||||
- транспорт: `tcp`, `websocket`, `grpc`, `xhttp`;
|
- транспорт: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||||
- SNI берутся из белого списка доменов (whitelist); можно использовать свой список.
|
- SNI берутся из белого списка доменов (whitelist) или задаются вручную;
|
||||||
|
- нода, relay-сервер, порт и флаг могут настраиваться отдельно для каждого инбаунда.
|
||||||
|
|
||||||
Все подключения объединяются в одну подписку со статичным URL. Бот работает с панелью `3x-ui` и не вмешивается в её работу напрямую, используя открытое API панели.
|
Все подключения объединяются в одну подписку со статичным URL. 3DP-MANAGER работает с панелью `3x-ui` и не вмешивается в её работу напрямую, используя открытое API панели.
|
||||||
|
|
||||||
Вторичная цель — стабильность подключения: клиент получает 10 вариантов подключений и может выбрать любое из них.
|
Вторичная цель — стабильность подключения: клиент получает несколько вариантов подключений и может выбрать любой из них. В одну подписку можно добавлять инбаунды с разных нод, использовать relay-серверы и добавлять собственные внешние ссылки как кастомные подключения.
|
||||||
|
|
||||||
Дополнительно: бот можно использовать в каскадной схеме. Сервис перенаправления автоматически настроит переадресацию подписки и трафика к основному серверу.
|
Дополнительно: 3DP-MANAGER можно использовать в каскадной схеме. Сервис перенаправления автоматически настроит переадресацию подписки и трафика к выбранной ноде.
|
||||||
|
|
||||||
Рекомендации:
|
Рекомендации:
|
||||||
|
|
||||||
@@ -46,15 +47,25 @@
|
|||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Генерация 10 разнообразных подключений
|
- Генерация разнообразных подключений для одной или нескольких подписок
|
||||||
- Формирование единой подписки со статичным URL
|
- Формирование единой подписки со статичным URL
|
||||||
|
- Управление несколькими нодами `3x-ui`
|
||||||
|
- Авторизация нод по логину/паролю или токену
|
||||||
|
- Автоопределение IP, страны и флага ноды по URL панели
|
||||||
|
- Выбор ноды, relay-сервера, флага, SNI и порта для каждого инбаунда
|
||||||
|
- Поддержка фиксированного порта или значения `random`
|
||||||
|
- Кастомные подключения: можно добавить готовую внешнюю ссылку в подписку
|
||||||
|
- Отдельные пути к `fullchain.pem` и `privkey.pem` для Hysteria2 UDP
|
||||||
- Поддержка кастомного `whitelist` доменов
|
- Поддержка кастомного `whitelist` доменов
|
||||||
- Автоматическая настройка перенаправления трафика (опционально)
|
- Автоматическая настройка relay-перенаправления трафика (опционально)
|
||||||
|
- Установка Web UI через HTTP или HTTPS: Let's Encrypt, self-signed или свои сертификаты
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Ubuntu 20.04 (и выше), Debian 12.11 (и выше)
|
- Ubuntu 20.04 (и выше), Debian 12.11 (и выше)
|
||||||
- Панель `3x-ui` v2.8.4 (и выше)
|
- Панель `3x-ui` v2.9.4 (и выше)
|
||||||
|
- Root-доступ на сервере
|
||||||
|
- Docker и Docker Compose; если их нет, установочный скрипт попробует установить их автоматически
|
||||||
- Домен + SSL сертификат (опционально)
|
- Домен + SSL сертификат (опционально)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -62,13 +73,29 @@
|
|||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
У вас должна быть установлена панель управления `3x-ui`, которую можно поставить командой: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
У вас должна быть установлена панель управления `3x-ui`, которую можно поставить командой: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||||
|
|
||||||
Установите проект на сервер командой:
|
Установите проект на сервер командой:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Краткое описание: запускает скрипт установки и разворачивает контейнеры и сервисы.</sup>
|
Во время установки скрипт:
|
||||||
|
|
||||||
|
- проверит ОС, root-доступ, Docker и Docker Compose;
|
||||||
|
- при нехватке RAM создаст swap-файл 2 GB, если swap отсутствует;
|
||||||
|
- предложит выбрать режим доступа к Web UI:
|
||||||
|
- HTTPS через Let's Encrypt;
|
||||||
|
- HTTPS с self-signed сертификатом;
|
||||||
|
- HTTPS со своими сертификатами;
|
||||||
|
- HTTP без шифрования;
|
||||||
|
- сгенерирует случайный порт Web UI;
|
||||||
|
- сгенерирует логин и пароль администратора;
|
||||||
|
- развернёт контейнеры `postgres`, `backend` и `frontend`.
|
||||||
|
|
||||||
|
После завершения установки в терминале будут показаны URL, логин и пароль. Сразу смените пароль в настройках 3DP-MANAGER.
|
||||||
|
|
||||||
|
<sup>Краткое описание: запускает интерактивный скрипт установки, подготавливает окружение и разворачивает контейнеры проекта.</sup>
|
||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
@@ -78,7 +105,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Краткое описание: подтягивает последние изменения и перезапускает контейнеры.</sup>
|
<sup>Краткое описание: подтягивает последние изменения, применяет совместимые исправления конфигурации, обновляет контейнеры и перезапускает сервис.</sup>
|
||||||
|
|
||||||
## Удаление
|
## Удаление
|
||||||
|
|
||||||
@@ -92,12 +119,76 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Первый вход и доступы
|
||||||
|
|
||||||
|
Логин и пароль администратора выводятся в конце установки. Если нужно посмотреть их повторно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||||
|
```
|
||||||
|
|
||||||
|
Web UI доступен по адресу, который показал установщик, например:
|
||||||
|
|
||||||
|
- `https://example.com:PORT` для HTTPS;
|
||||||
|
- `http://SERVER_IP:PORT` для HTTP.
|
||||||
|
|
||||||
|
Если включён `ufw`, установщик открывает рабочие диапазоны портов для инбаундов: `443`, `8443` и `10000-60000` для TCP/UDP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ноды
|
||||||
|
|
||||||
|
Раздел **Ноды** позволяет подключить одну или несколько панелей `3x-ui`.
|
||||||
|
|
||||||
|
Для каждой ноды указываются:
|
||||||
|
|
||||||
|
- название;
|
||||||
|
- URL панели `3x-ui`;
|
||||||
|
- IP ноды;
|
||||||
|
- флаг/страна;
|
||||||
|
- тип авторизации: `password` или `token`;
|
||||||
|
- признак основной ноды.
|
||||||
|
|
||||||
|
После ввода URL приложение пытается определить IP и страну ноды автоматически. Флаг используется в названиях подключений внутри подписки, чтобы в клиенте было проще отличать серверы.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Основная нода используется как значение по умолчанию для подписок, инбаундов и relay-серверов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подписки
|
||||||
|
|
||||||
|
Раздел **Подписки** управляет статичными URL подписок и составом инбаундов.
|
||||||
|
|
||||||
|
Для каждого инбаунда можно настроить:
|
||||||
|
|
||||||
|
- тип подключения;
|
||||||
|
- ноду;
|
||||||
|
- relay-сервер;
|
||||||
|
- флаг;
|
||||||
|
- порт: конкретное число `1-65535` или `random`;
|
||||||
|
- SNI: конкретный домен или `random`;
|
||||||
|
- готовую внешнюю ссылку для типа `custom`.
|
||||||
|
|
||||||
|
Для `hysteria2-udp` можно указать отдельные пути к сертификату и ключу:
|
||||||
|
|
||||||
|
- `certificateFile`, например `/root/cert/example.com/fullchain.pem`;
|
||||||
|
- `keyFile`, например `/root/cert/example.com/privkey.pem`.
|
||||||
|
|
||||||
|
Если порт или SNI указаны как `random`, значение будет выбрано при ротации из доступного диапазона или whitelist доменов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Установка сервиса перенаправления (forwarding)
|
## Установка сервиса перенаправления (forwarding)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Сервис перенаправления работает на промежуточном сервере
|
> Сервис перенаправления работает на промежуточном сервере
|
||||||
|
|
||||||
Сервис перенаправления позволяет проксировать входящие порты с промежуточного сервера на основной. IP адрес замените на IP адрес основного сервера (не домен! только IP адрес):
|
Сервис перенаправления позволяет проксировать входящие порты с промежуточного сервера на выбранную ноду. В Web UI такие серверы настраиваются в разделе **Relay серверы** и могут быть привязаны к конкретной ноде.
|
||||||
|
|
||||||
|
Relay-сервер можно добавить по IP или домену. Если указан домен, backend попытается определить IP автоматически.
|
||||||
|
|
||||||
|
Для ручной установки forwarding на промежуточном сервере замените `IP_ADDRESS` на IP основной ноды:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||||
@@ -115,16 +206,6 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Посмотреть логин и пароль в терминале
|
|
||||||
|
|
||||||
Команда позволяет посмотреть логин и пароль от личного кабинета 3DP-MANAGER.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сбор доменов из мульти-подписок
|
## Сбор доменов из мульти-подписок
|
||||||
|
|
||||||
Утилита извлекает домены из подписок и формирует `whitelist` для генератора.
|
Утилита извлекает домены из подписок и формирует `whitelist` для генератора.
|
||||||
|
|||||||
+145
-68
@@ -2,69 +2,100 @@
|
|||||||
|
|
||||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||||
|
|
||||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||||
|
|
||||||
# 3DP-MANAGER
|
# 3DP-MANAGER
|
||||||
|
|
||||||
这是一个用于为 `3x-ui` 面板自动生成入站连接、生成统一订阅并将流量从中继服务器转发到主服务器的实用工具。
|
3DP-MANAGER 是一个用于为 [3x-ui](https://github.com/MHSanaei/3x-ui) 面板自动生成 inbound、创建统一订阅、管理多个 3x-ui 节点,并配置从中间服务器到源服务器的 relay 转发的工具。从 2.0.0 版本开始,项目提供图形界面和简单的用户设置。
|
||||||
|
|
||||||
**支持项目**
|
**支持项目**
|
||||||
|
|
||||||
- 捐助/支付信息:
|
- 银行转账:
|
||||||
- MIR 卡: `2204320436318077`
|
- :credit_card: MIR 卡:`2204320436318077`
|
||||||
- MasterCard: `5395452209474530`
|
- :credit_card: MasterCard:`5395452209474530`
|
||||||
- PayPal: `vasiljevdenisx@gmail.com`
|
- 电子钱包:
|
||||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
- :moneybag: YooMoney:`4100116897060652`
|
||||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
- :moneybag: PayPal:`vasiljevdenisx@gmail.com`
|
||||||
- 比特币: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
- 加密货币:
|
||||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
- :coin: USDT | ETH (ERC20 | BEP20):`0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||||
- Bybit ID: `165292278`
|
- :coin: USDT | TRX (TRC20):`TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||||
|
- :coin: Bitcoin:`bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||||
|
- :coin: TON:`UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||||
|
- :coin: Bybit ID:`165292278`
|
||||||
|
|
||||||
## 描述
|
## 描述
|
||||||
|
|
||||||
该工具的主要目标是使您的流量看起来不那么统一。机器人会在设定间隔生成 10 个具有不同参数的连接:
|
该工具的主要目标是让您的流量看起来不那么单一。3DP-MANAGER 会按设定间隔生成一组具有不同参数的连接:
|
||||||
|
|
||||||
- 协议:`vless`、`vmess`、`shadowsocks`、`trojan`
|
- 协议:`vless`、`vmess`、`shadowsocks`、`hysteria2`、`trojan`;
|
||||||
- 端口:`443`、`8443`(固定)以及 `10000-60000` 范围内的随机端口
|
- 端口:固定的 `443`、`8443`,以及 `10000-60000` 范围内的随机端口;
|
||||||
- 传输:`tcp`、`websocket`、`grpc`、`xhttp`
|
- 传输:`tcp`、`websocket`、`grpc`、`xhttp`;
|
||||||
- SNI 从域名白名单(`whitelist`)中获取;也可以使用自定义列表
|
- SNI 可从域名白名单中选择,也可以手动指定;
|
||||||
|
- 每个 inbound 都可以单独设置节点、relay 服务器、端口和旗帜。
|
||||||
|
|
||||||
所有连接会合并为具有静态 URL 的单一订阅。该机器人通过 `3x-ui` 面板的公共 API 工作,不会直接修改面板内部。
|
所有连接会合并到一个具有静态 URL 的订阅中。3DP-MANAGER 通过 `3x-ui` 面板的公开 API 工作,不会直接干预面板内部。
|
||||||
|
|
||||||
次要目标是提高连接稳定性:客户端会收到 10 个可选连接,用户可以选择任意一个。
|
次要目标是提高连接稳定性:客户端会收到多个连接选项,并可选择其中任意一个。一个订阅可以包含来自不同节点的 inbound、relay 服务器,以及作为自定义连接添加的外部链接。
|
||||||
|
|
||||||
此外,机器人可用于级联部署。转发服务会自动配置将订阅和流量重定向到主服务器。
|
此外,3DP-MANAGER 也可用于级联部署。转发服务会把订阅和流量转发配置到选定节点。
|
||||||
|
|
||||||
建议:
|
建议:
|
||||||
|
|
||||||
- 订阅请使用 HTTPS(域名 + SSL 证书)。
|
- 为订阅使用 HTTPS(域名 + SSL 证书)。
|
||||||
- 生成间隔设置为 ≥ 10 分钟;为稳定性建议每天一次(1440 分钟)。
|
- 生成间隔设置为不少于 10 分钟;为了稳定性,建议每天一次(1440 分钟)。
|
||||||
- 在客户端设置更频繁的自动更新(例如每小时),以便与服务器同步。
|
- 在客户端设置更频繁的自动更新,例如每小时一次,以便与服务器保持同步。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 生成 10 个多样化连接
|
- 为一个或多个订阅生成多样化连接
|
||||||
- 形成具有静态 URL 的统一订阅
|
- 创建具有静态 URL 的统一订阅
|
||||||
- 支持自定义 `whitelist` 域名
|
- 管理多个 `3x-ui` 节点
|
||||||
- 可选的自动流量转发配置
|
- 支持使用登录名/密码或 token 进行节点认证
|
||||||
|
- 根据面板 URL 自动检测节点 IP、国家和旗帜
|
||||||
|
- 可为每个 inbound 选择节点、relay 服务器、旗帜、SNI 和端口
|
||||||
|
- 支持固定端口或 `random`
|
||||||
|
- 支持自定义连接:可将现成的外部链接加入订阅
|
||||||
|
- 可为 Hysteria2 UDP 单独指定 `fullchain.pem` 和 `privkey.pem` 路径
|
||||||
|
- 支持自定义域名 `whitelist`
|
||||||
|
- 可选的自动 relay 转发配置
|
||||||
|
- Web UI 可通过 HTTP 或 HTTPS 安装:Let's Encrypt、自签名证书或自定义证书
|
||||||
|
|
||||||
## 要求
|
## 要求
|
||||||
|
|
||||||
- Ubuntu 20.04 或更高
|
- Ubuntu 20.04 或更高版本,Debian 12.11 或更高版本
|
||||||
- `3x-ui` 面板
|
- `3x-ui` 面板 v2.8.4 或更高版本
|
||||||
|
- 服务器 root 权限
|
||||||
|
- Docker 和 Docker Compose;如果缺失,安装脚本会尝试自动安装
|
||||||
- 域名 + SSL 证书(可选)
|
- 域名 + SSL 证书(可选)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
在服务器上运行以下命令安装项目:
|
必须先安装 `3x-ui` 控制面板。可使用以下命令安装:`bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||||
|
|
||||||
|
在服务器上安装项目:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>简要说明:运行安装脚本并部署容器和服务。</sup>
|
安装过程中,脚本会:
|
||||||
|
|
||||||
|
- 检查操作系统、root 权限、Docker 和 Docker Compose;
|
||||||
|
- 如果内存较低且没有 swap,则创建 2 GB 的 swap 文件;
|
||||||
|
- 询问 Web UI 访问模式:
|
||||||
|
- 通过 Let's Encrypt 使用 HTTPS;
|
||||||
|
- 使用自签名证书的 HTTPS;
|
||||||
|
- 使用自有证书的 HTTPS;
|
||||||
|
- 不加密的 HTTP;
|
||||||
|
- 生成随机 Web UI 端口;
|
||||||
|
- 生成管理员登录名和密码;
|
||||||
|
- 部署 `postgres`、`backend` 和 `frontend` 容器。
|
||||||
|
|
||||||
|
安装完成后,终端会显示 URL、登录名和密码。请立即在 3DP-MANAGER 设置中修改密码。
|
||||||
|
|
||||||
|
<sup>简要说明:运行交互式安装脚本,准备环境并部署项目容器。</sup>
|
||||||
|
|
||||||
## 更新
|
## 更新
|
||||||
|
|
||||||
@@ -74,7 +105,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>简要说明:拉取最新更改并重启容器。</sup>
|
<sup>简要说明:拉取最新更改,应用兼容的配置修复,更新容器并重启服务。</sup>
|
||||||
|
|
||||||
## 删除
|
## 删除
|
||||||
|
|
||||||
@@ -84,19 +115,86 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>简要说明:删除容器和配置文件,恢复到安装前状态。</sup>
|
<sup>简要说明:删除容器和配置文件,将系统恢复到安装前状态。</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安装转发服务(forwarding)
|
## 首次登录
|
||||||
|
|
||||||
转发服务允许将中继服务器的入站端口代理到主服务器。
|
管理员登录名和密码会在安装结束时输出。如需再次查看:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>简要说明:添加转发规则并创建用于更新订阅的服务。</sup>
|
Web UI 可通过安装脚本显示的地址访问,例如:
|
||||||
|
|
||||||
|
- HTTPS:`https://example.com:PORT`;
|
||||||
|
- HTTP:`http://SERVER_IP:PORT`。
|
||||||
|
|
||||||
|
如果启用了 `ufw`,安装脚本会开放 inbound 工作端口范围:TCP/UDP 的 `443`、`8443` 和 `10000-60000`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 节点
|
||||||
|
|
||||||
|
**节点** 页面用于连接一个或多个 `3x-ui` 面板。
|
||||||
|
|
||||||
|
每个节点需要设置:
|
||||||
|
|
||||||
|
- 名称;
|
||||||
|
- `3x-ui` 面板 URL;
|
||||||
|
- 节点 IP;
|
||||||
|
- 旗帜/国家;
|
||||||
|
- 认证类型:`password` 或 `token`;
|
||||||
|
- 是否为主节点。
|
||||||
|
|
||||||
|
输入 URL 后,应用会尝试自动检测节点 IP 和国家。旗帜会用于订阅中的连接名称,方便客户端区分服务器。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 主节点会作为订阅、inbound 和 relay 服务器的默认值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 订阅
|
||||||
|
|
||||||
|
**订阅** 页面用于管理静态订阅 URL 和 inbound 组成。
|
||||||
|
|
||||||
|
每个 inbound 可配置:
|
||||||
|
|
||||||
|
- 连接类型;
|
||||||
|
- 节点;
|
||||||
|
- relay 服务器;
|
||||||
|
- 旗帜;
|
||||||
|
- 端口:`1-65535` 的具体数字或 `random`;
|
||||||
|
- SNI:具体域名或 `random`;
|
||||||
|
- `custom` 类型的现成外部链接。
|
||||||
|
|
||||||
|
对于 `hysteria2-udp`,可以单独指定证书和密钥路径:
|
||||||
|
|
||||||
|
- `certificateFile`,例如 `/root/cert/example.com/fullchain.pem`;
|
||||||
|
- `keyFile`,例如 `/root/cert/example.com/privkey.pem`。
|
||||||
|
|
||||||
|
如果端口或 SNI 设置为 `random`,轮换时会从可用端口范围或域名白名单中选择值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装转发服务
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 转发服务运行在中间服务器上
|
||||||
|
|
||||||
|
转发服务会把中间服务器的入站端口代理到选定节点。在 Web UI 中,这些服务器在 **Relay 服务器** 页面配置,并可绑定到具体节点。
|
||||||
|
|
||||||
|
Relay 服务器可以通过 IP 或域名添加。如果提供域名,backend 会尝试自动解析其 IP。
|
||||||
|
|
||||||
|
如需在中间服务器上手动安装 forwarding,请将 `IP_ADDRESS` 替换为源节点 IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
<sup>简要说明:添加转发规则。</sup>
|
||||||
|
|
||||||
## 删除转发
|
## 删除转发
|
||||||
|
|
||||||
@@ -104,64 +202,43 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>简要说明:删除规则并禁用转发服务。</sup>
|
<sup>简要说明:删除转发规则。删除后,请执行 `ufw reload` 重新加载防火墙,并执行 `reboot` 重启系统以使更改生效。</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 显示订阅 URL
|
|
||||||
|
|
||||||
在容器环境中打印当前订阅 URL 的命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
|
||||||
```
|
|
||||||
|
|
||||||
<sup>简要说明:打印可在客户端使用的静态订阅 URL,适用于主服务器和中继服务器。</sup>
|
|
||||||
|
|
||||||
## 从多订阅收集域名
|
## 从多订阅收集域名
|
||||||
|
|
||||||
该工具可从订阅中提取域名并为生成器构建 `whitelist`。
|
该工具会从订阅中提取域名,并为生成器构建 `whitelist`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node get_domains.js
|
node get_domains.js
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>简要说明:在脚本中添加多订阅链接并运行命令 — 输出为域名列表。运行脚本需要 `Node.js`。</sup>
|
<sup>简要说明:在脚本中添加多订阅链接并运行命令,输出将是域名列表。运行脚本需要 `Node.js`。</sup>
|
||||||
|
|
||||||
## 使用自定义白名单
|
|
||||||
|
|
||||||
1. 准备一个与 `whitelist.txt` 相同格式的文件。
|
|
||||||
2. 将其重命名为 `my_whitelist.txt` 并复制到 `/opt/3dp-manager/app`。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
<sup>简要说明:将您的域名文件添加到应用容器中。</sup>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意与当前限制
|
## 注意事项和当前限制
|
||||||
|
|
||||||
- 共享的域名列表可能并非对所有提供商都有效;建议准备并使用自定义 `whitelist`。
|
- 共享域名列表并非适用于所有服务商,因此建议准备并使用自己的 whitelist。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
欢迎贡献!简单的贡献流程:
|
欢迎任何贡献!简单的贡献流程:
|
||||||
|
|
||||||
1. 在 GitHub 上 fork 仓库。
|
1. 在 GitHub 上 fork 仓库。
|
||||||
2. 创建有意义的分支名,例如 `feature/add-README` 或 `fix/whitelist-load`。
|
2. 创建有意义的分支名,例如 `feature/add-README` 或 `fix/whitelist-load`。
|
||||||
3. 提交更改并添加简短的提交信息。
|
3. 修改代码,并在 commit 中添加简短说明。
|
||||||
4. 在本地运行检查(如有)。
|
4. 如有本地检查,请运行它们。
|
||||||
5. 推送分支到您的 fork 并创建 Pull Request。
|
5. 将分支推送到你的 fork,并向主仓库创建 Pull Request。
|
||||||
|
|
||||||
<sup>建议:在 PR 中说明更改和测试步骤;较大更改请拆成小提交。</sup>
|
<sup>建议:在 PR 中说明更改、目标和测试步骤。如果更改较大,请拆分为较小的 commit。</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 讨论
|
## 讨论
|
||||||
|
|
||||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||||
- Issues
|
- 本仓库的 Issues 区
|
||||||
|
|||||||
+143
-66
@@ -2,69 +2,100 @@
|
|||||||
|
|
||||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||||
|
|
||||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||||
|
|
||||||
# 3DP-MANAGER
|
# 3DP-MANAGER
|
||||||
|
|
||||||
A utility for auto-generating inbound connections for the [3x-ui](https://github.com/MHSanaei/3x-ui) panel, creating a single subscription URL, and configuring traffic forwarding from an intermediate server to the main server.
|
A utility for automatically generating inbounds for the [3x-ui](https://github.com/MHSanaei/3x-ui) panel, creating unified subscriptions, managing multiple 3x-ui nodes, and configuring relay forwarding from intermediate servers to origin servers. Since version 2.0.0, the project has a graphical interface and simple user-facing settings.
|
||||||
|
|
||||||
**Support the project**
|
**Support the project**
|
||||||
|
|
||||||
- Donation / payment details:
|
- Bank transfer:
|
||||||
- MIR card: `2204320436318077`
|
- :credit_card: MIR card: `2204320436318077`
|
||||||
- MasterCard: `5395452209474530`
|
- :credit_card: MasterCard: `5395452209474530`
|
||||||
- PayPal: `vasiljevdenisx@gmail.com`
|
- E-wallet:
|
||||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
- :moneybag: YooMoney: `4100116897060652`
|
||||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||||
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
- Crypto:
|
||||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||||
- Bybit ID: `165292278`
|
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||||
|
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||||
|
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||||
|
- :coin: Bybit ID: `165292278`
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
The main goal of the utility is to make your traffic appear non-uniform. The bot generates 10 connection entries at a configured interval with varying parameters:
|
The main goal of the utility is to make your traffic look less uniform. 3DP-MANAGER generates a set of connections at a configured interval with different parameters:
|
||||||
|
|
||||||
- Protocols: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
- protocols: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||||
- Ports: `443`, `8443` (fixed) and random ports from the range `10000-60000`;
|
- ports: fixed `443`, `8443`, plus random ports from `10000-60000`;
|
||||||
- Transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
- transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||||
- SNI values are taken from a whitelist of domains (`whitelist`); you can use your own list.
|
- SNI values are taken from the domain whitelist or set manually;
|
||||||
|
- node, relay server, port, and flag can be configured separately for each inbound.
|
||||||
|
|
||||||
All generated connections are combined into a single subscription with a static URL. The bot works with the `3x-ui` panel using its public API and does not directly modify the panel internals.
|
All connections are combined into one subscription with a static URL. 3DP-MANAGER works with the `3x-ui` panel through its public API and does not directly interfere with the panel internals.
|
||||||
|
|
||||||
The secondary goal is connection stability: the client receives 10 alternate connection options and can choose any of them.
|
The secondary goal is connection stability: the client receives several connection options and can choose any of them. One subscription can contain inbounds from different nodes, relay servers, and your own external links as custom connections.
|
||||||
|
|
||||||
Additionally, the bot can be used in a cascading setup. The forwarding service will automatically configure the subscription and traffic redirection to the main server.
|
Additionally, 3DP-MANAGER can be used in a cascading setup. The forwarding service configures subscription and traffic forwarding to the selected node.
|
||||||
|
|
||||||
Recommendations:
|
Recommendations:
|
||||||
|
|
||||||
- Use HTTPS for the subscription (domain + SSL certificate).
|
- Use HTTPS for subscriptions (domain + SSL certificate).
|
||||||
- Set the generation interval to ≥ 10 minutes; for stability, once per day (1440 minutes) is recommended.
|
- Set the generation interval to at least 10 minutes; for stability, once per day (1440 minutes) is recommended.
|
||||||
- Configure the client to check for updates more frequently (for example, hourly) to stay synchronized with the server.
|
- Configure the client to refresh more frequently, for example every hour, so it stays synchronized with the server.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Generates 10 diverse connection entries
|
- Generates diverse connections for one or more subscriptions
|
||||||
- Creates a single subscription with a static URL
|
- Creates a unified subscription with a static URL
|
||||||
- Supports custom `whitelist` domains
|
- Manages multiple `3x-ui` nodes
|
||||||
- Optional automatic configuration of traffic forwarding
|
- Supports node authentication by login/password or token
|
||||||
|
- Automatically detects node IP, country, and flag from the panel URL
|
||||||
|
- Lets you choose node, relay server, flag, SNI, and port per inbound
|
||||||
|
- Supports a fixed port or `random`
|
||||||
|
- Supports custom connections: add a ready-made external link to a subscription
|
||||||
|
- Allows separate `fullchain.pem` and `privkey.pem` paths for Hysteria2 UDP
|
||||||
|
- Supports a custom domain `whitelist`
|
||||||
|
- Optional automatic relay forwarding setup
|
||||||
|
- Installs the Web UI over HTTP or HTTPS: Let's Encrypt, self-signed, or custom certificates
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Ubuntu 20.04 or newer
|
- Ubuntu 20.04 or newer, Debian 12.11 or newer
|
||||||
- `3x-ui` panel
|
- `3x-ui` panel v2.8.4 or newer
|
||||||
|
- Root access on the server
|
||||||
|
- Docker and Docker Compose; if missing, the installer will try to install them automatically
|
||||||
- Domain + SSL certificate (optional)
|
- Domain + SSL certificate (optional)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install the project on your server with:
|
The `3x-ui` control panel must already be installed. You can install it with: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||||
|
|
||||||
|
Install the project on the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Short description: runs the installer script and deploys containers and services.</sup>
|
During installation, the script will:
|
||||||
|
|
||||||
|
- check the OS, root access, Docker, and Docker Compose;
|
||||||
|
- create a 2 GB swap file if RAM is low and swap is missing;
|
||||||
|
- ask which Web UI access mode to use:
|
||||||
|
- HTTPS via Let's Encrypt;
|
||||||
|
- HTTPS with a self-signed certificate;
|
||||||
|
- HTTPS with your own certificates;
|
||||||
|
- HTTP without encryption;
|
||||||
|
- generate a random Web UI port;
|
||||||
|
- generate the administrator login and password;
|
||||||
|
- deploy the `postgres`, `backend`, and `frontend` containers.
|
||||||
|
|
||||||
|
After installation, the terminal will show the URL, login, and password. Change the password immediately in the 3DP-MANAGER settings.
|
||||||
|
|
||||||
|
<sup>Short description: runs the interactive installer, prepares the environment, and deploys the project containers.</sup>
|
||||||
|
|
||||||
## Update
|
## Update
|
||||||
|
|
||||||
@@ -74,7 +105,7 @@ Update to the latest version:
|
|||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Short description: pulls the latest changes and restarts containers.</sup>
|
<sup>Short description: pulls the latest changes, applies compatible configuration fixes, updates containers, and restarts the service.</sup>
|
||||||
|
|
||||||
## Removal
|
## Removal
|
||||||
|
|
||||||
@@ -88,37 +119,94 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Install forwarding service
|
## First Login
|
||||||
|
|
||||||
The forwarding service allows proxying incoming ports from the intermediate server to the main server.
|
The administrator login and password are printed at the end of installation. To view them again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Short description: adds forwarding rules and creates a service to update the subscription.</sup>
|
The Web UI is available at the address shown by the installer, for example:
|
||||||
|
|
||||||
## Remove forwarding
|
- `https://example.com:PORT` for HTTPS;
|
||||||
|
- `http://SERVER_IP:PORT` for HTTP.
|
||||||
|
|
||||||
|
If `ufw` is active, the installer opens the working inbound port ranges: `443`, `8443`, and `10000-60000` for TCP/UDP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nodes
|
||||||
|
|
||||||
|
The **Nodes** section lets you connect one or more `3x-ui` panels.
|
||||||
|
|
||||||
|
For each node, you specify:
|
||||||
|
|
||||||
|
- name;
|
||||||
|
- `3x-ui` panel URL;
|
||||||
|
- node IP;
|
||||||
|
- flag/country;
|
||||||
|
- authentication type: `password` or `token`;
|
||||||
|
- whether this node is the main node.
|
||||||
|
|
||||||
|
After you enter the URL, the application tries to detect the node IP and country automatically. The flag is used in connection names inside the subscription so clients can distinguish servers more easily.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The main node is used as the default value for subscriptions, inbounds, and relay servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
|
||||||
|
The **Subscriptions** section manages static subscription URLs and inbound composition.
|
||||||
|
|
||||||
|
For each inbound, you can configure:
|
||||||
|
|
||||||
|
- connection type;
|
||||||
|
- node;
|
||||||
|
- relay server;
|
||||||
|
- flag;
|
||||||
|
- port: a concrete number from `1-65535` or `random`;
|
||||||
|
- SNI: a concrete domain or `random`;
|
||||||
|
- a ready-made external link for the `custom` type.
|
||||||
|
|
||||||
|
For `hysteria2-udp`, you can specify separate certificate and key paths:
|
||||||
|
|
||||||
|
- `certificateFile`, for example `/root/cert/example.com/fullchain.pem`;
|
||||||
|
- `keyFile`, for example `/root/cert/example.com/privkey.pem`.
|
||||||
|
|
||||||
|
If port or SNI is set to `random`, the value will be selected during rotation from the available range or domain whitelist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install Forwarding Service
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The forwarding service runs on an intermediate server
|
||||||
|
|
||||||
|
The forwarding service proxies incoming ports from an intermediate server to the selected node. In the Web UI, these servers are configured in **Relay servers** and can be linked to a specific node.
|
||||||
|
|
||||||
|
A relay server can be added by IP or domain. If a domain is provided, the backend will try to resolve its IP automatically.
|
||||||
|
|
||||||
|
For manual forwarding installation on the intermediate server, replace `IP_ADDRESS` with the origin node IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
<sup>Short description: adds forwarding rules.</sup>
|
||||||
|
|
||||||
|
## Remove Forwarding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Short description: removes rules and disables the forwarding service.</sup>
|
<sup>Short description: removes forwarding rules. After removal, reload the firewall with `ufw reload` and reboot the system with `reboot` for changes to take effect.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Show subscription URL
|
## Collect Domains From Multi-Subscriptions
|
||||||
|
|
||||||
Command to print the current subscription URL from the container environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
|
||||||
```
|
|
||||||
|
|
||||||
<sup>Short description: prints the static subscription URL that can be used in clients. Works on both main and intermediate servers.</sup>
|
|
||||||
|
|
||||||
## Collect domains from multi-subscriptions
|
|
||||||
|
|
||||||
The utility extracts domains from subscriptions and builds a `whitelist` for the generator.
|
The utility extracts domains from subscriptions and builds a `whitelist` for the generator.
|
||||||
|
|
||||||
@@ -126,24 +214,13 @@ The utility extracts domains from subscriptions and builds a `whitelist` for the
|
|||||||
node get_domains.js
|
node get_domains.js
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Short description: add a multi-subscription link to the script and run the command — the output will be a list of domains. `Node.js` is required to run the script.</sup>
|
<sup>Short description: add a multi-subscription link to the script and run the command. The output will be a domain list. `Node.js` is required.</sup>
|
||||||
|
|
||||||
## Use your own whitelist
|
|
||||||
|
|
||||||
1. Prepare a file in the format of `whitelist.txt`.
|
|
||||||
2. Rename it to `my_whitelist.txt` and copy it into `/opt/3dp-manager/app`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
<sup>Short description: adds your domain file into the application container.</sup>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes and current limitations
|
## Notes And Current Limitations
|
||||||
|
|
||||||
- The shared domain list may not work with all providers; it is recommended to prepare and use your own `whitelist`.
|
- The shared domain list may not work with all providers, so it is recommended to prepare and use your own whitelist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,16 +229,16 @@ cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.t
|
|||||||
Contributions are welcome! Simple contributor workflow:
|
Contributions are welcome! Simple contributor workflow:
|
||||||
|
|
||||||
1. Fork the repository on GitHub.
|
1. Fork the repository on GitHub.
|
||||||
2. Create a branch with a meaningful name, e.g. `feature/add-README` or `fix/whitelist-load`.
|
2. Create a branch with a meaningful name, for example `feature/add-README` or `fix/whitelist-load`.
|
||||||
3. Make changes and add a short commit message.
|
3. Make changes and add a short description in the commit.
|
||||||
4. Run local checks if available.
|
4. Run local checks if available.
|
||||||
5. Push the branch to your fork and create a Pull Request to the main repository.
|
5. Push the branch to your fork and create a Pull Request to the main repository.
|
||||||
|
|
||||||
<sup>Tips: describe changes and testing steps in the PR. For large changes, split into smaller commits.</sup>
|
<sup>Tips: describe your changes in the PR, include the goal and test steps. If the changes are large, split them into small commits.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Discussion
|
## Discussion
|
||||||
|
|
||||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||||
- Issues
|
- Issues section in this repository
|
||||||
|
|||||||
+148
-71
@@ -2,166 +2,243 @@
|
|||||||
|
|
||||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||||
|
|
||||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||||
|
|
||||||
# 3DP-MANAGER
|
# 3DP-MANAGER
|
||||||
|
|
||||||
ابزاری برای تولید خودکار اتصالهای inbound برای پنل `3x-ui`، ساخت یک اشتراک یکپارچه و هدایت ترافیک از سرور واسط به سرور اصلی.
|
ابزاری برای تولید خودکار inbound برای پنل [3x-ui](https://github.com/MHSanaei/3x-ui)، ساخت اشتراکهای یکپارچه، مدیریت چندین نود 3x-ui و پیکربندی relay forwarding از سرورهای واسط به سرورهای اصلی. از نسخه 2.0.0 به بعد، پروژه دارای رابط گرافیکی و تنظیمات ساده کاربری است.
|
||||||
|
|
||||||
**حمایت از پروژه**
|
**حمایت از پروژه**
|
||||||
|
|
||||||
- اطلاعات پرداخت/اهدا:
|
- انتقال بانکی:
|
||||||
- کارت MIR: `2204320436318077`
|
- :credit_card: کارت MIR: `2204320436318077`
|
||||||
- کارت MasterCard: `5395452209474530`
|
- :credit_card: کارت MasterCard: `5395452209474530`
|
||||||
- PayPal: `vasiljevdenisx@gmail.com`
|
- کیف پول الکترونیکی:
|
||||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
- :moneybag: YooMoney: `4100116897060652`
|
||||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||||
- بیتکوین: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
- رمزارز:
|
||||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||||
- Bybit ID: `165292278`
|
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||||
|
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||||
|
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||||
|
- :coin: Bybit ID: `165292278`
|
||||||
|
|
||||||
## توضیحات
|
## توضیحات
|
||||||
|
|
||||||
هدف اصلی این ابزار این است که ترافیک شما یکسان به نظر نرسد. ربات در فواصل زمانی مشخص 10 اتصال با پارامترهای مختلف تولید میکند:
|
هدف اصلی ابزار این است که ترافیک شما یکسان به نظر نرسد. 3DP-MANAGER در بازه زمانی تعیینشده، مجموعهای از اتصالها با پارامترهای متفاوت تولید میکند:
|
||||||
|
|
||||||
- پروتکلها: `vless`, `vmess`, `shadowsocks`, `trojan`
|
- پروتکلها: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||||
- پورتها: `443`, `8443` (ثابت) و پورتهای تصادفی در بازه `10000-60000`
|
- پورتها: `443` و `8443` بهصورت ثابت، و پورتهای تصادفی از بازه `10000-60000`;
|
||||||
- لایه انتقال: `tcp`, `websocket`, `grpc`, `xhttp`
|
- transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||||
- SNI از لیست سفید دامنهها گرفته میشود (`whitelist`); میتوانید از لیست خود استفاده کنید.
|
- SNI از whitelist دامنهها انتخاب میشود یا بهصورت دستی تنظیم میگردد؛
|
||||||
|
- نود، relay server، پورت و پرچم را میتوان برای هر inbound جداگانه تنظیم کرد.
|
||||||
|
|
||||||
تمام اتصالات در یک اشتراک با URL ثابت تجمیع میشوند. ربات با پنل `3x-ui` از طریق API باز کار میکند و بهطور مستقیم در عملکرد داخلی پنل دخالت نمیکند.
|
همه اتصالها در یک اشتراک با URL ثابت ترکیب میشوند. 3DP-MANAGER با پنل `3x-ui` از طریق API عمومی آن کار میکند و مستقیماً در بخش داخلی پنل دخالت نمیکند.
|
||||||
|
|
||||||
هدف ثانویه افزایش پایداری اتصال است: مشتری 10 گزینه اتصال دریافت میکند و میتواند هر کدام را انتخاب کند.
|
هدف دوم پایداری اتصال است: کلاینت چند گزینه اتصال دریافت میکند و میتواند هرکدام را انتخاب کند. در یک اشتراک میتوان inboundهای مربوط به نودهای مختلف، relay serverها و لینکهای خارجی آماده را بهعنوان اتصال custom قرار داد.
|
||||||
|
|
||||||
علاوه بر این، ربات میتواند در طرحهای آبشاری استفاده شود. سرویس فورواردینگ بهطور خودکار بازنشانی اشتراک و ترافیک به سرور اصلی را تنظیم میکند.
|
همچنین 3DP-MANAGER را میتوان در سناریوی زنجیرهای استفاده کرد. سرویس forwarding، هدایت اشتراک و ترافیک را به نود انتخابشده پیکربندی میکند.
|
||||||
|
|
||||||
توصیهها:
|
توصیهها:
|
||||||
|
|
||||||
- برای اشتراک از HTTPS استفاده کنید (دامنه + گواهی SSL).
|
- برای اشتراک از HTTPS استفاده کنید (دامنه + گواهی SSL).
|
||||||
- فاصله تولید را ≥ 10 دقیقه در نظر بگیرید؛ برای پایداری توصیه میشود یکبار در روز (1440 دقیقه) تنظیم شود.
|
- فاصله تولید را حداقل 10 دقیقه تنظیم کنید؛ برای پایداری، یکبار در روز (1440 دقیقه) توصیه میشود.
|
||||||
- در کلاینت بهروزرسانی خودکار را بیشتر تنظیم کنید (مثلاً هر ساعت) تا همگامسازی با سرور انجام شود.
|
- در کلاینت، بهروزرسانی خودکار را دفعات بیشتری تنظیم کنید، مثلاً هر ساعت، تا با سرور همگام بماند.
|
||||||
|
|
||||||
## قابلیتها
|
## قابلیتها
|
||||||
|
|
||||||
- تولید 10 اتصال متنوع
|
- تولید اتصالهای متنوع برای یک یا چند اشتراک
|
||||||
- تشکیل یک اشتراک یکپارچه با URL ثابت
|
- ساخت اشتراک یکپارچه با URL ثابت
|
||||||
- پشتیبانی از دامنههای `whitelist` سفارشی
|
- مدیریت چندین نود `3x-ui`
|
||||||
- پیکربندی خودکار فورواردینگ ترافیک (اختیاری)
|
- پشتیبانی از احراز هویت نود با login/password یا token
|
||||||
|
- تشخیص خودکار IP، کشور و پرچم نود از URL پنل
|
||||||
|
- انتخاب نود، relay server، پرچم، SNI و پورت برای هر inbound
|
||||||
|
- پشتیبانی از پورت ثابت یا مقدار `random`
|
||||||
|
- پشتیبانی از اتصالهای custom: افزودن لینک خارجی آماده به اشتراک
|
||||||
|
- امکان تعیین مسیرهای جداگانه `fullchain.pem` و `privkey.pem` برای Hysteria2 UDP
|
||||||
|
- پشتیبانی از `whitelist` دامنه سفارشی
|
||||||
|
- تنظیم خودکار relay forwarding بهصورت اختیاری
|
||||||
|
- نصب Web UI از طریق HTTP یا HTTPS: Let's Encrypt، self-signed یا گواهیهای خودتان
|
||||||
|
|
||||||
## نیازمندیها
|
## نیازمندیها
|
||||||
|
|
||||||
- Ubuntu 20.04 یا بالاتر
|
- Ubuntu 20.04 یا جدیدتر، Debian 12.11 یا جدیدتر
|
||||||
- پنل `3x-ui`
|
- پنل `3x-ui` نسخه v2.8.4 یا جدیدتر
|
||||||
|
- دسترسی root روی سرور
|
||||||
|
- Docker و Docker Compose؛ اگر نصب نباشند، اسکریپت نصب تلاش میکند آنها را خودکار نصب کند
|
||||||
- دامنه + گواهی SSL (اختیاری)
|
- دامنه + گواهی SSL (اختیاری)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## نصب
|
## نصب
|
||||||
|
|
||||||
برای نصب پروژه در سرور، دستور زیر را اجرا کنید:
|
پنل مدیریتی `3x-ui` باید از قبل نصب شده باشد. میتوانید آن را با این دستور نصب کنید: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||||
|
|
||||||
|
پروژه را روی سرور نصب کنید:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>توضیح کوتاه: اسکریپت نصب را اجرا کرده و کانتینرها و سرویسها را راهاندازی میکند.</sup>
|
در زمان نصب، اسکریپت:
|
||||||
|
|
||||||
|
- سیستمعامل، دسترسی root، Docker و Docker Compose را بررسی میکند؛
|
||||||
|
- اگر RAM کم باشد و swap وجود نداشته باشد، یک فایل swap با حجم 2 GB میسازد؛
|
||||||
|
- حالت دسترسی Web UI را میپرسد:
|
||||||
|
- HTTPS با Let's Encrypt؛
|
||||||
|
- HTTPS با گواهی self-signed؛
|
||||||
|
- HTTPS با گواهیهای خودتان؛
|
||||||
|
- HTTP بدون رمزنگاری؛
|
||||||
|
- یک پورت تصادفی برای Web UI ایجاد میکند؛
|
||||||
|
- login و password مدیر را ایجاد میکند؛
|
||||||
|
- کانتینرهای `postgres`, `backend` و `frontend` را اجرا میکند.
|
||||||
|
|
||||||
|
پس از پایان نصب، URL، login و password در ترمینال نمایش داده میشود. بلافاصله password را در تنظیمات 3DP-MANAGER تغییر دهید.
|
||||||
|
|
||||||
|
<sup>توضیح کوتاه: اسکریپت نصب تعاملی را اجرا میکند، محیط را آماده میسازد و کانتینرهای پروژه را راهاندازی میکند.</sup>
|
||||||
|
|
||||||
## بهروزرسانی
|
## بهروزرسانی
|
||||||
|
|
||||||
برای بهروزرسانی به آخرین نسخه:
|
بهروزرسانی به آخرین نسخه:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>توضیح کوتاه: تغییرات جدید را میکشد و کانتینرها را ریاستارت میکند.</sup>
|
<sup>توضیح کوتاه: آخرین تغییرات را دریافت میکند، اصلاحات سازگار پیکربندی را اعمال میکند، کانتینرها را بهروزرسانی کرده و سرویس را راهاندازی مجدد میکند.</sup>
|
||||||
|
|
||||||
## حذف
|
## حذف
|
||||||
|
|
||||||
برای حذف کامل سرویس:
|
حذف کامل سرویس:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>توضیح کوتاه: کانتینرها و فایلهای پیکربندی را حذف میکند و سیستم را به وضعیت پیش از نصب برمیگرداند.</sup>
|
<sup>توضیح کوتاه: کانتینرها و فایلهای پیکربندی را حذف میکند و سیستم را به وضعیت پیش از نصب بازمیگرداند.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## نصب سرویس فورواردینگ (forwarding)
|
## اولین ورود
|
||||||
|
|
||||||
سرویس فورواردینگ امکان پراکسی کردن پورتهای ورودی از سرور واسط به سرور اصلی را فراهم میکند.
|
login و password مدیر در پایان نصب نمایش داده میشود. برای مشاهده دوباره:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>توضیح کوتاه: قوانین فورواردینگ را اضافه کرده و سرویسی برای بهروزرسانی اشتراک ایجاد میکند.</sup>
|
Web UI در آدرسی که نصبکننده نمایش داده قابل دسترسی است، برای مثال:
|
||||||
|
|
||||||
## حذف فورواردینگ
|
- `https://example.com:PORT` برای HTTPS؛
|
||||||
|
- `http://SERVER_IP:PORT` برای HTTP.
|
||||||
|
|
||||||
|
اگر `ufw` فعال باشد، نصبکننده بازههای پورت کاری inbound را برای TCP/UDP باز میکند: `443`, `8443` و `10000-60000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نودها
|
||||||
|
|
||||||
|
بخش **نودها** امکان اتصال یک یا چند پنل `3x-ui` را فراهم میکند.
|
||||||
|
|
||||||
|
برای هر نود مشخص میشود:
|
||||||
|
|
||||||
|
- نام؛
|
||||||
|
- URL پنل `3x-ui`؛
|
||||||
|
- IP نود؛
|
||||||
|
- پرچم/کشور؛
|
||||||
|
- نوع احراز هویت: `password` یا `token`؛
|
||||||
|
- اینکه نود اصلی است یا خیر.
|
||||||
|
|
||||||
|
پس از وارد کردن URL، برنامه تلاش میکند IP و کشور نود را خودکار تشخیص دهد. پرچم در نام اتصالها داخل اشتراک استفاده میشود تا تشخیص سرورها در کلاینت سادهتر باشد.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> نود اصلی بهعنوان مقدار پیشفرض برای اشتراکها، inboundها و relay serverها استفاده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اشتراکها
|
||||||
|
|
||||||
|
بخش **اشتراکها** URLهای ثابت اشتراک و ترکیب inboundها را مدیریت میکند.
|
||||||
|
|
||||||
|
برای هر inbound میتوانید تنظیم کنید:
|
||||||
|
|
||||||
|
- نوع اتصال؛
|
||||||
|
- نود؛
|
||||||
|
- relay server؛
|
||||||
|
- پرچم؛
|
||||||
|
- پورت: عدد مشخص از `1-65535` یا `random`؛
|
||||||
|
- SNI: دامنه مشخص یا `random`؛
|
||||||
|
- لینک خارجی آماده برای نوع `custom`.
|
||||||
|
|
||||||
|
برای `hysteria2-udp` میتوانید مسیرهای جداگانه گواهی و کلید را مشخص کنید:
|
||||||
|
|
||||||
|
- `certificateFile`، برای مثال `/root/cert/example.com/fullchain.pem`;
|
||||||
|
- `keyFile`، برای مثال `/root/cert/example.com/privkey.pem`.
|
||||||
|
|
||||||
|
اگر پورت یا SNI برابر `random` باشد، مقدار هنگام روتیشن از بازه موجود یا whitelist دامنهها انتخاب میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نصب سرویس forwarding
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> سرویس forwarding روی سرور واسط اجرا میشود
|
||||||
|
|
||||||
|
سرویس forwarding پورتهای ورودی سرور واسط را به نود انتخابشده proxy میکند. در Web UI این سرورها در بخش **Relay serverها** تنظیم میشوند و میتوانند به یک نود مشخص متصل شوند.
|
||||||
|
|
||||||
|
Relay server را میتوان با IP یا دامنه اضافه کرد. اگر دامنه وارد شود، backend تلاش میکند IP آن را خودکار resolve کند.
|
||||||
|
|
||||||
|
برای نصب دستی forwarding روی سرور واسط، `IP_ADDRESS` را با IP نود اصلی جایگزین کنید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
<sup>توضیح کوتاه: قوانین forwarding را اضافه میکند.</sup>
|
||||||
|
|
||||||
|
## حذف forwarding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>توضیح کوتاه: قوانین را حذف کرده و سرویس فورواردینگ را غیرفعال میکند.</sup>
|
<sup>توضیح کوتاه: قوانین forwarding را حذف میکند. پس از حذف، برای اعمال تغییرات firewall را با `ufw reload` بارگذاری مجدد کنید و سیستم را با `reboot` ریاستارت کنید.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## نمایش URL اشتراک
|
## جمعآوری دامنهها از چند اشتراک
|
||||||
|
|
||||||
دستور برای نمایش URL اشتراک فعلی در محیط کانتینر:
|
این ابزار دامنهها را از اشتراکها استخراج میکند و برای generator یک `whitelist` میسازد.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
|
||||||
```
|
|
||||||
|
|
||||||
<sup>توضیح کوتاه: URL ثابت اشتراک را نمایش میدهد که میتوان در کلاینتها استفاده کرد. هم در سرور اصلی و هم در سرور واسط کار میکند.</sup>
|
|
||||||
|
|
||||||
## جمعآوری دامنهها از چندین اشتراک
|
|
||||||
|
|
||||||
ابزار دامنهها را از اشتراکهای دارای چندین پیکربندی استخراج کرده و `whitelist` تولید میکند.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node get_domains.js
|
node get_domains.js
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>توضیح کوتاه: لینک چندین اشتراک را در اسکریپت قرار داده و فرمان را اجرا کنید — خروجی فهرست دامنهها خواهد بود. نیازمند `Node.js` است.</sup>
|
<sup>توضیح کوتاه: لینک چند اشتراک را در اسکریپت اضافه کنید و دستور را اجرا کنید. خروجی، فهرست دامنهها خواهد بود. برای اجرای اسکریپت به `Node.js` نیاز است.</sup>
|
||||||
|
|
||||||
## استفاده از whitelist شخصی
|
|
||||||
|
|
||||||
1. فایلی با ساختار مشابه `whitelist.txt` آماده کنید.
|
|
||||||
2. آن را به `my_whitelist.txt` تغییر نام دهید و در پوشه `/opt/3dp-manager/app` کپی کنید.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
<sup>توضیح کوتاه: فایل دامنه شما را به کانتینر برنامه اضافه میکند.</sup>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## نکات و محدودیتهای فعلی
|
## نکات و محدودیتهای فعلی
|
||||||
|
|
||||||
- لیست عمومی دامنهها برای همه ارائهدهندگان کار نمیکند؛ پیشنهاد میشود `whitelist` خود را تهیه و استفاده کنید.
|
- فهرست عمومی دامنهها با همه ارائهدهندگان کار نمیکند، بنابراین توصیه میشود whitelist خودتان را بسازید و استفاده کنید.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## مشارکت
|
## مشارکت
|
||||||
|
|
||||||
از هرگونه مشارکت در توسعه پروژه خوشحال میشوم! روند ساده برای مشارکتکنندگان:
|
از هرگونه مشارکت در توسعه پروژه استقبال میشود! روند ساده برای مشارکتکنندگان:
|
||||||
|
|
||||||
1. مخزن را در GitHub fork کنید.
|
1. مخزن را در GitHub fork کنید.
|
||||||
2. شاخهای با نام معنادار ایجاد کنید مانند `feature/add-README` یا `fix/whitelist-load`.
|
2. شاخهای با نام معنادار بسازید، مثل `feature/add-README` یا `fix/whitelist-load`.
|
||||||
3. تغییرات را اعمال کرده و یک پیام کوتاه در کامیت اضافه کنید.
|
3. تغییرات را اعمال کنید و توضیح کوتاهی در commit بنویسید.
|
||||||
4. در صورت وجود، بررسیهای محلی را اجرا کنید.
|
4. در صورت وجود، بررسیهای محلی را اجرا کنید.
|
||||||
5. شاخه را به fork خود پوش کرده و Pull Request ایجاد کنید.
|
5. شاخه را به fork خود push کنید و در مخزن اصلی Pull Request بسازید.
|
||||||
|
|
||||||
<sup>نکات: تغییرات را در PR شرح دهید و مراحل تست را بنویسید. در صورت تغییرات بزرگ، آنها را به کامیتهای کوچک تقسیم کنید.</sup>
|
<sup>نکته: تغییرات، هدف و مراحل تست را در PR توضیح دهید. اگر تغییرات بزرگ هستند، آنها را به commitهای کوچک تقسیم کنید.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## بحث
|
## گفتگو
|
||||||
|
|
||||||
- تلگرام: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
- تلگرام: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||||
- Issues
|
- بخش Issues در همین مخزن
|
||||||
|
|||||||
+160
-71
@@ -2,154 +2,243 @@
|
|||||||
|
|
||||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||||
|
|
||||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||||
|
|
||||||
# 3DP-MANAGER
|
# 3DP-MANAGER
|
||||||
|
|
||||||
Bu gural `3x-ui` paneli üçin awtomatiki ýagdaýda inbound baglanyşlaryny döredýär, birleşdirilen abunany (subscription) döretmäge we aragatnaşyk serwerinden esasy servere trafigiň ugradylyşyny sazlamaga kömek edýär.
|
[3x-ui](https://github.com/MHSanaei/3x-ui) paneli üçin inbound-lary awtomatiki döretmäge, birleşdirilen abunalary düzmäge, birnäçe 3x-ui node-ny dolandyrmaga we aralyk serwerlerden esasy serwerlere relay forwarding sazlamaga niýetlenen gural. 2.0.0 wersiýadan bäri taslamada grafiki interfeýs we ýönekeý ulanyjy sazlamalary bar.
|
||||||
|
|
||||||
**Taslamany goldaň**
|
**Taslamany goldaň**
|
||||||
|
|
||||||
- Töleg / goşant maglumatlary:
|
- Bank geçirimi:
|
||||||
- MIR kart: `2204320436318077`
|
- :credit_card: MIR kart: `2204320436318077`
|
||||||
- MasterCard: `5395452209474530`
|
- :credit_card: MasterCard: `5395452209474530`
|
||||||
- PayPal: `vasiljevdenisx@gmail.com`
|
- Elektron gapjyk:
|
||||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
- :moneybag: YooMoney: `4100116897060652`
|
||||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||||
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
- Kriptowalýuta:
|
||||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||||
- Bybit ID: `165292278`
|
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||||
|
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||||
|
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||||
|
- :coin: Bybit ID: `165292278`
|
||||||
|
|
||||||
## Düşündiriş
|
## Düşündiriş
|
||||||
|
|
||||||
Esasy maksady trafigiňizi birmeňzeş bolmaz ýaly etmekdir. Bot bellenen aralykda dürli parametrler bilen 10 sany baglanyşygy döredýär:
|
Guralyň esasy maksady trafigiňiziň birmeňzeş görünmezligini gazanmakdyr. 3DP-MANAGER bellenen aralykda dürli parametrli baglanyşyklar toplumyny döredýär:
|
||||||
|
|
||||||
- Protokollar: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
- protokollar: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||||
- Portlar: `443`, `8443` (hemişelik) we `10000-60000` aralygyndan tötänleýin portlar;
|
- portlar: hemişelik `443`, `8443` we `10000-60000` aralygyndaky tötänleýin portlar;
|
||||||
- Transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
- transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||||
- SNI `whitelist` domen sanawyndan alynýar; öz sanawyňyzy hem ulanyp bilersiňiz.
|
- SNI domen whitelist-den alynýar ýa-da el bilen berilýär;
|
||||||
|
- her inbound üçin node, relay serwer, port we baýdak aýratyn sazlanýar.
|
||||||
|
|
||||||
Ähli döredilen baglanyşyklary biri-birine birleşdirip, statik URL bilen bir abuna döredilýär. Bot `3x-ui` paneliniň açyk API-sini ulanýar we paneliň içki konfigurasiýasyna gönüden-göni aralaşmaýar.
|
Ähli baglanyşyklar statik URL-li bir abuna birleşdirilýär. 3DP-MANAGER `3x-ui` paneli bilen onuň açyk API-si arkaly işleýär we paneliň içki işine göni gatyşmaýar.
|
||||||
|
|
||||||
Ikinji maksady baglanyşygyň durnuklylygyny ýokarlandyrmakdyr: müşderi 10 dürli baglanyşyk opsiýasyny alýar we islese haýsyny islese saýlap bilýär.
|
Ikinji maksat baglanyşygyň durnuklylygydyr: müşderi birnäçe baglanyşyk wariantyny alýar we islänini saýlap bilýär. Bir abuna dürli node-lardan inbound-lary, relay serwerleri we custom baglanyşyk hökmünde taýýar daşarky linkleri goşup bolýar.
|
||||||
|
|
||||||
Göçürme (forwarding) hyzmaty bilen birleşdirilende, bot abonnament we trafik diňe esasy servere ugradylyşyny awtomatiki sazlar.
|
Mundan başga-da, 3DP-MANAGER kaskadly shemada ulanylyp bilner. Forwarding hyzmaty abunany we trafigi saýlanan node-a ugrukdyrmagy sazlaýar.
|
||||||
|
|
||||||
Maslahatchylyklar:
|
Maslahatlar:
|
||||||
|
|
||||||
- Abuna üçin HTTPS ulanyň (domain + SSL şahadatnamasy).
|
- Abuna üçin HTTPS ulanyň (domen + SSL şahadatnamasy).
|
||||||
- Dörediş aralygyny ≥ 10 minut goýuň; durnuklylyk üçin her gün bir gezek (1440 minut) maslahat berilýär.
|
- Döretmek aralygyny azyndan 10 minut goýuň; durnuklylyk üçin günde bir gezek (1440 minut) maslahat berilýär.
|
||||||
- Müşderide awtomatiki täzelenmäni ýygylaşdyryň (meselem, her sagat) — serwera sazlaşyklylyk üçin.
|
- Müşderide awtomatiki täzelenmäni ýygy goýuň, meselem her sagat, serwer bilen sazlaşyk saklansyn.
|
||||||
|
|
||||||
## Imkançylyklar
|
## Mümkinçilikler
|
||||||
|
|
||||||
- 10 dürli baglanyşygy öndürýär
|
- Bir ýa-da birnäçe abuna üçin dürli baglanyşyklar döredýär
|
||||||
- Statik URL bilen bir abuna döredýär
|
- Statik URL bilen birleşdirilen abuna döredýär
|
||||||
- Öz `whitelist` domenleriňizi goldaýar
|
- Birnäçe `3x-ui` node-ny dolandyrýar
|
||||||
- Trafigiň awtomatik forwarding sazlamalary (islege bagly)
|
- Node autentifikasiýasyny login/password ýa-da token arkaly goldaýar
|
||||||
|
- Panel URL-den node IP-sini, ýurduny we baýdagyny awtomatiki kesgitleýär
|
||||||
|
- Her inbound üçin node, relay serwer, baýdak, SNI we port saýlamaga mümkinçilik berýär
|
||||||
|
- Hemişelik porty ýa-da `random` bahasyny goldaýar
|
||||||
|
- Custom baglanyşyklary goldaýar: taýýar daşarky linki abuna goşup bolýar
|
||||||
|
- Hysteria2 UDP üçin `fullchain.pem` we `privkey.pem` ýollaryny aýratyn görkezmäge mümkinçilik berýär
|
||||||
|
- Öz domen `whitelist` sanawyňyzy goldaýar
|
||||||
|
- Relay forwarding-i awtomatiki sazlamak mümkinçiligi bar
|
||||||
|
- Web UI HTTP ýa-da HTTPS arkaly gurnalýar: Let's Encrypt, self-signed ýa-da öz şahadatnamalaryňyz
|
||||||
|
|
||||||
## Talaplar
|
## Talaplar
|
||||||
|
|
||||||
- Ubuntu 20.04 ýa-da täze
|
- Ubuntu 20.04 ýa-da täze, Debian 12.11 ýa-da täze
|
||||||
- `3x-ui` paneli
|
- `3x-ui` paneli v2.8.4 ýa-da täze
|
||||||
- Domain + SSL şahadatnamasy (islege bagly)
|
- Serwerde root elýeterliligi
|
||||||
|
- Docker we Docker Compose; ýok bolsa, gurnama skripti olary awtomatiki gurnamaga synanyşar
|
||||||
|
- Domen + SSL şahadatnamasy (islege bagly)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gurnama
|
## Gurnama
|
||||||
|
|
||||||
Taslamany serwera gurnamak üçin şu komandany ýerine ýetiriň:
|
`3x-ui` dolandyryş paneli öňünden gurnalan bolmaly. Ony şu komanda bilen gurnap bilersiňiz: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||||
|
|
||||||
|
Taslamany serwere gurnaň:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Gysgaça: gurnama skripti işledilýär we konteýnerler we hyzmatlar döredilýär.</sup>
|
Gurnama wagtynda skript:
|
||||||
|
|
||||||
## Täzelik
|
- OS, root elýeterliligi, Docker we Docker Compose-y barlaýar;
|
||||||
|
- RAM az we swap ýok bolsa, 2 GB swap faýl döredýär;
|
||||||
|
- Web UI elýeterlilik görnüşini saýladýar:
|
||||||
|
- Let's Encrypt arkaly HTTPS;
|
||||||
|
- self-signed şahadatnama bilen HTTPS;
|
||||||
|
- öz şahadatnamalaryňyz bilen HTTPS;
|
||||||
|
- şifrlemesiz HTTP;
|
||||||
|
- Web UI üçin tötänleýin port döredýär;
|
||||||
|
- administrator login we password döredýär;
|
||||||
|
- `postgres`, `backend` we `frontend` konteýnerlerini işe goýberýär.
|
||||||
|
|
||||||
Soňky wersiýa çenli täzeläň:
|
Gurnama tamamlanandan soň terminalda URL, login we password görkeziler. Password-y derrew 3DP-MANAGER sazlamalarynda üýtgediň.
|
||||||
|
|
||||||
|
<sup>Gysgaça: interaktiw gurnama skriptini işledýär, gurşawy taýýarlaýar we taslamanyň konteýnerlerini ýerleşdirýär.</sup>
|
||||||
|
|
||||||
|
## Täzelemek
|
||||||
|
|
||||||
|
Soňky wersiýa çenli täzelemek:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Gysgaça: iň soňky üýtgeşmeleri çekýär we konteýnerleri gaýtadan işledýär.</sup>
|
<sup>Gysgaça: soňky üýtgeşmeleri alýar, laýyk konfigurasiýa düzedişlerini ulanýar, konteýnerleri täzeleýär we hyzmaty gaýtadan işledýär.</sup>
|
||||||
|
|
||||||
## Pozmak
|
## Pozmak
|
||||||
|
|
||||||
Hyzmaty doly pozmak üçin:
|
Hyzmaty doly pozmak:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
<sup>Gysgaça: konteýnerleri we konfigurasiýa faýllaryny pozup, systemany gurnamadan öňki ýagdaýyna getirer.</sup>
|
<sup>Gysgaça: konteýnerleri we konfigurasiýa faýllaryny pozýar, ulgamy gurnamadan öňki ýagdaýa getirýär.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Forwarding hyzmatyny gurnamak
|
## Ilkinji Giriş
|
||||||
|
|
||||||
Forwarding hyzmaty aragatnaşyk serwerinden esasy servere gelýän portlary proxy arkaly geçirýär.
|
Administrator login we password gurnamanyň ahyrynda görkezilýär. Olary gaýtadan görmek üçin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Forwarding-i pozmak
|
Web UI gurnawçynyň görkezen salgysy boýunça elýeterlidir, mysal üçin:
|
||||||
|
|
||||||
|
- HTTPS üçin `https://example.com:PORT`;
|
||||||
|
- HTTP üçin `http://SERVER_IP:PORT`.
|
||||||
|
|
||||||
|
Eger `ufw` işjeň bolsa, gurnawçy inbound üçin iş portlaryny açýar: TCP/UDP üçin `443`, `8443` we `10000-60000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node-lar
|
||||||
|
|
||||||
|
**Node-lar** bölümi bir ýa-da birnäçe `3x-ui` panelini birikdirmäge mümkinçilik berýär.
|
||||||
|
|
||||||
|
Her node üçin görkezilýär:
|
||||||
|
|
||||||
|
- ady;
|
||||||
|
- `3x-ui` panel URL-i;
|
||||||
|
- node IP-si;
|
||||||
|
- baýdak/ýurt;
|
||||||
|
- autentifikasiýa görnüşi: `password` ýa-da `token`;
|
||||||
|
- esasy node bolup durýandygy.
|
||||||
|
|
||||||
|
URL girizilenden soň programma node IP-sini we ýurduny awtomatiki kesgitlemäge synanyşýar. Baýdak abunanyň içindäki baglanyşyk atlarynda ulanylýar, müşderide serwerleri tapawutlandyrmak aňsat bolýar.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Esasy node abunalar, inbound-lar we relay serwerler üçin deslapky baha hökmünde ulanylýar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abunalar
|
||||||
|
|
||||||
|
**Abunalar** bölümi statik abuna URL-lerini we inbound düzümini dolandyrýar.
|
||||||
|
|
||||||
|
Her inbound üçin sazlap bolýar:
|
||||||
|
|
||||||
|
- baglanyşyk görnüşi;
|
||||||
|
- node;
|
||||||
|
- relay serwer;
|
||||||
|
- baýdak;
|
||||||
|
- port: `1-65535` aralygyndaky anyk san ýa-da `random`;
|
||||||
|
- SNI: anyk domen ýa-da `random`;
|
||||||
|
- `custom` görnüşi üçin taýýar daşarky link.
|
||||||
|
|
||||||
|
`hysteria2-udp` üçin şahadatnama we açar ýollaryny aýratyn görkezmek bolýar:
|
||||||
|
|
||||||
|
- `certificateFile`, mysal üçin `/root/cert/example.com/fullchain.pem`;
|
||||||
|
- `keyFile`, mysal üçin `/root/cert/example.com/privkey.pem`.
|
||||||
|
|
||||||
|
Port ýa-da SNI `random` bolsa, baha rotasiýa wagtynda elýeterli aralykdan ýa-da domen whitelist-den saýlanar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forwarding Hyzmatyny Gurnamak
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Forwarding hyzmaty aralyk serwerde işleýär
|
||||||
|
|
||||||
|
Forwarding hyzmaty aralyk serweriň gelýän portlaryny saýlanan node-a proxy edýär. Web UI-de şeýle serwerler **Relay serwerler** bölüminde sazlanýar we belli bir node bilen baglanyşdyrylyp bilýär.
|
||||||
|
|
||||||
|
Relay serwer IP ýa-da domen boýunça goşulyp bilner. Domen görkezilse, backend onuň IP-sini awtomatiki çözmäge synanyşar.
|
||||||
|
|
||||||
|
Aralyk serwerde forwarding-i el bilen gurnamak üçin `IP_ADDRESS` ýerine esasy node IP-sini goýuň:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
<sup>Gysgaça: forwarding düzgünlerini goşýar.</sup>
|
||||||
|
|
||||||
|
## Forwarding-i Pozmak
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<sup>Gysgaça: forwarding düzgünlerini pozýar. Pozlandan soň üýtgeşmeler güýje girmegi üçin firewall-y `ufw reload` bilen täzeden ýükläň we ulgamy `reboot` bilen täzeden işlediň.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abuna URL-ni görkezmek
|
## Multi-Abunalardan Domenleri Toplamak
|
||||||
|
|
||||||
Konteýner gurşawynda häzirki abuna URL-ni görkezýän komanda:
|
Gural abunalardan domenleri çykaryp, generator üçin `whitelist` düzýär.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-abunalardan domenleri toplamak
|
|
||||||
|
|
||||||
Gural birden köp konfigurasiýa içeren abunalardan domenleri çykaryp `whitelist` düzýär.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node get_domains.js
|
node get_domains.js
|
||||||
```
|
```
|
||||||
|
|
||||||
## Öz `whitelist`-iňizi ulanmak
|
<sup>Gysgaça: skripte multi-abuna linkini goşuň we komandany işlediň. Netijede domenleriň sanawy çykýar. Skript üçin `Node.js` gerek.</sup>
|
||||||
|
|
||||||
1. `whitelist.txt` bilen meňzeş struktura eýe faýl taýýarlaň.
|
|
||||||
2. Ony `my_whitelist.txt` diýip atlandyryp `/opt/3dp-manager/app` içine göçüriň.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bellikler we çäklendirmeler
|
## Bellikler We Häzirki Çäklendirmeler
|
||||||
|
|
||||||
- Umumy domen sanawy ähli üpjün edijilerde işlemeýär; öz `whitelist`-iňizi düzmegiňizi maslahat berýäris.
|
- Umumy domen sanawy ähli prowaýderlerde işlemeýär, şonuň üçin öz whitelist sanawyňyzy taýýarlamak we ulanmak maslahat berilýär.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goşanty
|
## Goşant
|
||||||
|
|
||||||
Proýekte goşant bermäge hoş geldiňiz! Goşant prosesi ýönekeý:
|
Taslama goşantlaryňyza hoşal bolarys! Goşantçylar üçin ýönekeý proses:
|
||||||
|
|
||||||
1. GitHub-da repo-ny fork ediň.
|
1. GitHub-da repozitoriýany fork ediň.
|
||||||
2. Meni manly at bilen şahamça (branch) dörediň, meselem `feature/add-README` ýa-da `fix/whitelist-load`.
|
2. Manyly at bilen branch dörediň, mysal üçin `feature/add-README` ýa-da `fix/whitelist-load`.
|
||||||
3. Üýtgeşmeleri giriziň we gysga commit ýazmagy unutmaň.
|
3. Üýtgeşmeleri giriziň we commit-de gysga düşündiriş ýazyň.
|
||||||
4. Lokal barlaglary işlediň (bardy bolsa).
|
4. Bar bolsa, lokal barlaglary işlediň.
|
||||||
5. Şahamçany fork-a iteriň we Pull Request dörediň.
|
5. Branch-i öz fork-uňyza push ediň we esasy repozitoriýa Pull Request dörediň.
|
||||||
|
|
||||||
|
<sup>Maslahat: PR-de üýtgeşmeleri, maksady we test ädimlerini ýazyň. Üýtgeşmeler uly bolsa, olary kiçi commit-lere bölüň.</sup>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Çeperdeşlik
|
## Ara Alyp Maslahatlaşmak
|
||||||
|
|
||||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||||
- Issues
|
- Şu repozitoriýanyň Issues bölümi
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ COPY . .
|
|||||||
ENV VITE_API_URL=/api
|
ENV VITE_API_URL=/api
|
||||||
ENV VITE_LOG_LEVEL=debug
|
ENV VITE_LOG_LEVEL=debug
|
||||||
ENV VITE_SEND_LOGS_TO_BACKEND=true
|
ENV VITE_SEND_LOGS_TO_BACKEND=true
|
||||||
ENV VITE_APP_VERSION=2.1.0
|
ENV VITE_APP_VERSION=2.2.0
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,9 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link data-rh="true" rel="icon" href="/img/favicon.png" />
|
<link data-rh="true" rel="icon" href="/img/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>3DP-MANAGER</title>
|
<title>3DP-MANAGER</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ const drawerWidth = 240;
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ text: 'Подписки', icon: <People />, path: '/' },
|
{ text: 'Подписки', icon: <People />, path: '/' },
|
||||||
{ text: 'Домены', icon: <Dns />, path: '/domains' },
|
|
||||||
{ text: 'Ноды', icon: <Hub />, path: '/nodes' },
|
{ text: 'Ноды', icon: <Hub />, path: '/nodes' },
|
||||||
{ text: 'Relay серверы', icon: <SwapHoriz />, path: '/tunnels' },
|
{ text: 'Relay серверы', icon: <SwapHoriz />, path: '/tunnels' },
|
||||||
|
{ text: 'Домены', icon: <Dns />, path: '/domains' },
|
||||||
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
|
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ export const nodesApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async detectLocation(url: string) {
|
||||||
|
const { data } = await api.post<{
|
||||||
|
ip?: string;
|
||||||
|
host?: string;
|
||||||
|
flag?: string;
|
||||||
|
country?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
}>('/nodes/detect-location', { url });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async syncFromMain() {
|
async syncFromMain() {
|
||||||
const { data } = await api.post<{ success: boolean; count: number }>(
|
const { data } = await api.post<{ success: boolean; count: number }>(
|
||||||
'/nodes/sync/main',
|
'/nodes/sync/main',
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
html {
|
||||||
|
font-family: "Inter", "Roboto", "Helvetica", "Arial", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.animated-container {
|
.animated-container {
|
||||||
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
|
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
|
||||||
background-size: 400% 400%;
|
background-size: 400% 400%;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import './index.css'
|
|||||||
import '@fontsource/inter/300.css';
|
import '@fontsource/inter/300.css';
|
||||||
import '@fontsource/inter/400.css';
|
import '@fontsource/inter/400.css';
|
||||||
import '@fontsource/inter/500.css';
|
import '@fontsource/inter/500.css';
|
||||||
|
import '@fontsource/inter/600.css';
|
||||||
import '@fontsource/inter/700.css';
|
import '@fontsource/inter/700.css';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function DomainsPage() {
|
|||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [newDomain, setNewDomain] = useState('');
|
const [newDomain, setNewDomain] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const emptyDomainsNotified = useRef(false);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
@@ -203,6 +204,13 @@ export default function DomainsPage() {
|
|||||||
|
|
||||||
setDomains(data.data);
|
setDomains(data.data);
|
||||||
setTotalCount(data.total);
|
setTotalCount(data.total);
|
||||||
|
if (data.total === 0 && !emptyDomainsNotified.current) {
|
||||||
|
emptyDomainsNotified.current = true;
|
||||||
|
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы один домен!' });
|
||||||
|
}
|
||||||
|
if (data.total > 0) {
|
||||||
|
emptyDomainsNotified.current = false;
|
||||||
|
}
|
||||||
Logger.debug(`Loaded ${data.data.length} domains (total: ${data.total})`, 'Domains');
|
Logger.debug(`Loaded ${data.data.length} domains (total: ${data.total})`, 'Domains');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('Failed to load', 'Domains', error);
|
Logger.error('Failed to load', 'Domains', error);
|
||||||
|
|||||||
+202
-31
@@ -1,14 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -30,49 +32,69 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Delete,
|
Delete,
|
||||||
Edit,
|
Edit,
|
||||||
Refresh,
|
|
||||||
Star,
|
Star,
|
||||||
StarBorder,
|
StarBorder,
|
||||||
Sync,
|
Sync,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import api from '../api';
|
||||||
import { nodesApi } from '../features/nodes/api';
|
import { nodesApi } from '../features/nodes/api';
|
||||||
import type { NodeAuthType, NodePayload, NodeRecord } from '../types/node';
|
import type { NodeAuthType, NodePayload, NodeRecord } from '../types/node';
|
||||||
|
import { getApiErrorMessage } from '../utils/errorHandlers';
|
||||||
|
import { FlagIcon, FlagOptionLabel } from '../utils/flags';
|
||||||
|
|
||||||
const emptyForm: NodePayload = {
|
const emptyForm: NodePayload = {
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
authType: 'password',
|
ip: '',
|
||||||
|
flag: '',
|
||||||
|
authType: 'token',
|
||||||
login: '',
|
login: '',
|
||||||
password: '',
|
password: '',
|
||||||
token: '',
|
token: '',
|
||||||
isMain: false,
|
isMain: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CountryOption {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidIp = (value?: string) =>
|
||||||
|
!!value &&
|
||||||
|
(/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(value.trim()) ||
|
||||||
|
/^([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}$/i.test(value.trim()));
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<NodeRecord | null>(null);
|
const [editing, setEditing] = useState<NodeRecord | null>(null);
|
||||||
const [form, setForm] = useState<NodePayload>(emptyForm);
|
const [form, setForm] = useState<NodePayload>(emptyForm);
|
||||||
const [checkingId, setCheckingId] = useState<string | null>(null);
|
const [checkingId, setCheckingId] = useState<string | null>(null);
|
||||||
|
const [countries, setCountries] = useState<CountryOption[]>([]);
|
||||||
|
const [detectingLocation, setDetectingLocation] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<NodeRecord | null>(null);
|
||||||
|
const [formErrors, setFormErrors] = useState<Partial<Record<keyof NodePayload, string>>>({});
|
||||||
const [message, setMessage] = useState({
|
const [message, setMessage] = useState({
|
||||||
open: false,
|
open: false,
|
||||||
type: 'success' as 'success' | 'error',
|
type: 'success' as 'success' | 'error',
|
||||||
text: '',
|
text: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainNode = useMemo(() => nodes.find((node) => node.isMain), [nodes]);
|
|
||||||
|
|
||||||
const loadNodes = useCallback(async () => {
|
const loadNodes = useCallback(async () => {
|
||||||
setNodes(await nodesApi.list());
|
const data = await nodesApi.list();
|
||||||
|
setNodes(Array.isArray(data) ? data : []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNodes();
|
loadNodes();
|
||||||
|
api.get<CountryOption[]>('/settings/countries').then((res) => setCountries(Array.isArray(res.data) ? res.data : []));
|
||||||
}, [loadNodes]);
|
}, [loadNodes]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
|
setFormErrors({});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,6 +103,8 @@ export default function NodesPage() {
|
|||||||
setForm({
|
setForm({
|
||||||
name: node.name,
|
name: node.name,
|
||||||
url: node.url || '',
|
url: node.url || '',
|
||||||
|
ip: node.ip || '',
|
||||||
|
flag: node.flag || '',
|
||||||
authType: node.authType,
|
authType: node.authType,
|
||||||
login: node.login || '',
|
login: node.login || '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -88,45 +112,103 @@ export default function NodesPage() {
|
|||||||
isMain: node.isMain,
|
isMain: node.isMain,
|
||||||
version: node.version || '',
|
version: node.version || '',
|
||||||
});
|
});
|
||||||
|
setFormErrors({});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateField = <K extends keyof NodePayload>(key: K, value: NodePayload[K]) => {
|
const updateField = <K extends keyof NodePayload>(key: K, value: NodePayload[K]) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setFormErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (requireSecrets = !editing) => {
|
||||||
|
const errors: Partial<Record<keyof NodePayload, string>> = {};
|
||||||
|
|
||||||
|
if (!form.name.trim()) errors.name = 'Введите название ноды';
|
||||||
|
if (!form.url.trim()) errors.url = 'Введите URL панели 3x-ui';
|
||||||
|
if (!isValidIp(form.ip)) errors.ip = 'Введите корректный IP ноды';
|
||||||
|
if (!form.flag) errors.flag = 'Выберите флаг ноды';
|
||||||
|
|
||||||
|
if (form.authType === 'password') {
|
||||||
|
if (!form.login?.trim()) errors.login = 'Введите логин';
|
||||||
|
if (requireSecrets && !form.password?.trim()) errors.password = 'Введите пароль';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.authType === 'token' && requireSecrets && !form.token?.trim()) {
|
||||||
|
errors.token = 'Введите токен';
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectNodeLocation = async () => {
|
||||||
|
const url = form.url.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
setDetectingLocation(true);
|
||||||
|
try {
|
||||||
|
const result = await nodesApi.detectLocation(url.replace(/\/+$/, ''));
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ip: result.ip || prev.ip,
|
||||||
|
flag: result.flag || prev.flag,
|
||||||
|
}));
|
||||||
|
if (result.country || result.ip) {
|
||||||
|
setMessage({
|
||||||
|
open: true,
|
||||||
|
type: 'success',
|
||||||
|
text: `Определено: ${result.country || 'страна неизвестна'}${result.ip ? `, IP ${result.ip}` : ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ open: true, type: 'error', text: 'Не удалось определить страну ноды' });
|
||||||
|
} finally {
|
||||||
|
setDetectingLocation(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveNode = async () => {
|
const saveNode = async () => {
|
||||||
if (!form.name.trim() || !form.url.trim()) {
|
if (!validateForm(!editing)) {
|
||||||
setMessage({ open: true, type: 'error', text: 'Укажите название и URL ноды' });
|
setMessage({ open: true, type: 'error', text: 'Заполните обязательные поля' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: NodePayload = {
|
const payload: NodePayload = {
|
||||||
...form,
|
...form,
|
||||||
url: form.url.replace(/\/+$/, ''),
|
url: form.url.replace(/\/+$/, ''),
|
||||||
|
ip: form.ip || undefined,
|
||||||
|
flag: form.flag || undefined,
|
||||||
login: form.authType === 'password' ? form.login : undefined,
|
login: form.authType === 'password' ? form.login : undefined,
|
||||||
password: form.authType === 'password' && form.password ? form.password : undefined,
|
password: form.authType === 'password' && form.password ? form.password : undefined,
|
||||||
token: form.authType === 'token' && form.token ? form.token : undefined,
|
token: form.authType === 'token' && form.token ? form.token : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editing) {
|
try {
|
||||||
await nodesApi.update(editing.id, payload);
|
if (editing) {
|
||||||
} else {
|
await nodesApi.update(editing.id, payload);
|
||||||
await nodesApi.create(payload);
|
} else {
|
||||||
}
|
await nodesApi.create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMessage({
|
setMessage({
|
||||||
open: true,
|
open: true,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
text: editing ? 'Нода обновлена' : 'Нода добавлена',
|
text: editing ? 'Нода обновлена' : 'Нода добавлена',
|
||||||
});
|
});
|
||||||
loadNodes();
|
loadNodes();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const text =
|
||||||
|
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
'Не удалось сохранить ноду';
|
||||||
|
setMessage({ open: true, type: 'error', text });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkFormConnection = async () => {
|
const checkFormConnection = async () => {
|
||||||
if (!form.name.trim() || !form.url.trim()) {
|
if (!validateForm(true)) {
|
||||||
setMessage({ open: true, type: 'error', text: 'Укажите название и URL ноды' });
|
setMessage({ open: true, type: 'error', text: 'Заполните обязательные поля для проверки подключения' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +252,22 @@ export default function NodesPage() {
|
|||||||
loadNodes();
|
loadNodes();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeNode = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await nodesApi.remove(deleteTarget.id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMessage({ open: true, type: 'success', text: 'Нода удалена' });
|
||||||
|
loadNodes();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
text: getApiErrorMessage(error, 'Ошибка удаления ноды'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, gap: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, gap: 2 }}>
|
||||||
@@ -178,9 +276,9 @@ export default function NodesPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<Button startIcon={<Sync />} variant="outlined" onClick={syncNodes}>
|
{/* <Button startIcon={<Sync />} variant="outlined" onClick={syncNodes}>
|
||||||
Синхронизировать
|
Синхронизировать
|
||||||
</Button>
|
</Button> */}
|
||||||
<Button startIcon={<Add />} variant="contained" onClick={openCreate}>
|
<Button startIcon={<Add />} variant="contained" onClick={openCreate}>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,6 +291,8 @@ export default function NodesPage() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Название</TableCell>
|
<TableCell>Название</TableCell>
|
||||||
|
<TableCell>Флаг</TableCell>
|
||||||
|
<TableCell>IP</TableCell>
|
||||||
<TableCell>URL панели</TableCell>
|
<TableCell>URL панели</TableCell>
|
||||||
<TableCell>Авторизация</TableCell>
|
<TableCell>Авторизация</TableCell>
|
||||||
<TableCell>Статус</TableCell>
|
<TableCell>Статус</TableCell>
|
||||||
@@ -211,17 +311,22 @@ export default function NodesPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{node.url}
|
<FlagIcon flag={node.flag} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{node.ip || '-'}</TableCell>
|
||||||
|
<TableCell>{node.url}</TableCell>
|
||||||
<TableCell>{node.authType}</TableCell>
|
<TableCell>{node.authType}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{node.isMain && <Chip icon={<CheckCircle />} label="Основная" color="success" size="small" />}
|
{node.isMain && <Chip icon={<CheckCircle />} label="Основная" color="success" size="small" />}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
|
{/* <IconButton disabled={checkingId === node.id} onClick={() => checkNode(node)}>
|
||||||
|
{checkingId === node.id ? <CircularProgress size={20} /> : <CheckCircle />}
|
||||||
|
</IconButton> */}
|
||||||
<IconButton onClick={() => openEdit(node)}>
|
<IconButton onClick={() => openEdit(node)}>
|
||||||
<Edit />
|
<Edit />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="error" onClick={() => nodesApi.remove(node.id).then(loadNodes)}>
|
<IconButton color="error" onClick={() => setDeleteTarget(node)}>
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -229,7 +334,7 @@ export default function NodesPage() {
|
|||||||
))}
|
))}
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary' }}>
|
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary' }}>
|
||||||
Ноды не добавлены
|
Ноды не добавлены
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -242,14 +347,55 @@ export default function NodesPage() {
|
|||||||
<DialogTitle>{editing ? 'Редактировать ноду' : 'Новая нода'}</DialogTitle>
|
<DialogTitle>{editing ? 'Редактировать ноду' : 'Новая нода'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
<TextField label="Название" value={form.name} onChange={(e) => updateField('name', e.target.value)} />
|
<TextField
|
||||||
|
label="Название"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
|
error={!!formErrors.name}
|
||||||
|
helperText={formErrors.name}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="URL панели 3x-ui"
|
label="URL панели 3x-ui"
|
||||||
helperText="Например: https://85.198.84.27:35366/2vIsDA5HanQ3R7JyIH"
|
required
|
||||||
|
helperText={formErrors.url || 'Например: https://85.198.84.27:35366/2vIsDA5HanQ3R7JyIH'}
|
||||||
value={form.url}
|
value={form.url}
|
||||||
onChange={(e) => updateField('url', e.target.value)}
|
onChange={(e) => updateField('url', e.target.value)}
|
||||||
|
onBlur={detectNodeLocation}
|
||||||
|
error={!!formErrors.url}
|
||||||
|
InputProps={{ endAdornment: detectingLocation ? <CircularProgress size={18} /> : undefined }}
|
||||||
/>
|
/>
|
||||||
<FormControl fullWidth>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="IP ноды"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={form.ip || ''}
|
||||||
|
onChange={(e) => updateField('ip', e.target.value)}
|
||||||
|
error={!!formErrors.ip}
|
||||||
|
helperText={formErrors.ip}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth required error={!!formErrors.flag}>
|
||||||
|
<InputLabel>Флаг</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.flag || ''}
|
||||||
|
label="Флаг"
|
||||||
|
onChange={(e) => updateField('flag', e.target.value)}
|
||||||
|
renderValue={(value) => (
|
||||||
|
<FlagOptionLabel flag={value} label={countries.find((country) => country.emoji === value)?.name || 'Флаг'} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Без флага</MenuItem>
|
||||||
|
{countries.map((country) => (
|
||||||
|
<MenuItem key={country.code} value={country.emoji}>
|
||||||
|
<FlagOptionLabel flag={country.emoji} code={country.code} label={country.name} />
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{formErrors.flag && <FormHelperText>{formErrors.flag}</FormHelperText>}
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
<FormControl fullWidth required>
|
||||||
<InputLabel>Тип авторизации</InputLabel>
|
<InputLabel>Тип авторизации</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={form.authType}
|
value={form.authType}
|
||||||
@@ -262,21 +408,35 @@ export default function NodesPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
{form.authType === 'password' ? (
|
{form.authType === 'password' ? (
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
<TextField label="Логин" fullWidth value={form.login || ''} onChange={(e) => updateField('login', e.target.value)} />
|
<TextField
|
||||||
|
label="Логин"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={form.login || ''}
|
||||||
|
onChange={(e) => updateField('login', e.target.value)}
|
||||||
|
error={!!formErrors.login}
|
||||||
|
helperText={formErrors.login}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={editing ? 'Новый пароль' : 'Пароль'}
|
label={editing ? 'Новый пароль' : 'Пароль'}
|
||||||
type="password"
|
type="password"
|
||||||
|
required={!editing}
|
||||||
fullWidth
|
fullWidth
|
||||||
value={form.password || ''}
|
value={form.password || ''}
|
||||||
onChange={(e) => updateField('password', e.target.value)}
|
onChange={(e) => updateField('password', e.target.value)}
|
||||||
|
error={!!formErrors.password}
|
||||||
|
helperText={formErrors.password || (editing ? 'Оставьте пустым, чтобы не менять пароль' : undefined)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
label={editing ? 'Новый токен' : 'Токен'}
|
label={editing ? 'Новый токен' : 'Токен'}
|
||||||
type="password"
|
type="password"
|
||||||
|
required={!editing}
|
||||||
value={form.token || ''}
|
value={form.token || ''}
|
||||||
onChange={(e) => updateField('token', e.target.value)}
|
onChange={(e) => updateField('token', e.target.value)}
|
||||||
|
error={!!formErrors.token}
|
||||||
|
helperText={formErrors.token || (editing ? 'Оставьте пустым, чтобы не менять токен' : undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
@@ -292,6 +452,17 @@ export default function NodesPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Удалить ноду?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>Вы уверены, что хотите удалить ноду {deleteTarget?.name}?</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||||
|
<Button color="error" variant="contained" onClick={removeNode}>Удалить</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Snackbar open={message.open} autoHideDuration={5000} onClose={() => setMessage({ ...message, open: false })}>
|
<Snackbar open={message.open} autoHideDuration={5000} onClose={() => setMessage({ ...message, open: false })}>
|
||||||
<Alert severity={message.type}>{message.text}</Alert>
|
<Alert severity={message.type}>{message.text}</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ interface Tunnel {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
domain?: string;
|
||||||
sshPort: number;
|
sshPort: number;
|
||||||
username: string;
|
username: string;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
@@ -49,6 +50,7 @@ interface Tunnel {
|
|||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
name: '',
|
name: '',
|
||||||
nodeId: '',
|
nodeId: '',
|
||||||
|
ip: '',
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
username: 'root',
|
username: 'root',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -74,7 +76,7 @@ export default function TunnelsPage() {
|
|||||||
title: '',
|
title: '',
|
||||||
confirmText: 'Подтвердить',
|
confirmText: 'Подтвердить',
|
||||||
confirmColor: 'primary' as 'primary' | 'error',
|
confirmColor: 'primary' as 'primary' | 'error',
|
||||||
onConfirm: () => { },
|
onConfirm: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -87,8 +89,8 @@ export default function TunnelsPage() {
|
|||||||
api.get<Tunnel[]>('/tunnels'),
|
api.get<Tunnel[]>('/tunnels'),
|
||||||
api.get<NodeRecord[]>('/nodes'),
|
api.get<NodeRecord[]>('/nodes'),
|
||||||
]);
|
]);
|
||||||
setTunnels(tunnelsRes.data);
|
setTunnels(Array.isArray(tunnelsRes.data) ? tunnelsRes.data : []);
|
||||||
setNodes(nodesRes.data);
|
setNodes(Array.isArray(nodesRes.data) ? nodesRes.data : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('Failed to load forwarding data', 'Tunnels', error);
|
Logger.error('Failed to load forwarding data', 'Tunnels', error);
|
||||||
}
|
}
|
||||||
@@ -98,14 +100,12 @@ export default function TunnelsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const getNodeAddress = (node?: NodeRecord) => {
|
const isValidIp = (value: string) =>
|
||||||
if (!node?.url) return '';
|
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(value.trim()) ||
|
||||||
try {
|
/^([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}$/i.test(value.trim());
|
||||||
return new URL(node.url).hostname;
|
const isValidDomain = (value: string) =>
|
||||||
} catch {
|
/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(value.trim());
|
||||||
return node.url;
|
const isValidAddress = (value: string) => isValidIp(value) || isValidDomain(value);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedNode = nodes.find((node) => node.id === form.nodeId) || mainNode;
|
const selectedNode = nodes.find((node) => node.id === form.nodeId) || mainNode;
|
||||||
|
|
||||||
@@ -114,6 +114,7 @@ export default function TunnelsPage() {
|
|||||||
|
|
||||||
if (!form.name.trim()) errors.name = 'Введите название relay сервера';
|
if (!form.name.trim()) errors.name = 'Введите название relay сервера';
|
||||||
if (!selectedNode) errors.nodeId = 'Добавьте или выберите ноду';
|
if (!selectedNode) errors.nodeId = 'Добавьте или выберите ноду';
|
||||||
|
if (!isValidAddress(form.ip)) errors.ip = 'Введите корректный IP или домен relay сервера';
|
||||||
if (!form.sshPort || form.sshPort < 1 || form.sshPort > 65535) {
|
if (!form.sshPort || form.sshPort < 1 || form.sshPort > 65535) {
|
||||||
errors.sshPort = 'Порт должен быть от 1 до 65535';
|
errors.sshPort = 'Порт должен быть от 1 до 65535';
|
||||||
}
|
}
|
||||||
@@ -137,6 +138,10 @@ export default function TunnelsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы одну ноду!' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
@@ -154,11 +159,15 @@ export default function TunnelsPage() {
|
|||||||
privateKey: authMethod === 'key' ? form.privateKey : undefined,
|
privateKey: authMethod === 'key' ? form.privateKey : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.post('/tunnels', payload);
|
try {
|
||||||
setOpen(false);
|
await api.post('/tunnels', payload);
|
||||||
resetForm();
|
setOpen(false);
|
||||||
loadData();
|
resetForm();
|
||||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер добавлен' });
|
loadData();
|
||||||
|
setSnackbar({ open: true, type: 'success', message: 'Relay сервер добавлен' });
|
||||||
|
} catch (error) {
|
||||||
|
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Не удалось добавить relay сервер') });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstall = (id: number) => {
|
const handleInstall = (id: number) => {
|
||||||
@@ -185,7 +194,7 @@ export default function TunnelsPage() {
|
|||||||
const handleDelete = (tunnel: Tunnel) => {
|
const handleDelete = (tunnel: Tunnel) => {
|
||||||
const deleteForwarding =
|
const deleteForwarding =
|
||||||
tunnel.isInstalled &&
|
tunnel.isInstalled &&
|
||||||
window.confirm('Удалить перенаправление на сервере через forwarding_delete.sh?');
|
window.confirm('Удалить перенаправление на сервере через forwarding_delete.sh (приведет сервер к первоначальному состоянию)? Нажмите Ок для подтверждения.');
|
||||||
|
|
||||||
setConfirmDialog({
|
setConfirmDialog({
|
||||||
open: true,
|
open: true,
|
||||||
@@ -197,9 +206,7 @@ export default function TunnelsPage() {
|
|||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
setLoadingId(tunnel.id);
|
setLoadingId(tunnel.id);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/tunnels/${tunnel.id}`, {
|
await api.delete(`/tunnels/${tunnel.id}`, { params: { deleteForwarding } });
|
||||||
params: { deleteForwarding },
|
|
||||||
});
|
|
||||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер удалён' });
|
setSnackbar({ open: true, type: 'success', message: 'Relay сервер удалён' });
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -215,11 +222,8 @@ export default function TunnelsPage() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||||
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
|
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
|
||||||
<Box>
|
<Box><Button variant="contained" startIcon={<Add />} onClick={openCreate}>Добавить</Button></Box>
|
||||||
<Button variant="contained" startIcon={<Add />} onClick={openCreate}>
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper sx={{ overflowX: 'auto' }}>
|
<Paper sx={{ overflowX: 'auto' }}>
|
||||||
@@ -241,7 +245,7 @@ export default function TunnelsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Dns fontSize="small" color="action" />
|
<Dns fontSize="small" color="action" />
|
||||||
{tunnel.ip}
|
{tunnel.domain || tunnel.ip}
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -284,61 +288,20 @@ export default function TunnelsPage() {
|
|||||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Новый relay сервер</DialogTitle>
|
<DialogTitle>Новый relay сервер</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField margin="dense" label="Название" required fullWidth value={form.name} onChange={handleChange('name')} error={!!formErrors.name} helperText={formErrors.name} />
|
||||||
margin="dense"
|
<FormControl fullWidth required margin="dense" error={!!formErrors.nodeId}>
|
||||||
label="Название"
|
|
||||||
fullWidth
|
|
||||||
value={form.name}
|
|
||||||
onChange={handleChange('name')}
|
|
||||||
error={!!formErrors.name}
|
|
||||||
helperText={formErrors.name}
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth margin="dense" error={!!formErrors.nodeId}>
|
|
||||||
<InputLabel>Нода</InputLabel>
|
<InputLabel>Нода</InputLabel>
|
||||||
<Select
|
<Select value={form.nodeId || mainNode?.id || ''} label="Нода" onChange={(event) => setForm((prev) => ({ ...prev, nodeId: event.target.value }))}>
|
||||||
value={form.nodeId || mainNode?.id || ''}
|
|
||||||
label="Нода"
|
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, nodeId: event.target.value }))}
|
|
||||||
>
|
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<MenuItem key={node.id} value={node.id}>
|
<MenuItem key={node.id} value={node.id}>{node.name}{node.isMain ? ' (основная)' : ''}</MenuItem>
|
||||||
{node.name}{node.isMain ? ' (основная)' : ''}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{formErrors.nodeId && (
|
{formErrors.nodeId && <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>{formErrors.nodeId}</Typography>}
|
||||||
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>
|
|
||||||
{formErrors.nodeId}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField
|
<TextField margin="dense" label="IP или домен relay сервера" required fullWidth value={form.ip} onChange={handleChange('ip')} error={!!formErrors.ip} helperText={formErrors.ip} />
|
||||||
margin="dense"
|
|
||||||
label="IP из URL ноды"
|
|
||||||
fullWidth
|
|
||||||
value={getNodeAddress(selectedNode)}
|
|
||||||
slotProps={{ input: { readOnly: true } }}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<TextField
|
<TextField margin="dense" label="SSH порт" required type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} error={!!formErrors.sshPort} helperText={formErrors.sshPort} />
|
||||||
margin="dense"
|
<TextField margin="dense" label="SSH пользователь" required fullWidth value={form.username} onChange={handleChange('username')} error={!!formErrors.username} helperText={formErrors.username} />
|
||||||
label="SSH порт"
|
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
value={form.sshPort}
|
|
||||||
onChange={handleChange('sshPort')}
|
|
||||||
error={!!formErrors.sshPort}
|
|
||||||
helperText={formErrors.sshPort}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
margin="dense"
|
|
||||||
label="SSH пользователь"
|
|
||||||
fullWidth
|
|
||||||
value={form.username}
|
|
||||||
onChange={handleChange('username')}
|
|
||||||
error={!!formErrors.username}
|
|
||||||
helperText={formErrors.username}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
|
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
|
||||||
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
|
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
|
||||||
@@ -348,30 +311,9 @@ export default function TunnelsPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{authMethod === 'password' ? (
|
{authMethod === 'password' ? (
|
||||||
<TextField
|
<TextField margin="dense" label="SSH пароль" required type="password" fullWidth value={form.password} onChange={handleChange('password')} error={!!formErrors.password} helperText={formErrors.password} />
|
||||||
margin="dense"
|
|
||||||
label="SSH пароль"
|
|
||||||
type="password"
|
|
||||||
fullWidth
|
|
||||||
value={form.password}
|
|
||||||
onChange={handleChange('password')}
|
|
||||||
error={!!formErrors.password}
|
|
||||||
helperText={formErrors.password}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField margin="dense" label="SSH private key" required multiline rows={4} fullWidth value={form.privateKey} onChange={handleChange('privateKey')} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }} error={!!formErrors.privateKey} helperText={formErrors.privateKey} />
|
||||||
margin="dense"
|
|
||||||
label="SSH private key"
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
fullWidth
|
|
||||||
value={form.privateKey}
|
|
||||||
onChange={handleChange('privateKey')}
|
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
||||||
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
|
|
||||||
error={!!formErrors.privateKey}
|
|
||||||
helperText={formErrors.privateKey}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -382,33 +324,15 @@ export default function TunnelsPage() {
|
|||||||
|
|
||||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||||
<DialogTitle>Подтверждение</DialogTitle>
|
<DialogTitle>Подтверждение</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent><Typography>{confirmDialog.title}</Typography></DialogContent>
|
||||||
<Typography>{confirmDialog.title}</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
||||||
<Button
|
<Button onClick={() => { setConfirmDialog({ ...confirmDialog, open: false }); confirmDialog.onConfirm(); }} variant="contained" color={confirmDialog.confirmColor}>{confirmDialog.confirmText}</Button>
|
||||||
onClick={() => {
|
|
||||||
setConfirmDialog({ ...confirmDialog, open: false });
|
|
||||||
confirmDialog.onConfirm();
|
|
||||||
}}
|
|
||||||
variant="contained"
|
|
||||||
color={confirmDialog.confirmColor}
|
|
||||||
>
|
|
||||||
{confirmDialog.confirmText}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar open={snackbar.open} autoHideDuration={6000} onClose={() => setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||||
open={snackbar.open}
|
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>{snackbar.message}</Alert>
|
||||||
autoHideDuration={6000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface NodeRecord {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
domain?: string;
|
||||||
|
ip?: string;
|
||||||
|
flag?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
protocol?: NodeProtocol;
|
protocol?: NodeProtocol;
|
||||||
authType: NodeAuthType;
|
authType: NodeAuthType;
|
||||||
@@ -19,6 +22,9 @@ export interface NodeRecord {
|
|||||||
export interface NodePayload {
|
export interface NodePayload {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
domain?: string;
|
||||||
|
ip?: string;
|
||||||
|
flag?: string;
|
||||||
authType: NodeAuthType;
|
authType: NodeAuthType;
|
||||||
login?: string;
|
login?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Box, Stack, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
const SPECIAL_CODES: Record<string, string> = {
|
||||||
|
ENGLAND: 'gb-eng',
|
||||||
|
SCOTLAND: 'gb-sct',
|
||||||
|
WALES: 'gb-wls',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeFlag = (flag?: string) => {
|
||||||
|
if (!flag) return '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(flag);
|
||||||
|
} catch {
|
||||||
|
return flag;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countryCodeFromFlag = (flag?: string) => {
|
||||||
|
const decoded = decodeFlag(flag);
|
||||||
|
const points = Array.from(decoded).map((char) => char.codePointAt(0) || 0);
|
||||||
|
|
||||||
|
if (points.length < 2) return undefined;
|
||||||
|
if (points[0] < 0x1f1e6 || points[0] > 0x1f1ff) return undefined;
|
||||||
|
if (points[1] < 0x1f1e6 || points[1] > 0x1f1ff) return undefined;
|
||||||
|
|
||||||
|
return String.fromCharCode(points[0] - 0x1f1e6 + 65, points[1] - 0x1f1e6 + 65);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCode = (code?: string) => {
|
||||||
|
if (!code) return undefined;
|
||||||
|
return SPECIAL_CODES[code.toUpperCase()] || code.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FlagIcon({
|
||||||
|
flag,
|
||||||
|
code,
|
||||||
|
size = 22,
|
||||||
|
}: {
|
||||||
|
flag?: string;
|
||||||
|
code?: string;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const normalizedCode = normalizeCode(code || countryCodeFromFlag(flag));
|
||||||
|
|
||||||
|
if (!normalizedCode) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: Math.round(size * 0.72),
|
||||||
|
borderRadius: 0.5,
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'inline-block',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`https://flagcdn.com/w40/${normalizedCode}.png`}
|
||||||
|
srcSet={`https://flagcdn.com/w80/${normalizedCode}.png 2x`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: Math.round(size * 0.72),
|
||||||
|
borderRadius: 0.5,
|
||||||
|
objectFit: 'cover',
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.12)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlagOptionLabel({
|
||||||
|
flag,
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
flag?: string;
|
||||||
|
code?: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack component="span" direction="row" spacing={1} alignItems="center">
|
||||||
|
<FlagIcon flag={flag} code={code} size={22} />
|
||||||
|
<Typography component="span" variant="body2">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '2.1.0';
|
export const APP_VERSION = '2.2.0';
|
||||||
|
|||||||
@@ -175,13 +175,13 @@ describe('SubscriptionsPage', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockGet).toHaveBeenCalledWith('/tunnels')
|
expect(mockGet).toHaveBeenCalledWith('/tunnels')
|
||||||
expect(mockGet).toHaveBeenCalledTimes(3)
|
expect(mockGet).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Селектор сервера', () => {
|
describe('Список подписок без выбора сервера', () => {
|
||||||
it('должен отображать селектор сервера при наличии туннелей', async () => {
|
it('не должен отображать верхний селектор сервера при наличии туннелей', async () => {
|
||||||
const mockTunnels = [
|
const mockTunnels = [
|
||||||
{ id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
|
{ id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
|
||||||
]
|
]
|
||||||
@@ -189,38 +189,7 @@ describe('SubscriptionsPage', () => {
|
|||||||
renderSubscriptionsPage()
|
renderSubscriptionsPage()
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const select = screen.getByRole('combobox')
|
expect(screen.queryByText('Основной сервер')).not.toBeInTheDocument()
|
||||||
expect(select).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('должен отображать иконку Dns для основного сервера', async () => {
|
|
||||||
const mockTunnels = [
|
|
||||||
{ id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
|
|
||||||
]
|
|
||||||
setupMockGet({ tunnels: mockTunnels, subscriptions: [] })
|
|
||||||
renderSubscriptionsPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Основной сервер')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('должен переключать выбранный сервер', async () => {
|
|
||||||
const mockTunnels = [
|
|
||||||
{ id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
|
|
||||||
]
|
|
||||||
setupMockGet({ tunnels: mockTunnels, subscriptions: [] })
|
|
||||||
renderSubscriptionsPage()
|
|
||||||
|
|
||||||
const select = await screen.findByRole('combobox')
|
|
||||||
fireEvent.mouseDown(select)
|
|
||||||
|
|
||||||
const tunnelOption = await screen.findByText('Tunnel 1')
|
|
||||||
fireEvent.click(tunnelOption)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Tunnel 1')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -273,7 +242,7 @@ describe('SubscriptionsPage', () => {
|
|||||||
fireEvent.click(createButton)
|
fireEvent.click(createButton)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Инбаунды (10/20)')).toBeInTheDocument()
|
expect(screen.getByText('Инбаунды (9/20)')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -317,7 +286,7 @@ describe('SubscriptionsPage', () => {
|
|||||||
fireEvent.click(createButton)
|
fireEvent.click(createButton)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Инбаунды (10/20)')).toBeInTheDocument()
|
expect(screen.getByText('Инбаунды (9/20)')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -345,7 +314,7 @@ describe('SubscriptionsPage', () => {
|
|||||||
fireEvent.click(addButton)
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Инбаунды (11/20)')).toBeInTheDocument()
|
expect(screen.getByText('Инбаунды (10/20)')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,7 +340,7 @@ describe('SubscriptionsPage', () => {
|
|||||||
const createButton = await screen.findByText('Создать')
|
const createButton = await screen.findByText('Создать')
|
||||||
fireEvent.click(createButton)
|
fireEvent.click(createButton)
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 11; i++) {
|
||||||
const addButton = screen.getByText('Добавить инбаунд')
|
const addButton = screen.getByText('Добавить инбаунд')
|
||||||
fireEvent.click(addButton)
|
fireEvent.click(addButton)
|
||||||
}
|
}
|
||||||
@@ -380,7 +349,7 @@ describe('SubscriptionsPage', () => {
|
|||||||
const addButton = screen.getByText('Добавить инбаунд')
|
const addButton = screen.getByText('Добавить инбаунд')
|
||||||
expect(addButton).toBeDisabled()
|
expect(addButton).toBeDisabled()
|
||||||
})
|
})
|
||||||
})
|
}, 15000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Сохранение подписки', () => {
|
describe('Сохранение подписки', () => {
|
||||||
|
|||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
name: 3dp-manager
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
@@ -8,7 +10,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql
|
- pg_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
|||||||
+27
-65
@@ -6,7 +6,7 @@ set -euo pipefail
|
|||||||
#################################
|
#################################
|
||||||
PROJECT_DIR="/opt/3dp-manager"
|
PROJECT_DIR="/opt/3dp-manager"
|
||||||
DOCKER_USER="denpiligrim"
|
DOCKER_USER="denpiligrim"
|
||||||
DOCKER_TAG="main"
|
DOCKER_TAG="dp-new-release"
|
||||||
IMAGE_SERVER="ghcr.io/${DOCKER_USER}/3dp-manager-server:${DOCKER_TAG}"
|
IMAGE_SERVER="ghcr.io/${DOCKER_USER}/3dp-manager-server:${DOCKER_TAG}"
|
||||||
IMAGE_CLIENT="ghcr.io/${DOCKER_USER}/3dp-manager-client:${DOCKER_TAG}"
|
IMAGE_CLIENT="ghcr.io/${DOCKER_USER}/3dp-manager-client:${DOCKER_TAG}"
|
||||||
|
|
||||||
@@ -96,6 +96,25 @@ get_random_port() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_previous_install_data() {
|
||||||
|
log "Checking for previous 3dp-manager installation data..."
|
||||||
|
|
||||||
|
if [[ -f docker-compose.yml ]]; then
|
||||||
|
warn "Existing docker-compose.yml found. Stopping previous installation and removing its volumes so newly generated credentials are used."
|
||||||
|
"${COMPOSE_CMD[@]}" down --volumes --remove-orphans || true
|
||||||
|
else
|
||||||
|
warn "docker-compose.yml not found. Removing known 3dp-manager containers if they still exist."
|
||||||
|
docker rm -f 3dp-postgres 3dp-backend 3dp-frontend >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
for volume in 3dp-manager_pg_data 3dpmanager_pg_data; do
|
||||||
|
if docker volume inspect "$volume" >/dev/null 2>&1; then
|
||||||
|
warn "Removing stale Postgres volume: $volume"
|
||||||
|
docker volume rm "$volume" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# ASCII-баннер
|
# ASCII-баннер
|
||||||
#################################
|
#################################
|
||||||
@@ -189,6 +208,7 @@ mkdir -p "$PROJECT_DIR/server"
|
|||||||
mkdir -p "$PROJECT_DIR/client"
|
mkdir -p "$PROJECT_DIR/client"
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
|
cleanup_previous_install_data
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# СБОР ДАННЫХ: SSL / HTTPS
|
# СБОР ДАННЫХ: SSL / HTTPS
|
||||||
@@ -349,64 +369,6 @@ else
|
|||||||
fi
|
fi
|
||||||
log "Сгенерированы секретные ключи для БД и JWT."
|
log "Сгенерированы секретные ключи для БД и JWT."
|
||||||
|
|
||||||
#################################
|
|
||||||
# Hysteria 2
|
|
||||||
#################################
|
|
||||||
|
|
||||||
# Проверка установки Hysteria 2 через наличие systemd сервиса
|
|
||||||
if ! systemctl cat hysteria-server.service &> /dev/null; then
|
|
||||||
echo "Сервис Hysteria 2 не найден. Начинаем установку..."
|
|
||||||
|
|
||||||
# Установка Hysteria 2 согласно документации
|
|
||||||
bash <(curl -fsSL https://get.hy2.sh/) < /dev/null || true
|
|
||||||
|
|
||||||
RANDOM_FREE_PORT=$(get_random_port 10000 20000)
|
|
||||||
|
|
||||||
# Генерация надежных паролей
|
|
||||||
GENERATED_PASSWORD=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | cut -c1-16)
|
|
||||||
GENERATED_OBFS_PASSWORD=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | cut -c1-16)
|
|
||||||
|
|
||||||
# Запрос данных у пользователя
|
|
||||||
echo "=== Настройка Hysteria 2 ==="
|
|
||||||
read -e -p "Введите email для уведомлений Let's Encrypt: " HYSTERIA_EMAIL
|
|
||||||
|
|
||||||
HYSTERIA_EMAIL=$(echo "$HYSTERIA_EMAIL" | tr -cd 'a-zA-Z0-9.@_-')
|
|
||||||
|
|
||||||
# Создание конфигурационного файла
|
|
||||||
cat > /etc/hysteria/config.yaml <<EOF
|
|
||||||
listen: :$RANDOM_FREE_PORT
|
|
||||||
|
|
||||||
acme:
|
|
||||||
domains:
|
|
||||||
- $UI_HOST
|
|
||||||
email: $HYSTERIA_EMAIL
|
|
||||||
|
|
||||||
auth:
|
|
||||||
type: password
|
|
||||||
password: $GENERATED_PASSWORD
|
|
||||||
|
|
||||||
obfs:
|
|
||||||
type: salamander
|
|
||||||
salamander:
|
|
||||||
password: $GENERATED_OBFS_PASSWORD
|
|
||||||
|
|
||||||
masquerade:
|
|
||||||
type: proxy
|
|
||||||
proxy:
|
|
||||||
url: https://ya.ru/
|
|
||||||
rewriteHost: true
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Перезапуск демона и включение сервиса для автозапуска
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now hysteria-server.service
|
|
||||||
systemctl restart hysteria-server.service
|
|
||||||
|
|
||||||
echo "Hysteria 2 успешно установлена и запущена на порту $RANDOM_FREE_PORT"
|
|
||||||
systemctl status hysteria-server.service --no-pager
|
|
||||||
else
|
|
||||||
echo "Hysteria 2 уже установлена, пропускаем установку."
|
|
||||||
fi
|
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
|
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
|
||||||
@@ -466,6 +428,8 @@ EOF
|
|||||||
|
|
||||||
# 2. Docker Compose
|
# 2. Docker Compose
|
||||||
cat > docker-compose.yml <<EOF
|
cat > docker-compose.yml <<EOF
|
||||||
|
name: 3dp-manager
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
@@ -476,7 +440,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DB_PASS}
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
POSTGRES_DB: 3dp_manager
|
POSTGRES_DB: 3dp_manager
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql
|
- pg_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
|
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -502,8 +466,6 @@ services:
|
|||||||
ADMIN_LOGIN: ${ADMIN_USER}
|
ADMIN_LOGIN: ${ADMIN_USER}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||||
PORT: 3100
|
PORT: 3100
|
||||||
volumes:
|
|
||||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
@@ -577,6 +539,8 @@ EOF
|
|||||||
|
|
||||||
# 2. Docker Compose
|
# 2. Docker Compose
|
||||||
cat > docker-compose.yml <<EOF
|
cat > docker-compose.yml <<EOF
|
||||||
|
name: 3dp-manager
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
@@ -587,7 +551,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DB_PASS}
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
POSTGRES_DB: 3dp_manager
|
POSTGRES_DB: 3dp_manager
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql
|
- pg_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
|
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -613,8 +577,6 @@ services:
|
|||||||
ADMIN_LOGIN: ${ADMIN_USER}
|
ADMIN_LOGIN: ${ADMIN_USER}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||||
PORT: 3100
|
PORT: 3100
|
||||||
volumes:
|
|
||||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "3dp-manager",
|
"name": "3dp-manager",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"description": "Inbound generator for 3x-ui",
|
"description": "Inbound generator for 3x-ui",
|
||||||
"private": false,
|
"private": false,
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { InboundBuilderService } from '../src/inbounds/inbound-builder.service';
|
||||||
|
import { XuiInboundRaw } from '../src/inbounds/xui-inbound.types';
|
||||||
|
import { Node } from '../src/nodes/entities/node.entity';
|
||||||
|
import { XuiService } from '../src/xui/xui.service';
|
||||||
|
|
||||||
|
const SNI = process.env.SMOKE_SNI || 'www.cloudflare.com';
|
||||||
|
|
||||||
|
type SmokeResult = {
|
||||||
|
type: string;
|
||||||
|
port: number;
|
||||||
|
success: boolean;
|
||||||
|
xuiId?: number | null;
|
||||||
|
deleted?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomPort = () => Math.floor(Math.random() * (60000 - 20000 + 1)) + 20000;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting inbound smoke test...');
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
console.log('Application context is ready.');
|
||||||
|
|
||||||
|
const nodeRepo = app.get<Repository<Node>>(getRepositoryToken(Node));
|
||||||
|
const xuiService = app.get(XuiService);
|
||||||
|
const builder = app.get(InboundBuilderService);
|
||||||
|
|
||||||
|
const node = await nodeRepo
|
||||||
|
.createQueryBuilder('node')
|
||||||
|
.addSelect('node.password')
|
||||||
|
.addSelect('node.token')
|
||||||
|
.where('node.isMain = :isMain', { isMain: true })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
throw new Error('Main node not found');
|
||||||
|
}
|
||||||
|
console.log(`Main node loaded: ${node.name}`);
|
||||||
|
|
||||||
|
const keys = await xuiService.getNewX25519Cert(node);
|
||||||
|
if (!keys) {
|
||||||
|
throw new Error('Could not get Reality keys from the main node');
|
||||||
|
}
|
||||||
|
console.log('Reality keys received.');
|
||||||
|
|
||||||
|
const buildCases: Array<{
|
||||||
|
type: string;
|
||||||
|
build: (port: number, uuid: string) => XuiInboundRaw;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: 'vless-tcp-reality',
|
||||||
|
build: (port, uuid) => builder.buildVlessRealityTcp({ port, uuid, sni: SNI, ...keys }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'vless-xhttp-reality',
|
||||||
|
build: (port, uuid) => builder.buildVlessRealityXhttp({ port, uuid, sni: SNI, ...keys }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'vless-grpc-reality',
|
||||||
|
build: (port, uuid) => builder.buildVlessRealityGrpc({ port, uuid, sni: SNI, ...keys }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'vless-ws',
|
||||||
|
build: (port, uuid) => builder.buildVlessWs({ port, uuid, sni: SNI }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'vmess-tcp',
|
||||||
|
build: (port, uuid) => builder.buildVmessTcp({ port, uuid }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'shadowsocks-tcp',
|
||||||
|
build: (port, uuid) => builder.buildShadowsocksTcp({ port, uuid }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'trojan-tcp-reality',
|
||||||
|
build: (port, uuid) => builder.buildTrojanRealityTcp({ port, uuid, sni: SNI, ...keys }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'hysteria2-udp',
|
||||||
|
build: (port, uuid) => builder.buildHysteria2Inbound({ port, uuid, sni: SNI }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results: SmokeResult[] = [];
|
||||||
|
|
||||||
|
for (const testCase of buildCases) {
|
||||||
|
const port = randomPort();
|
||||||
|
const uuid = uuidv4();
|
||||||
|
let xuiId: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Testing ${testCase.type} on port ${port}...`);
|
||||||
|
const config = testCase.build(port, uuid);
|
||||||
|
config.remark = `smoke-${testCase.type}-${Date.now()}`;
|
||||||
|
xuiId = await xuiService.addInbound(config, node);
|
||||||
|
|
||||||
|
if (xuiId) {
|
||||||
|
console.log(`${testCase.type}: created with xuiId=${xuiId}; deleting...`);
|
||||||
|
await xuiService.deleteInbound(xuiId, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
type: testCase.type,
|
||||||
|
port,
|
||||||
|
success: Boolean(xuiId),
|
||||||
|
xuiId,
|
||||||
|
deleted: Boolean(xuiId),
|
||||||
|
});
|
||||||
|
console.log(`${testCase.type}: ${xuiId ? 'ok' : 'failed'}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (xuiId) {
|
||||||
|
await xuiService.deleteInbound(xuiId, node);
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
type: testCase.type,
|
||||||
|
port,
|
||||||
|
success: false,
|
||||||
|
xuiId,
|
||||||
|
deleted: Boolean(xuiId),
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.table(results);
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
const failed = results.filter((result) => !result.success);
|
||||||
|
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -422,38 +422,87 @@ export class InboundBuilderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildHysteria2Inbound(params: { port: number; uuid: string; sni: string }) {
|
buildHysteria2Inbound(params: {
|
||||||
|
port: number;
|
||||||
|
uuid: string;
|
||||||
|
sni: string;
|
||||||
|
certificateFile?: string;
|
||||||
|
keyFile?: string;
|
||||||
|
}) {
|
||||||
const { port, uuid, sni } = params;
|
const { port, uuid, sni } = params;
|
||||||
|
const certificateFile =
|
||||||
|
params.certificateFile || `/root/cert/${sni}/fullchain.pem`;
|
||||||
|
const keyFile =
|
||||||
|
params.keyFile || `/root/cert/${sni}/privkey.pem`;
|
||||||
|
const obfsPassword = crypto.randomBytes(8).toString('hex');
|
||||||
return {
|
return {
|
||||||
enable: true,
|
enable: true,
|
||||||
port,
|
port,
|
||||||
protocol: 'hysteria2',
|
protocol: 'hysteria',
|
||||||
remark: 'hysteria2-udp',
|
remark: 'hysteria2-udp',
|
||||||
settings: JSON.stringify({
|
settings: JSON.stringify({
|
||||||
clients: [
|
clients: [
|
||||||
{
|
{
|
||||||
password: uuid,
|
auth: uuid,
|
||||||
email: uuid,
|
email: uuid,
|
||||||
enable: true,
|
enable: true,
|
||||||
limitIp: 0,
|
|
||||||
totalGB: 0,
|
|
||||||
expiryTime: 0,
|
|
||||||
reset: 0,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
masquerade: `${sni}:443`,
|
version: 2,
|
||||||
}),
|
}),
|
||||||
streamSettings: JSON.stringify({
|
streamSettings: JSON.stringify({
|
||||||
network: 'udp',
|
network: 'hysteria',
|
||||||
security: 'tls',
|
security: 'tls',
|
||||||
|
finalmask: {
|
||||||
|
udp: [
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
password: obfsPassword,
|
||||||
|
},
|
||||||
|
type: 'salamander',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hysteriaSettings: {
|
||||||
|
auth: uuid,
|
||||||
|
masquerade: {
|
||||||
|
content: '',
|
||||||
|
dir: '',
|
||||||
|
headers: {},
|
||||||
|
insecure: true,
|
||||||
|
rewriteHost: false,
|
||||||
|
statusCode: 0,
|
||||||
|
type: 'proxy',
|
||||||
|
url: 'https://google.com',
|
||||||
|
},
|
||||||
|
udpIdleTimeout: 60,
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
tlsSettings: {
|
tlsSettings: {
|
||||||
serverName: sni,
|
serverName: sni,
|
||||||
alpn: ['h3'],
|
alpn: ['h3'],
|
||||||
|
certificates: [
|
||||||
|
{
|
||||||
|
buildChain: false,
|
||||||
|
certificateFile,
|
||||||
|
keyFile,
|
||||||
|
oneTimeLoading: false,
|
||||||
|
usage: 'encipherment',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cipherSuites: '',
|
||||||
|
disableSystemRoot: false,
|
||||||
|
echForceQuery: 'none',
|
||||||
|
echServerKeys: '',
|
||||||
|
enableSessionResumption: false,
|
||||||
|
maxVersion: '1.3',
|
||||||
|
minVersion: '1.2',
|
||||||
|
rejectUnknownSni: false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
sniffing: JSON.stringify({
|
sniffing: JSON.stringify({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
destOverride: ['http', 'tls', 'quic'],
|
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||||
metadataOnly: false,
|
metadataOnly: false,
|
||||||
routeOnly: false,
|
routeOnly: false,
|
||||||
}),
|
}),
|
||||||
@@ -487,6 +536,7 @@ export class InboundBuilderService {
|
|||||||
case 'trojan':
|
case 'trojan':
|
||||||
link = this.buildTrojanLink(inbound, sni, idOrPass);
|
link = this.buildTrojanLink(inbound, sni, idOrPass);
|
||||||
break;
|
break;
|
||||||
|
case 'hysteria':
|
||||||
case 'hysteria2':
|
case 'hysteria2':
|
||||||
link = this.buildHysteria2PanelLink(inbound, sni, idOrPass, flagEmoji);
|
link = this.buildHysteria2PanelLink(inbound, sni, idOrPass, flagEmoji);
|
||||||
break;
|
break;
|
||||||
@@ -645,13 +695,38 @@ export class InboundBuilderService {
|
|||||||
) {
|
) {
|
||||||
const stream = JSON.parse(inbound.streamSettings) as {
|
const stream = JSON.parse(inbound.streamSettings) as {
|
||||||
tlsSettings?: { serverName?: string };
|
tlsSettings?: { serverName?: string };
|
||||||
|
finalmask?: { udp?: Array<{ type?: string; settings?: { password?: string } }> };
|
||||||
};
|
};
|
||||||
|
const settings = JSON.parse(inbound.settings) as {
|
||||||
|
clients?: Array<{ auth?: string; password?: string }>;
|
||||||
|
};
|
||||||
|
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
|
||||||
|
const finalmask = stream.finalmask?.udp?.[0];
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('insecure', '0');
|
params.set('insecure', '1');
|
||||||
|
params.set('security', 'tls');
|
||||||
|
params.set('fp', 'chrome');
|
||||||
|
params.set('alpn', 'h3');
|
||||||
|
|
||||||
|
const fmConfig = {
|
||||||
|
udp: [
|
||||||
|
{
|
||||||
|
type: finalmask.type,
|
||||||
|
settings: {
|
||||||
|
password: finalmask.settings.password,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
params.set('fm', JSON.stringify(fmConfig));
|
||||||
params.set('sni', stream.tlsSettings?.serverName || serverAddress);
|
params.set('sni', stream.tlsSettings?.serverName || serverAddress);
|
||||||
|
if (finalmask?.type) params.set('obfs', finalmask.type);
|
||||||
|
if (finalmask?.settings?.password) {
|
||||||
|
params.set('obfs-password', finalmask.settings.password);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`hy2://${password}@${serverAddress}:${inbound.port}/?${params.toString()}` +
|
`hy2://${auth}@${serverAddress}:${inbound.port}/?${params.toString()}` +
|
||||||
`#${flagEmoji}%20${encodeURIComponent(inbound.remark || '')}`
|
`#${flagEmoji}%20${encodeURIComponent(inbound.remark || '')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -694,11 +769,28 @@ export class InboundBuilderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('insecure', '0');
|
params.set('insecure', '1');
|
||||||
|
params.set('security', 'tls');
|
||||||
|
params.set('fp', 'chrome');
|
||||||
|
params.set('alpn', 'h3');
|
||||||
|
|
||||||
params.set('sni', serverAddress);
|
params.set('sni', serverAddress);
|
||||||
|
|
||||||
params.set('obfs', obfs);
|
params.set('obfs', obfs);
|
||||||
params.set('obfs-password', obfsPass);
|
params.set('obfs-password', obfsPass);
|
||||||
|
|
||||||
|
const fmConfig = {
|
||||||
|
udp: [
|
||||||
|
{
|
||||||
|
type: obfs,
|
||||||
|
settings: {
|
||||||
|
password: obfsPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
params.set('fm', JSON.stringify(fmConfig));
|
||||||
|
|
||||||
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
|
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddNodeIpFlagAndInboundLabels1770000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddNodeIpFlagAndInboundLabels1770000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "node"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ip" character varying,
|
||||||
|
ADD COLUMN IF NOT EXISTS "flag" character varying
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "node" DROP COLUMN IF EXISTS "flag"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "node" DROP COLUMN IF EXISTS "ip"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsIP,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -17,6 +18,18 @@ export class CreateNodeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
url: string;
|
url: string;
|
||||||
|
|
||||||
|
@IsIP()
|
||||||
|
@IsOptional()
|
||||||
|
ip?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
domain?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
flag?: string;
|
||||||
|
|
||||||
@IsEnum(NodeAuthType)
|
@IsEnum(NodeAuthType)
|
||||||
authType: NodeAuthType;
|
authType: NodeAuthType;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export class Node {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
host?: string;
|
host?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
domain?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
ip?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
flag?: string;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: true })
|
@Column({ type: 'int', nullable: true })
|
||||||
port?: number;
|
port?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export class NodesController {
|
|||||||
return this.nodesService.checkPayload(dto);
|
return this.nodesService.checkPayload(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('detect-location')
|
||||||
|
detectLocation(@Body() body: { url: string }) {
|
||||||
|
return this.nodesService.detectLocation(body.url);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateNodeDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateNodeDto) {
|
||||||
return this.nodesService.update(id, dto);
|
return this.nodesService.update(id, dto);
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { Node } from './entities/node.entity';
|
|||||||
import { NodesController } from './nodes.controller';
|
import { NodesController } from './nodes.controller';
|
||||||
import { NodesService } from './nodes.service';
|
import { NodesService } from './nodes.service';
|
||||||
import { XuiModule } from '../xui/xui.module';
|
import { XuiModule } from '../xui/xui.module';
|
||||||
|
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||||
|
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||||
|
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Node]), XuiModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Node, Subscription, Tunnel, Inbound]),
|
||||||
|
XuiModule,
|
||||||
|
],
|
||||||
controllers: [NodesController],
|
controllers: [NodesController],
|
||||||
providers: [NodesService],
|
providers: [NodesService],
|
||||||
exports: [NodesService, TypeOrmModule],
|
exports: [NodesService, TypeOrmModule],
|
||||||
|
|||||||
@@ -8,12 +8,34 @@ import { Repository } from 'typeorm';
|
|||||||
import { CreateNodeDto, UpdateNodeDto } from './dto/node.dto';
|
import { CreateNodeDto, UpdateNodeDto } from './dto/node.dto';
|
||||||
import { Node, NodeAuthType, NodeProtocol } from './entities/node.entity';
|
import { Node, NodeAuthType, NodeProtocol } from './entities/node.entity';
|
||||||
import { XuiService } from '../xui/xui.service';
|
import { XuiService } from '../xui/xui.service';
|
||||||
|
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||||
|
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||||
|
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||||
|
import * as dns from 'dns/promises';
|
||||||
|
import * as net from 'net';
|
||||||
|
import { COUNTRIES } from '../settings/countries';
|
||||||
|
|
||||||
|
type GeoResult = {
|
||||||
|
ip: string;
|
||||||
|
country?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
flag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDomainFromHost = (host?: string) =>
|
||||||
|
host && net.isIP(host) === 0 ? host : undefined;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NodesService {
|
export class NodesService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Node)
|
@InjectRepository(Node)
|
||||||
private readonly nodesRepo: Repository<Node>,
|
private readonly nodesRepo: Repository<Node>,
|
||||||
|
@InjectRepository(Subscription)
|
||||||
|
private readonly subscriptionsRepo: Repository<Subscription>,
|
||||||
|
@InjectRepository(Tunnel)
|
||||||
|
private readonly tunnelsRepo: Repository<Tunnel>,
|
||||||
|
@InjectRepository(Inbound)
|
||||||
|
private readonly inboundsRepo: Repository<Inbound>,
|
||||||
private readonly xuiService: XuiService,
|
private readonly xuiService: XuiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -47,10 +69,17 @@ export class NodesService {
|
|||||||
|
|
||||||
async create(dto: CreateNodeDto) {
|
async create(dto: CreateNodeDto) {
|
||||||
this.assertCredentials(dto);
|
this.assertCredentials(dto);
|
||||||
|
const resolved = await this.resolveNodeLocation(dto.url, dto.flag, dto.ip);
|
||||||
|
|
||||||
const node = this.nodesRepo.create({
|
const node = this.nodesRepo.create({
|
||||||
...dto,
|
...dto,
|
||||||
url: this.normalizeUrl(dto.url),
|
url: this.normalizeUrl(dto.url),
|
||||||
|
host: resolved.host,
|
||||||
|
domain: dto.domain || resolved.domain,
|
||||||
|
port: resolved.port,
|
||||||
|
protocol: resolved.protocol,
|
||||||
|
ip: resolved.ip,
|
||||||
|
flag: resolved.flag,
|
||||||
isMain: dto.isMain ?? false,
|
isMain: dto.isMain ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +116,21 @@ export class NodesService {
|
|||||||
Object.assign(node, dto);
|
Object.assign(node, dto);
|
||||||
if (dto.url) {
|
if (dto.url) {
|
||||||
node.url = this.normalizeUrl(dto.url);
|
node.url = this.normalizeUrl(dto.url);
|
||||||
|
const resolved = await this.resolveNodeLocation(
|
||||||
|
dto.url,
|
||||||
|
dto.flag ?? node.flag,
|
||||||
|
dto.ip,
|
||||||
|
);
|
||||||
|
node.host = resolved.host;
|
||||||
|
node.domain = dto.domain || resolved.domain;
|
||||||
|
node.port = resolved.port;
|
||||||
|
node.protocol = resolved.protocol;
|
||||||
|
node.ip = resolved.ip;
|
||||||
|
node.flag = resolved.flag;
|
||||||
|
} else {
|
||||||
|
if (dto.domain !== undefined) node.domain = dto.domain;
|
||||||
|
if (dto.ip) node.ip = dto.ip;
|
||||||
|
if (dto.flag) node.flag = dto.flag;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.isMain) {
|
if (dto.isMain) {
|
||||||
@@ -99,14 +143,17 @@ export class NodesService {
|
|||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
const node = await this.findOneWithSecrets(id);
|
const node = await this.findOneWithSecrets(id);
|
||||||
if (node.isMain && (await this.nodesRepo.count()) > 1) {
|
const nodeCount = await this.nodesRepo.count();
|
||||||
throw new BadRequestException('Select another main node before deleting');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
await this.cleanupNodeDependencies(node, nodeCount === 1);
|
||||||
await this.nodesRepo.remove(node);
|
await this.nodesRepo.remove(node);
|
||||||
|
|
||||||
const main = await this.getDefaultNode();
|
const main = await this.getDefaultNode();
|
||||||
if (!main) {
|
if (!main) {
|
||||||
const fallback = await this.nodesRepo.findOne({ where: {} });
|
const fallback = await this.nodesRepo.findOne({
|
||||||
|
where: {},
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
fallback.isMain = true;
|
fallback.isMain = true;
|
||||||
await this.nodesRepo.save(fallback);
|
await this.nodesRepo.save(fallback);
|
||||||
@@ -116,6 +163,65 @@ export class NodesService {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanupNodeDependencies(node: Node, isLastNode: boolean) {
|
||||||
|
await this.deleteNodeInbounds(node);
|
||||||
|
const id = node.id;
|
||||||
|
|
||||||
|
if (isLastNode) {
|
||||||
|
await this.subscriptionsRepo.createQueryBuilder().delete().execute();
|
||||||
|
await this.tunnelsRepo.createQueryBuilder().delete().execute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tunnelsRepo.delete({ nodeId: id });
|
||||||
|
await this.inboundsRepo.delete({ nodeId: id });
|
||||||
|
|
||||||
|
const subscriptions = await this.subscriptionsRepo.find({
|
||||||
|
where: [{ nodeId: id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
sub.nodeId = undefined;
|
||||||
|
sub.node = undefined;
|
||||||
|
await this.subscriptionsRepo.save(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredSubscriptions = await this.subscriptionsRepo.find();
|
||||||
|
for (const sub of configuredSubscriptions) {
|
||||||
|
const config = sub.inboundsConfig || [];
|
||||||
|
const nextConfig = config.map((item) => {
|
||||||
|
if (item.nodeId !== id) return item;
|
||||||
|
const { nodeId: _nodeId, relayServerId: _relayServerId, ...rest } = item;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (JSON.stringify(nextConfig) !== JSON.stringify(config)) {
|
||||||
|
sub.inboundsConfig = nextConfig;
|
||||||
|
await this.subscriptionsRepo.save(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteNodeInbounds(node: Node) {
|
||||||
|
const inbounds = await this.inboundsRepo.find({
|
||||||
|
where: { nodeId: node.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const inbound of inbounds) {
|
||||||
|
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||||
|
const isDeleted = await this.xuiService.deleteInbound(
|
||||||
|
inbound.xuiId,
|
||||||
|
node,
|
||||||
|
);
|
||||||
|
if (!isDeleted) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Failed to delete inbound ${inbound.xuiId} from 3x-ui`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setMain(id: string) {
|
async setMain(id: string) {
|
||||||
const node = await this.findOneWithSecrets(id);
|
const node = await this.findOneWithSecrets(id);
|
||||||
await this.clearMainNode(id);
|
await this.clearMainNode(id);
|
||||||
@@ -164,7 +270,12 @@ export class NodesService {
|
|||||||
name: item.name || item.host,
|
name: item.name || item.host,
|
||||||
url,
|
url,
|
||||||
host: item.host,
|
host: item.host,
|
||||||
|
domain: getDomainFromHost(item.host),
|
||||||
port: item.port,
|
port: item.port,
|
||||||
|
ip: await this.resolveIp(item.host),
|
||||||
|
flag: (
|
||||||
|
await this.lookupGeo(await this.resolveIp(item.host))
|
||||||
|
)?.flag,
|
||||||
protocol:
|
protocol:
|
||||||
item.protocol === NodeProtocol.Http
|
item.protocol === NodeProtocol.Http
|
||||||
? NodeProtocol.Http
|
? NodeProtocol.Http
|
||||||
@@ -217,6 +328,107 @@ export class NodesService {
|
|||||||
return { success: status.success, version: status.version };
|
return { success: status.success, version: status.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async detectLocation(url: string) {
|
||||||
|
const resolved = await this.resolveNodeLocation(url);
|
||||||
|
return {
|
||||||
|
ip: resolved.ip,
|
||||||
|
host: resolved.host,
|
||||||
|
domain: resolved.domain,
|
||||||
|
port: resolved.port,
|
||||||
|
protocol: resolved.protocol,
|
||||||
|
flag: resolved.flag,
|
||||||
|
country: resolved.country,
|
||||||
|
countryCode: resolved.countryCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveNodeLocation(
|
||||||
|
url: string,
|
||||||
|
preferredFlag?: string,
|
||||||
|
preferredIp?: string,
|
||||||
|
) {
|
||||||
|
const normalized = this.normalizeUrl(url);
|
||||||
|
const parsed = this.parseUrl(normalized);
|
||||||
|
const ip = preferredIp || (await this.resolveIp(parsed.host));
|
||||||
|
const geo = ip ? await this.lookupGeo(ip) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
domain: getDomainFromHost(parsed.host),
|
||||||
|
ip,
|
||||||
|
country: geo?.country,
|
||||||
|
countryCode: geo?.countryCode,
|
||||||
|
flag: preferredFlag || geo?.flag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return {
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parsed.port ? Number(parsed.port) : undefined,
|
||||||
|
protocol:
|
||||||
|
parsed.protocol.replace(':', '') === NodeProtocol.Http
|
||||||
|
? NodeProtocol.Http
|
||||||
|
: NodeProtocol.Https,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { host: url, port: undefined, protocol: NodeProtocol.Https };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveIp(host?: string) {
|
||||||
|
if (!host || host === 'localhost') return undefined;
|
||||||
|
if (net.isIP(host) !== 0) return host;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dns.lookup(host);
|
||||||
|
return result.address;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupGeo(ip?: string): Promise<GeoResult | undefined> {
|
||||||
|
if (!ip || ip === '127.0.0.1') return undefined;
|
||||||
|
|
||||||
|
const fromCode = (countryCode?: string, country?: string) => {
|
||||||
|
const countryInfo = COUNTRIES.find((c) => c.code === countryCode);
|
||||||
|
return countryInfo
|
||||||
|
? { ip, country: countryInfo.name, countryCode, flag: countryInfo.emoji }
|
||||||
|
: { ip, country, countryCode };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://ipwho.is/${ip}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
success?: boolean;
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
|
};
|
||||||
|
if (data.success !== false) {
|
||||||
|
return fromCode(data.country_code, data.country);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback below.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://ip-api.com/json/${ip}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
};
|
||||||
|
if (data.status === 'success') {
|
||||||
|
return fromCode(data.countryCode, data.country);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeUrl(url: string) {
|
private normalizeUrl(url: string) {
|
||||||
return url.trim().replace(/\/+$/, '');
|
return url.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class RotationService implements OnModuleInit {
|
|||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isAutoRotationEnabled: true,
|
isAutoRotationEnabled: true,
|
||||||
},
|
},
|
||||||
relations: ['inbounds', 'node', 'relayServer'],
|
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||||
});
|
});
|
||||||
if (subscriptions.length === 0) {
|
if (subscriptions.length === 0) {
|
||||||
return { success: false, message: 'Нет активных подписок для ротации' };
|
return { success: false, message: 'Нет активных подписок для ротации' };
|
||||||
@@ -156,7 +156,13 @@ export class RotationService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const sub of subscriptions) {
|
for (const sub of subscriptions) {
|
||||||
await this.rotateSubscription(sub, domains, defaultNode);
|
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||||
|
if (!rotated) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to delete old inbounds',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Ротация завершена.');
|
this.logger.debug('Ротация завершена.');
|
||||||
@@ -174,7 +180,16 @@ export class RotationService implements OnModuleInit {
|
|||||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||||
for (const inbound of sub.inbounds) {
|
for (const inbound of sub.inbounds) {
|
||||||
if (inbound.xuiId && inbound.xuiId > 0) {
|
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||||
await this.xuiService.deleteInbound(inbound.xuiId, inbound.node);
|
const isDeleted = await this.xuiService.deleteInbound(
|
||||||
|
inbound.xuiId,
|
||||||
|
await this.resolveInboundNode(inbound),
|
||||||
|
);
|
||||||
|
if (!isDeleted) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete old inbound ${inbound.xuiId}; rotation for subscription ${sub.id} is aborted`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.inboundRepo.delete(inbound.id);
|
await this.inboundRepo.delete(inbound.id);
|
||||||
}
|
}
|
||||||
@@ -186,7 +201,7 @@ export class RotationService implements OnModuleInit {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
'Не удалось получить Reality ключи, пропускаем подписку',
|
'Не удалось получить Reality ключи, пропускаем подписку',
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usedPorts = new Set<number>();
|
const usedPorts = new Set<number>();
|
||||||
@@ -196,7 +211,7 @@ export class RotationService implements OnModuleInit {
|
|||||||
const flag = await this.settingRepo.findOne({
|
const flag = await this.settingRepo.findOne({
|
||||||
where: { key: 'xui_geo_flag' },
|
where: { key: 'xui_geo_flag' },
|
||||||
});
|
});
|
||||||
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
const defaultFlagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||||
|
|
||||||
// Получаем конфиг или пустой массив
|
// Получаем конфиг или пустой массив
|
||||||
const inboundsConfig = sub.inboundsConfig || [];
|
const inboundsConfig = sub.inboundsConfig || [];
|
||||||
@@ -209,15 +224,20 @@ export class RotationService implements OnModuleInit {
|
|||||||
sub.node,
|
sub.node,
|
||||||
defaultNode,
|
defaultNode,
|
||||||
);
|
);
|
||||||
const relayServer = await this.resolveRelay(
|
const resolvedRelay = await this.resolveRelay(
|
||||||
config.relayServerId,
|
config.relayServerId,
|
||||||
sub.relayServer,
|
sub.relayServer,
|
||||||
);
|
);
|
||||||
|
const relayServer =
|
||||||
|
resolvedRelay && this.isRelayAvailableForNode(resolvedRelay, targetNode)
|
||||||
|
? resolvedRelay
|
||||||
|
: undefined;
|
||||||
const targetAddress =
|
const targetAddress =
|
||||||
relayServer?.domain ||
|
relayServer?.domain ||
|
||||||
relayServer?.ip ||
|
relayServer?.ip ||
|
||||||
this.getNodeAddress(targetNode) ||
|
this.getNodeAddress(targetNode) ||
|
||||||
serverAddress;
|
serverAddress;
|
||||||
|
const flagEmoji = config.flag || targetNode?.flag || defaultFlagEmoji;
|
||||||
|
|
||||||
let sni = '';
|
let sni = '';
|
||||||
|
|
||||||
@@ -250,33 +270,39 @@ export class RotationService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
usedPorts.add(port);
|
usedPorts.add(port);
|
||||||
|
|
||||||
|
const hysteriaSni = this.getNodeAddress(targetNode) || serverAddress;
|
||||||
const hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
|
const hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
|
||||||
port,
|
port,
|
||||||
uuid,
|
uuid,
|
||||||
sni,
|
sni: hysteriaSni,
|
||||||
|
certificateFile: config.certificateFile,
|
||||||
|
keyFile: config.keyFile,
|
||||||
});
|
});
|
||||||
|
if (config.name?.trim()) {
|
||||||
|
hysteriaConfig.remark = config.name.trim();
|
||||||
|
}
|
||||||
const xuiId = await this.xuiService.addInbound(
|
const xuiId = await this.xuiService.addInbound(
|
||||||
hysteriaConfig,
|
hysteriaConfig,
|
||||||
targetNode,
|
targetNode,
|
||||||
);
|
);
|
||||||
const link =
|
if (!xuiId) {
|
||||||
xuiId && hysteriaConfig
|
this.logger.warn(
|
||||||
? this.inboundBuilder.buildInboundLink(
|
'Hysteria2 inbound was not created by 3x-ui; skipping subscription link for this inbound',
|
||||||
hysteriaConfig,
|
);
|
||||||
targetAddress,
|
continue;
|
||||||
uuid,
|
}
|
||||||
flagEmoji,
|
|
||||||
)
|
const link = this.inboundBuilder.buildInboundLink(
|
||||||
: this.inboundBuilder.buildHysteria2Link(
|
hysteriaConfig,
|
||||||
targetAddress,
|
targetAddress,
|
||||||
sni,
|
uuid,
|
||||||
flagEmoji + '%20hysteria2-udp',
|
flagEmoji,
|
||||||
);
|
);
|
||||||
const newInbound = this.inboundRepo.create({
|
const newInbound = this.inboundRepo.create({
|
||||||
xuiId: xuiId || 0,
|
xuiId,
|
||||||
port,
|
port,
|
||||||
protocol: 'hysteria2',
|
protocol: 'hysteria2',
|
||||||
remark: 'hysteria2-udp',
|
remark: hysteriaConfig.remark,
|
||||||
link: link,
|
link: link,
|
||||||
subscription: sub,
|
subscription: sub,
|
||||||
node: targetNode,
|
node: targetNode,
|
||||||
@@ -350,6 +376,10 @@ export class RotationService implements OnModuleInit {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.name?.trim()) {
|
||||||
|
xuiConfig.remark = config.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
|
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
|
||||||
|
|
||||||
if (xuiId && xuiConfig) {
|
if (xuiId && xuiConfig) {
|
||||||
@@ -379,6 +409,8 @@ export class RotationService implements OnModuleInit {
|
|||||||
await this.inboundRepo.save(newInbound);
|
await this.inboundRepo.save(newInbound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickDomain(list: Domain[]): string {
|
private pickDomain(list: Domain[]): string {
|
||||||
@@ -413,7 +445,7 @@ export class RotationService implements OnModuleInit {
|
|||||||
|
|
||||||
const sub = await this.subRepo.findOne({
|
const sub = await this.subRepo.findOne({
|
||||||
where: { id: subscriptionId },
|
where: { id: subscriptionId },
|
||||||
relations: ['inbounds', 'node', 'relayServer'],
|
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sub) {
|
if (!sub) {
|
||||||
@@ -437,7 +469,13 @@ export class RotationService implements OnModuleInit {
|
|||||||
return { success: false, message: 'Список доменов пуст!' };
|
return { success: false, message: 'Список доменов пуст!' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.rotateSubscription(sub, domains, defaultNode);
|
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||||
|
if (!rotated) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to delete old inbounds',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
|
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
|
||||||
return { success: true, message: 'Ротация успешно выполнена' };
|
return { success: true, message: 'Ротация успешно выполнена' };
|
||||||
@@ -472,18 +510,33 @@ export class RotationService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
|
private async resolveInboundNode(inbound: Inbound) {
|
||||||
if (!relayServerId) return subscriptionRelay ?? undefined;
|
if (!inbound.nodeId) return inbound.node;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ??
|
(await this.nodeRepo
|
||||||
subscriptionRelay ??
|
.createQueryBuilder('node')
|
||||||
undefined
|
.addSelect('node.password')
|
||||||
|
.addSelect('node.token')
|
||||||
|
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||||
|
.getOne()) ?? inbound.node
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
|
||||||
|
if (!relayServerId) return subscriptionRelay ?? undefined;
|
||||||
|
return (await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRelayAvailableForNode(relay: Tunnel, node?: Node) {
|
||||||
|
if (!relay.nodeId) return true;
|
||||||
|
return Boolean(node?.id && relay.nodeId === node.id);
|
||||||
|
}
|
||||||
|
|
||||||
private getNodeAddress(node?: Node) {
|
private getNodeAddress(node?: Node) {
|
||||||
if (!node) return undefined;
|
if (!node) return undefined;
|
||||||
|
if (node.domain) return node.domain;
|
||||||
|
if (node.ip) return node.ip;
|
||||||
if (node.host) return node.host;
|
if (node.host) return node.host;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export class SettingsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('countries')
|
||||||
|
countries() {
|
||||||
|
return COUNTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('check')
|
@Post('check')
|
||||||
async checkConnection(
|
async checkConnection(
|
||||||
@Body() body: { xui_url: string; xui_login: string; xui_password: string },
|
@Body() body: { xui_url: string; xui_login: string; xui_password: string },
|
||||||
|
|||||||
@@ -9,14 +9,45 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
|
ValidateBy,
|
||||||
|
ValidationOptions,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
const PORT_OR_RANDOM = 'portOrRandom';
|
||||||
|
|
||||||
|
function IsPortOrRandom(validationOptions?: ValidationOptions) {
|
||||||
|
return ValidateBy(
|
||||||
|
{
|
||||||
|
name: PORT_OR_RANDOM,
|
||||||
|
validator: {
|
||||||
|
validate: (value: unknown) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value === 'random') return true;
|
||||||
|
const port =
|
||||||
|
typeof value === 'number'
|
||||||
|
? value
|
||||||
|
: typeof value === 'string' && /^\d+$/.test(value)
|
||||||
|
? Number(value)
|
||||||
|
: NaN;
|
||||||
|
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||||
|
},
|
||||||
|
defaultMessage: () =>
|
||||||
|
'port must be "random" or an integer from 1 to 65535',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validationOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class InboundConfigDto {
|
export class InboundConfigDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsPortOrRandom()
|
||||||
port?: number | string;
|
port?: number | string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -35,6 +66,22 @@ export class InboundConfigDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
relayServerId?: number;
|
relayServerId?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
flag?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
certificateFile?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
keyFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateSubscriptionDto {
|
export class CreateSubscriptionDto {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export class Subscription {
|
|||||||
link?: string;
|
link?: string;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
relayServerId?: number;
|
relayServerId?: number;
|
||||||
|
flag?: string;
|
||||||
|
name?: string;
|
||||||
|
certificateFile?: string;
|
||||||
|
keyFile?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Subscription } from './entities/subscription.entity';
|
import { Subscription } from './entities/subscription.entity';
|
||||||
@@ -8,6 +8,7 @@ import { UpdateSubscriptionDto } from './dto/update-subscription.dto';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { Node } from '../nodes/entities/node.entity';
|
import { Node } from '../nodes/entities/node.entity';
|
||||||
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||||
|
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
@@ -29,6 +30,7 @@ export class SubscriptionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateSubscriptionDto) {
|
async create(dto: CreateSubscriptionDto) {
|
||||||
|
await this.validateInboundsConfig(dto.inboundsConfig);
|
||||||
const sub = this.subRepo.create({
|
const sub = this.subRepo.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
uuid: uuidv4(),
|
uuid: uuidv4(),
|
||||||
@@ -57,6 +59,7 @@ export class SubscriptionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.inboundsConfig) {
|
if (dto.inboundsConfig) {
|
||||||
|
await this.validateInboundsConfig(dto.inboundsConfig);
|
||||||
sub.inboundsConfig = dto.inboundsConfig;
|
sub.inboundsConfig = dto.inboundsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +89,18 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||||
for (const inbound of sub.inbounds) {
|
for (const inbound of sub.inbounds) {
|
||||||
await this.xuiService.deleteInbound(inbound.xuiId, inbound.node);
|
if (!inbound.xuiId || inbound.xuiId <= 0) continue;
|
||||||
|
|
||||||
|
const isDeleted = await this.xuiService.deleteInbound(
|
||||||
|
inbound.xuiId,
|
||||||
|
await this.resolveInboundNode(inbound),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isDeleted) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Failed to delete inbound ${inbound.xuiId} from 3x-ui`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +109,97 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
private async resolveNode(nodeId?: string | null) {
|
private async resolveNode(nodeId?: string | null) {
|
||||||
if (!nodeId) return null;
|
if (!nodeId) return null;
|
||||||
return this.nodeRepo
|
const node = await this.nodeRepo
|
||||||
.createQueryBuilder('node')
|
.createQueryBuilder('node')
|
||||||
.addSelect('node.password')
|
.addSelect('node.password')
|
||||||
.addSelect('node.token')
|
.addSelect('node.token')
|
||||||
.where('node.id = :nodeId', { nodeId })
|
.where('node.id = :nodeId', { nodeId })
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
throw new BadRequestException('Node not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveInboundNode(inbound: Inbound) {
|
||||||
|
if (!inbound.nodeId) return undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await this.nodeRepo
|
||||||
|
.createQueryBuilder('node')
|
||||||
|
.addSelect('node.password')
|
||||||
|
.addSelect('node.token')
|
||||||
|
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||||
|
.getOne()) ?? inbound.node
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveRelay(relayServerId?: number | null) {
|
private async resolveRelay(relayServerId?: number | null) {
|
||||||
if (!relayServerId) return null;
|
if (!relayServerId) return null;
|
||||||
return this.tunnelRepo.findOne({ where: { id: relayServerId } });
|
const relay = await this.tunnelRepo.findOne({
|
||||||
|
where: { id: relayServerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!relay) {
|
||||||
|
throw new BadRequestException('Relay server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return relay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateInboundsConfig(
|
||||||
|
inboundsConfig?: CreateSubscriptionDto['inboundsConfig'],
|
||||||
|
) {
|
||||||
|
for (const config of inboundsConfig || []) {
|
||||||
|
if (config.type === 'custom') continue;
|
||||||
|
|
||||||
|
if (config.nodeId) {
|
||||||
|
const node = await this.nodeRepo.findOne({
|
||||||
|
where: { id: config.nodeId },
|
||||||
|
});
|
||||||
|
if (!node) {
|
||||||
|
throw new BadRequestException('Node not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.relayServerId) {
|
||||||
|
const relay = await this.tunnelRepo.findOne({
|
||||||
|
where: { id: config.relayServerId },
|
||||||
|
});
|
||||||
|
if (!relay) {
|
||||||
|
throw new BadRequestException('Relay server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.nodeId && relay.nodeId && relay.nodeId !== config.nodeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Relay server belongs to another node',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.port === undefined ||
|
||||||
|
config.port === null ||
|
||||||
|
config.port === '' ||
|
||||||
|
config.port === 'random'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port =
|
||||||
|
typeof config.port === 'number'
|
||||||
|
? config.port
|
||||||
|
: /^\d+$/.test(config.port)
|
||||||
|
? Number(config.port)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Port must be "random" or an integer from 1 to 65535',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export class CreateTunnelDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
ip: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { Tunnel } from './entities/tunnel.entity';
|
|||||||
import { Setting } from '../settings/entities/setting.entity';
|
import { Setting } from '../settings/entities/setting.entity';
|
||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
import { Node } from '../nodes/entities/node.entity';
|
import { Node } from '../nodes/entities/node.entity';
|
||||||
|
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node])],
|
imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node, Subscription])],
|
||||||
controllers: [TunnelsController],
|
controllers: [TunnelsController],
|
||||||
providers: [TunnelsService, SshService],
|
providers: [TunnelsService, SshService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { Tunnel } from './entities/tunnel.entity';
|
|||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
import { Setting } from '../settings/entities/setting.entity';
|
import { Setting } from '../settings/entities/setting.entity';
|
||||||
import { Node } from '../nodes/entities/node.entity';
|
import { Node } from '../nodes/entities/node.entity';
|
||||||
|
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||||
import { CreateTunnelDto } from './dto/create-tunnel.dto';
|
import { CreateTunnelDto } from './dto/create-tunnel.dto';
|
||||||
|
import * as net from 'net';
|
||||||
|
import * as dns from 'dns/promises';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TunnelsService {
|
export class TunnelsService {
|
||||||
@@ -15,10 +18,14 @@ export class TunnelsService {
|
|||||||
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
|
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
|
||||||
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
|
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
|
||||||
@InjectRepository(Node) private nodeRepo: Repository<Node>,
|
@InjectRepository(Node) private nodeRepo: Repository<Node>,
|
||||||
|
@InjectRepository(Subscription)
|
||||||
|
private subscriptionRepo: Repository<Subscription>,
|
||||||
private sshService: SshService,
|
private sshService: SshService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(createTunnelDto: CreateTunnelDto) {
|
async create(createTunnelDto: CreateTunnelDto) {
|
||||||
|
const address = await this.resolveRelayAddress(createTunnelDto.ip);
|
||||||
|
|
||||||
const node = createTunnelDto.nodeId
|
const node = createTunnelDto.nodeId
|
||||||
? await this.nodeRepo.findOne({ where: { id: createTunnelDto.nodeId } })
|
? await this.nodeRepo.findOne({ where: { id: createTunnelDto.nodeId } })
|
||||||
: await this.nodeRepo.findOne({ where: { isMain: true } });
|
: await this.nodeRepo.findOne({ where: { isMain: true } });
|
||||||
@@ -27,20 +34,19 @@ export class TunnelsService {
|
|||||||
throw new HttpException('Node not found', HttpStatus.BAD_REQUEST);
|
throw new HttpException('Node not found', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = this.getNodeAddress(node);
|
const tunnelPayload = {
|
||||||
if (!ip) {
|
|
||||||
throw new HttpException(
|
|
||||||
'Cannot determine relay IP from node URL',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tunnel = this.tunnelRepo.create({
|
|
||||||
...createTunnelDto,
|
...createTunnelDto,
|
||||||
ip,
|
ip: address.ip,
|
||||||
node,
|
node,
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const domain = createTunnelDto.domain || address.domain;
|
||||||
|
if (domain) {
|
||||||
|
Object.assign(tunnelPayload, { domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnel = this.tunnelRepo.create(tunnelPayload);
|
||||||
return this.tunnelRepo.save(tunnel);
|
return this.tunnelRepo.save(tunnel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +59,37 @@ export class TunnelsService {
|
|||||||
await this.uninstallScript(id);
|
await this.uninstallScript(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.cleanupRelayDependencies(id);
|
||||||
return this.tunnelRepo.delete(id);
|
return this.tunnelRepo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanupRelayDependencies(id: number) {
|
||||||
|
const subscriptions = await this.subscriptionRepo.find({
|
||||||
|
where: [{ relayServerId: id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
sub.relayServerId = undefined;
|
||||||
|
sub.relayServer = undefined;
|
||||||
|
await this.subscriptionRepo.save(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredSubscriptions = await this.subscriptionRepo.find();
|
||||||
|
for (const sub of configuredSubscriptions) {
|
||||||
|
const config = sub.inboundsConfig || [];
|
||||||
|
const nextConfig = config.map((item) => {
|
||||||
|
if (item.relayServerId !== id) return item;
|
||||||
|
const { relayServerId: _relayServerId, ...rest } = item;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (JSON.stringify(nextConfig) !== JSON.stringify(config)) {
|
||||||
|
sub.inboundsConfig = nextConfig;
|
||||||
|
await this.subscriptionRepo.save(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async installScript(id: number) {
|
async installScript(id: number) {
|
||||||
const tunnel = await this.tunnelRepo
|
const tunnel = await this.tunnelRepo
|
||||||
.createQueryBuilder('tunnel')
|
.createQueryBuilder('tunnel')
|
||||||
@@ -79,7 +113,10 @@ export class TunnelsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mainServerIp =
|
const mainServerIp =
|
||||||
targetNode?.host || this.getNodeAddress(targetNode) || hostSetting.value;
|
targetNode?.ip ||
|
||||||
|
targetNode?.host ||
|
||||||
|
this.getNodeAddress(targetNode) ||
|
||||||
|
hostSetting?.value;
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`,
|
`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`,
|
||||||
@@ -166,4 +203,42 @@ export class TunnelsService {
|
|||||||
return node.url;
|
return node.url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveRelayAddress(value: string) {
|
||||||
|
const address = value.trim();
|
||||||
|
if (!address) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Relay server address is required',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (net.isIP(address) !== 0) {
|
||||||
|
return { ip: address, domain: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isValidHostname(address)) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Relay server address is invalid',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dns.lookup(address);
|
||||||
|
return { ip: result.address, domain: address };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
'Relay server domain cannot be resolved',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidHostname(value: string) {
|
||||||
|
if (value.length > 253) return false;
|
||||||
|
return /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
|
import * as http from 'http';
|
||||||
import { Setting } from '../settings/entities/setting.entity';
|
import { Setting } from '../settings/entities/setting.entity';
|
||||||
import {
|
import {
|
||||||
XuiResponse,
|
XuiResponse,
|
||||||
@@ -29,7 +30,7 @@ export class XuiService {
|
|||||||
) {
|
) {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
proxy: false,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,11 +62,20 @@ export class XuiService {
|
|||||||
return axios.create({
|
return axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
proxy: false,
|
||||||
|
...this.getAgentConfig(baseURL),
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getAgentConfig(baseURL?: string) {
|
||||||
|
if (!baseURL || baseURL.startsWith('https://')) {
|
||||||
|
return { httpsAgent: new https.Agent({ rejectUnauthorized: false }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { httpAgent: new http.Agent() };
|
||||||
|
}
|
||||||
|
|
||||||
private async createAuthenticatedApi(node?: Node): Promise<AxiosInstance | null> {
|
private async createAuthenticatedApi(node?: Node): Promise<AxiosInstance | null> {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
const success = await this.login();
|
const success = await this.login();
|
||||||
@@ -121,6 +131,9 @@ export class XuiService {
|
|||||||
|
|
||||||
this.logger.log(`Attempting login to 3x-ui: ${config['xui_url']}`);
|
this.logger.log(`Attempting login to 3x-ui: ${config['xui_url']}`);
|
||||||
this.api.defaults.baseURL = config['xui_url'];
|
this.api.defaults.baseURL = config['xui_url'];
|
||||||
|
const agentConfig = this.getAgentConfig(config['xui_url']);
|
||||||
|
this.api.defaults.httpAgent = agentConfig.httpAgent;
|
||||||
|
this.api.defaults.httpsAgent = agentConfig.httpsAgent;
|
||||||
|
|
||||||
const res = await this.api.post<LoginResponse>('/login', {
|
const res = await this.api.post<LoginResponse>('/login', {
|
||||||
username: config['xui_login'],
|
username: config['xui_login'],
|
||||||
@@ -211,16 +224,35 @@ export class XuiService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteInbound(id: number, node?: Node) {
|
async deleteInbound(id: number, node?: Node): Promise<boolean> {
|
||||||
|
if (!id || id <= 0) {
|
||||||
|
this.logger.debug(`Skipping 3x-ui inbound deletion for non-remote id: ${id}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = await this.createAuthenticatedApi(node);
|
const api = await this.createAuthenticatedApi(node);
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
await api.post(`/panel/api/inbounds/del/${id}`);
|
this.logger.error(`3x-ui authentication failed before deleting inbound ${id}`);
|
||||||
this.logger.debug(`Инбаунд ${id} удален`);
|
return false;
|
||||||
|
}
|
||||||
|
const res = await api.post<XuiResponse<unknown>>(
|
||||||
|
`/panel/api/inbounds/del/${id}`,
|
||||||
|
);
|
||||||
|
if (!res.data?.success) {
|
||||||
|
this.logger.error(
|
||||||
|
`3x-ui rejected inbound deletion ${id}: ${res.data?.msg || 'unknown error'}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Inbound ${id} deleted`);
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as AxiosError;
|
const error = e as AxiosError;
|
||||||
this.logger.error(`Ошибка удаления инбаунда ${id}: ${error.message}`);
|
this.logger.error(`Ошибка удаления инбаунда ${id}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkConnection(
|
async checkConnection(
|
||||||
@@ -234,7 +266,8 @@ export class XuiService {
|
|||||||
const tempApi = axios.create({
|
const tempApi = axios.create({
|
||||||
baseURL: url,
|
baseURL: url,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
proxy: false,
|
||||||
|
...this.getAgentConfig(url),
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,50 @@ describe('InboundBuilderService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildHysteria2Inbound', () => {
|
||||||
|
it('creates 3x-ui hysteria v2 inbound with certificate paths', () => {
|
||||||
|
const result = service.buildHysteria2Inbound({
|
||||||
|
port: 34443,
|
||||||
|
uuid: 'test-auth',
|
||||||
|
sni: 'oil.3dp-manager.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
enable: true,
|
||||||
|
listen: '0.0.0.0',
|
||||||
|
port: 34443,
|
||||||
|
protocol: 'hysteria',
|
||||||
|
tag: 'inbound-34443',
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = JSON.parse(result.settings);
|
||||||
|
const streamSettings = JSON.parse(result.streamSettings);
|
||||||
|
|
||||||
|
expect(settings.clients[0].auth).toBe('test-auth');
|
||||||
|
expect(settings.version).toBe(2);
|
||||||
|
expect(streamSettings.network).toBe('hysteria');
|
||||||
|
expect(streamSettings.hysteriaSettings.version).toBe(2);
|
||||||
|
expect(streamSettings.finalmask.udp[0].type).toBe('salamander');
|
||||||
|
expect(streamSettings.tlsSettings.certificates[0].certificateFile).toBe(
|
||||||
|
'/etc/letsencrypt/live/oil.3dp-manager.com/fullchain.pem',
|
||||||
|
);
|
||||||
|
expect(streamSettings.tlsSettings.certificates[0].keyFile).toBe(
|
||||||
|
'/etc/letsencrypt/live/oil.3dp-manager.com/privkey.pem',
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = service.buildInboundLink(
|
||||||
|
result as any,
|
||||||
|
'relay.example.com',
|
||||||
|
'fallback-auth',
|
||||||
|
'%F0%9F%92%AF',
|
||||||
|
);
|
||||||
|
expect(link).toContain('hy2://test-auth@relay.example.com:34443/');
|
||||||
|
expect(link).toContain('sni=oil.3dp-manager.com');
|
||||||
|
expect(link).toContain('obfs=salamander');
|
||||||
|
expect(link).toContain('obfs-password=abcd1234');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildInboundLink', () => {
|
describe('buildInboundLink', () => {
|
||||||
const baseInbound = {
|
const baseInbound = {
|
||||||
protocol: 'vless',
|
protocol: 'vless',
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { NodesService } from 'src/nodes/nodes.service';
|
||||||
|
import { Node } from 'src/nodes/entities/node.entity';
|
||||||
|
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
||||||
|
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||||
|
import { Inbound } from 'src/inbounds/entities/inbound.entity';
|
||||||
|
import { XuiService } from 'src/xui/xui.service';
|
||||||
|
|
||||||
|
describe('NodesService', () => {
|
||||||
|
const createNodeRepo = (getOne: jest.Mock) => ({
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
getOne,
|
||||||
|
})),
|
||||||
|
count: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createService = (nodeRepo: ReturnType<typeof createNodeRepo>) => {
|
||||||
|
const subscriptionsRepo = {
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn().mockResolvedValue({}),
|
||||||
|
})),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const tunnelsRepo = {
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn().mockResolvedValue({}),
|
||||||
|
})),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
const inboundsRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
const xuiService = {
|
||||||
|
deleteInbound: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new NodesService(
|
||||||
|
nodeRepo as unknown as Repository<Node>,
|
||||||
|
subscriptionsRepo as unknown as Repository<Subscription>,
|
||||||
|
tunnelsRepo as unknown as Repository<Tunnel>,
|
||||||
|
inboundsRepo as unknown as Repository<Inbound>,
|
||||||
|
xuiService as unknown as XuiService,
|
||||||
|
),
|
||||||
|
subscriptionsRepo,
|
||||||
|
tunnelsRepo,
|
||||||
|
inboundsRepo,
|
||||||
|
xuiService,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('deletes the main node and makes the next node main', async () => {
|
||||||
|
const mainNode = { id: 'main', isMain: true } as Node;
|
||||||
|
const nextNode = { id: 'next', isMain: false } as Node;
|
||||||
|
const getOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(mainNode)
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
const nodeRepo = createNodeRepo(getOne);
|
||||||
|
nodeRepo.count.mockResolvedValue(2);
|
||||||
|
nodeRepo.findOne.mockResolvedValue(nextNode);
|
||||||
|
nodeRepo.save.mockResolvedValue(nextNode);
|
||||||
|
const { service } = createService(nodeRepo);
|
||||||
|
|
||||||
|
const result = await service.remove('main');
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(nodeRepo.remove).toHaveBeenCalledWith(mainNode);
|
||||||
|
expect(nodeRepo.findOne).toHaveBeenCalledWith({
|
||||||
|
where: {},
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
expect(nextNode.isMain).toBe(true);
|
||||||
|
expect(nodeRepo.save).toHaveBeenCalledWith(nextNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses node credentials when deleting node inbounds', async () => {
|
||||||
|
const mainNode = { id: 'main', isMain: true } as Node;
|
||||||
|
const getOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(mainNode)
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
const nodeRepo = createNodeRepo(getOne);
|
||||||
|
nodeRepo.count.mockResolvedValue(1);
|
||||||
|
nodeRepo.findOne.mockResolvedValue(null);
|
||||||
|
const { service, inboundsRepo, xuiService } = createService(nodeRepo);
|
||||||
|
inboundsRepo.find.mockResolvedValue([{ id: 1, xuiId: 101, nodeId: 'main' }]);
|
||||||
|
|
||||||
|
await service.remove('main');
|
||||||
|
|
||||||
|
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, mainNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,8 @@ import { Domain } from 'src/domains/entities/domain.entity';
|
|||||||
import { Setting } from 'src/settings/entities/setting.entity';
|
import { Setting } from 'src/settings/entities/setting.entity';
|
||||||
import { XuiService } from 'src/xui/xui.service';
|
import { XuiService } from 'src/xui/xui.service';
|
||||||
import { InboundBuilderService } from 'src/inbounds/inbound-builder.service';
|
import { InboundBuilderService } from 'src/inbounds/inbound-builder.service';
|
||||||
|
import { Node } from 'src/nodes/entities/node.entity';
|
||||||
|
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||||
|
|
||||||
// Mock @nestjs/schedule для тестирования Cron
|
// Mock @nestjs/schedule для тестирования Cron
|
||||||
jest.mock('@nestjs/schedule', () => ({
|
jest.mock('@nestjs/schedule', () => ({
|
||||||
@@ -64,6 +66,19 @@ describe('RotationService', () => {
|
|||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockNodeRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
getOne: jest.fn(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTunnelRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const mockXuiService = {
|
const mockXuiService = {
|
||||||
login: jest.fn(),
|
login: jest.fn(),
|
||||||
deleteInbound: jest.fn(),
|
deleteInbound: jest.fn(),
|
||||||
@@ -79,6 +94,7 @@ describe('RotationService', () => {
|
|||||||
buildVmessTcp: jest.fn(),
|
buildVmessTcp: jest.fn(),
|
||||||
buildShadowsocksTcp: jest.fn(),
|
buildShadowsocksTcp: jest.fn(),
|
||||||
buildTrojanRealityTcp: jest.fn(),
|
buildTrojanRealityTcp: jest.fn(),
|
||||||
|
buildHysteria2Inbound: jest.fn(),
|
||||||
buildHysteria2Link: jest.fn(),
|
buildHysteria2Link: jest.fn(),
|
||||||
buildInboundLink: jest.fn(),
|
buildInboundLink: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -103,6 +119,14 @@ describe('RotationService', () => {
|
|||||||
provide: getRepositoryToken(Setting),
|
provide: getRepositoryToken(Setting),
|
||||||
useValue: mockSettingRepo,
|
useValue: mockSettingRepo,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Node),
|
||||||
|
useValue: mockNodeRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Tunnel),
|
||||||
|
useValue: mockTunnelRepo,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: XuiService,
|
provide: XuiService,
|
||||||
useValue: mockXuiService,
|
useValue: mockXuiService,
|
||||||
@@ -129,6 +153,10 @@ describe('RotationService', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockXuiService.deleteInbound.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe('onModuleInit', () => {
|
describe('onModuleInit', () => {
|
||||||
it('должен инициализировать настройки по умолчанию', async () => {
|
it('должен инициализировать настройки по умолчанию', async () => {
|
||||||
mockSettingRepo.findOne.mockResolvedValue(null);
|
mockSettingRepo.findOne.mockResolvedValue(null);
|
||||||
@@ -437,7 +465,7 @@ describe('RotationService', () => {
|
|||||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
|
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, undefined);
|
||||||
expect(inboundRepo.delete).toHaveBeenCalled();
|
expect(inboundRepo.delete).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -468,6 +496,7 @@ describe('RotationService', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
uuid: 'uuid-1',
|
uuid: 'uuid-1',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
|
node: { id: 'node-1', domain: 'node.example.com' },
|
||||||
inbounds: [],
|
inbounds: [],
|
||||||
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
||||||
};
|
};
|
||||||
@@ -475,15 +504,59 @@ describe('RotationService', () => {
|
|||||||
mockDomainRepo.find.mockResolvedValue([
|
mockDomainRepo.find.mockResolvedValue([
|
||||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||||
]);
|
]);
|
||||||
mockInboundBuilder.buildHysteria2Link.mockReturnValue('hy2://link');
|
mockXuiService.getNewX25519Cert.mockResolvedValue({
|
||||||
|
privateKey: 'key',
|
||||||
|
publicKey: 'pub',
|
||||||
|
});
|
||||||
|
mockInboundBuilder.buildHysteria2Inbound.mockReturnValue({
|
||||||
|
protocol: 'hysteria2',
|
||||||
|
remark: 'hysteria2-udp',
|
||||||
|
settings: '{"clients":[{"password":"uuid"}]}',
|
||||||
|
streamSettings: '{"tlsSettings":{"serverName":"ya.ru"}}',
|
||||||
|
sniffing: '{}',
|
||||||
|
});
|
||||||
|
mockXuiService.addInbound.mockResolvedValue(101);
|
||||||
|
mockInboundBuilder.buildInboundLink.mockReturnValue('hy2://link');
|
||||||
mockInboundRepo.save.mockResolvedValue({});
|
mockInboundRepo.save.mockResolvedValue({});
|
||||||
|
|
||||||
await (service as any).rotateSubscription(mockSub, [
|
await (service as any).rotateSubscription(mockSub, [
|
||||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(xuiService.addInbound).not.toHaveBeenCalled();
|
expect(xuiService.addInbound).toHaveBeenCalled();
|
||||||
expect(inboundBuilder.buildHysteria2Link).toHaveBeenCalled();
|
expect(inboundBuilder.buildHysteria2Inbound).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sni: 'node.example.com' }),
|
||||||
|
);
|
||||||
|
expect(inboundBuilder.buildInboundLink).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save hysteria2 when 3x-ui does not create an inbound', async () => {
|
||||||
|
const mockSub = {
|
||||||
|
id: '1',
|
||||||
|
uuid: 'uuid-1',
|
||||||
|
isEnabled: true,
|
||||||
|
inbounds: [],
|
||||||
|
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockXuiService.getNewX25519Cert.mockResolvedValue({
|
||||||
|
privateKey: 'key',
|
||||||
|
publicKey: 'pub',
|
||||||
|
});
|
||||||
|
mockInboundBuilder.buildHysteria2Inbound.mockReturnValue({
|
||||||
|
protocol: 'hysteria2',
|
||||||
|
remark: 'hysteria2-udp',
|
||||||
|
settings: '{"clients":[{"password":"uuid"}]}',
|
||||||
|
streamSettings: '{"tlsSettings":{"serverName":"ya.ru"}}',
|
||||||
|
sniffing: '{}',
|
||||||
|
});
|
||||||
|
mockXuiService.addInbound.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await (service as any).rotateSubscription(mockSub, [
|
||||||
|
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(inboundRepo.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('должен использовать случайный порт, если указано random', async () => {
|
it('должен использовать случайный порт, если указано random', async () => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
|
|||||||
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
||||||
import { XuiService } from 'src/xui/xui.service';
|
import { XuiService } from 'src/xui/xui.service';
|
||||||
import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto';
|
import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto';
|
||||||
|
import { Node } from 'src/nodes/entities/node.entity';
|
||||||
|
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||||
|
|
||||||
describe('SubscriptionsService', () => {
|
describe('SubscriptionsService', () => {
|
||||||
let service: SubscriptionsService;
|
let service: SubscriptionsService;
|
||||||
@@ -23,6 +25,19 @@ describe('SubscriptionsService', () => {
|
|||||||
remove: jest.fn(),
|
remove: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockNodeRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
getOne: jest.fn(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTunnelRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const mockXuiService = {
|
const mockXuiService = {
|
||||||
deleteInbound: jest.fn(),
|
deleteInbound: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -35,6 +50,14 @@ describe('SubscriptionsService', () => {
|
|||||||
provide: getRepositoryToken(Subscription),
|
provide: getRepositoryToken(Subscription),
|
||||||
useValue: mockSubRepo,
|
useValue: mockSubRepo,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Node),
|
||||||
|
useValue: mockNodeRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Tunnel),
|
||||||
|
useValue: mockTunnelRepo,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: XuiService,
|
provide: XuiService,
|
||||||
useValue: mockXuiService,
|
useValue: mockXuiService,
|
||||||
@@ -47,6 +70,7 @@ describe('SubscriptionsService', () => {
|
|||||||
getRepositoryToken(Subscription),
|
getRepositoryToken(Subscription),
|
||||||
);
|
);
|
||||||
xuiService = module.get<XuiService>(XuiService);
|
xuiService = module.get<XuiService>(XuiService);
|
||||||
|
mockXuiService.deleteInbound.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -86,7 +110,7 @@ describe('SubscriptionsService', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockSubs);
|
expect(result).toEqual(mockSubs);
|
||||||
expect(subRepo.find).toHaveBeenCalledWith({
|
expect(subRepo.find).toHaveBeenCalledWith({
|
||||||
relations: ['inbounds'],
|
relations: ['inbounds', 'node', 'relayServer'],
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -128,6 +152,8 @@ describe('SubscriptionsService', () => {
|
|||||||
uuid: expect.any(String),
|
uuid: expect.any(String),
|
||||||
inboundsConfig: createDto.inboundsConfig,
|
inboundsConfig: createDto.inboundsConfig,
|
||||||
isAutoRotationEnabled: true,
|
isAutoRotationEnabled: true,
|
||||||
|
node: null,
|
||||||
|
relayServer: null,
|
||||||
});
|
});
|
||||||
expect(subRepo.save).toHaveBeenCalledWith(mockSubscription);
|
expect(subRepo.save).toHaveBeenCalledWith(mockSubscription);
|
||||||
expect(result).toEqual(mockSubscription);
|
expect(result).toEqual(mockSubscription);
|
||||||
@@ -197,7 +223,7 @@ describe('SubscriptionsService', () => {
|
|||||||
|
|
||||||
expect(subRepo.findOne).toHaveBeenCalledWith({
|
expect(subRepo.findOne).toHaveBeenCalledWith({
|
||||||
where: { id: 'test-id' },
|
where: { id: 'test-id' },
|
||||||
relations: ['inbounds'],
|
relations: ['inbounds', 'node', 'relayServer'],
|
||||||
});
|
});
|
||||||
expect(subRepo.save).toHaveBeenCalledWith(
|
expect(subRepo.save).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: 'New Name' }),
|
expect.objectContaining({ name: 'New Name' }),
|
||||||
@@ -329,8 +355,8 @@ describe('SubscriptionsService', () => {
|
|||||||
|
|
||||||
await service.remove('test-id');
|
await service.remove('test-id');
|
||||||
|
|
||||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
|
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, undefined);
|
||||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102);
|
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102, undefined);
|
||||||
expect(subRepo.remove).toHaveBeenCalledWith(subWithInbounds);
|
expect(subRepo.remove).toHaveBeenCalledWith(subWithInbounds);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe('TunnelsController', () => {
|
|||||||
|
|
||||||
await controller.remove('1');
|
await controller.remove('1');
|
||||||
|
|
||||||
expect(tunnelsService.remove).toHaveBeenCalledWith(1);
|
expect(tunnelsService.remove).toHaveBeenCalledWith(1, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { TunnelsService } from 'src/tunnels/tunnels.service';
|
|||||||
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||||
import { SshService } from 'src/tunnels/ssh.service';
|
import { SshService } from 'src/tunnels/ssh.service';
|
||||||
import { Setting } from 'src/settings/entities/setting.entity';
|
import { Setting } from 'src/settings/entities/setting.entity';
|
||||||
|
import { Node } from 'src/nodes/entities/node.entity';
|
||||||
|
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
||||||
|
|
||||||
describe('TunnelsService', () => {
|
describe('TunnelsService', () => {
|
||||||
let service: TunnelsService;
|
let service: TunnelsService;
|
||||||
@@ -34,6 +36,15 @@ describe('TunnelsService', () => {
|
|||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockNodeRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSubscriptionRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const mockSshService = {
|
const mockSshService = {
|
||||||
executeCommand: jest.fn(),
|
executeCommand: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -50,6 +61,14 @@ describe('TunnelsService', () => {
|
|||||||
provide: getRepositoryToken(Setting),
|
provide: getRepositoryToken(Setting),
|
||||||
useValue: mockSettingRepo,
|
useValue: mockSettingRepo,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Node),
|
||||||
|
useValue: mockNodeRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Subscription),
|
||||||
|
useValue: mockSubscriptionRepo,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: SshService,
|
provide: SshService,
|
||||||
useValue: mockSshService,
|
useValue: mockSshService,
|
||||||
@@ -64,21 +83,27 @@ describe('TunnelsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('должен создать туннель', async () => {
|
it('должен создать туннель', async () => {
|
||||||
const dto = { ip: '192.168.1.1', sshPort: 22, username: 'root' };
|
const dto = { ip: '192.168.1.1', sshPort: 22, username: 'root' };
|
||||||
const mockTunnel = { id: 1, ...dto };
|
const node = { id: 'node-1', isMain: true };
|
||||||
|
const mockTunnel = { id: 1, ...dto, node, nodeId: node.id };
|
||||||
|
|
||||||
|
mockNodeRepo.findOne.mockResolvedValue(node);
|
||||||
mockTunnelRepo.create.mockReturnValue(mockTunnel);
|
mockTunnelRepo.create.mockReturnValue(mockTunnel);
|
||||||
mockTunnelRepo.save.mockResolvedValue(mockTunnel);
|
mockTunnelRepo.save.mockResolvedValue(mockTunnel);
|
||||||
|
|
||||||
const result = await service.create(dto);
|
const result = await service.create(dto);
|
||||||
|
|
||||||
expect(result).toEqual(mockTunnel);
|
expect(result).toEqual(mockTunnel);
|
||||||
expect(tunnelRepo.create).toHaveBeenCalledWith(dto);
|
expect(tunnelRepo.create).toHaveBeenCalledWith({
|
||||||
|
...dto,
|
||||||
|
node,
|
||||||
|
nodeId: node.id,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,12 +119,13 @@ describe('TunnelsService', () => {
|
|||||||
const result = await service.findAll();
|
const result = await service.findAll();
|
||||||
|
|
||||||
expect(result).toEqual(mockTunnels);
|
expect(result).toEqual(mockTunnels);
|
||||||
expect(tunnelRepo.find).toHaveBeenCalledTimes(1);
|
expect(tunnelRepo.find).toHaveBeenCalledWith({ relations: ['node'] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remove', () => {
|
describe('remove', () => {
|
||||||
it('должен удалить туннель по ID', async () => {
|
it('должен удалить туннель по ID', async () => {
|
||||||
|
mockSubscriptionRepo.find.mockResolvedValue([]);
|
||||||
mockTunnelRepo.delete.mockResolvedValue({ affected: 1 });
|
mockTunnelRepo.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
|
||||||
await service.remove(1);
|
await service.remove(1);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('XuiService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
@@ -149,7 +149,16 @@ describe('XuiService', () => {
|
|||||||
|
|
||||||
describe('deleteInbound', () => {
|
describe('deleteInbound', () => {
|
||||||
it('должен удалить инбаунд', async () => {
|
it('должен удалить инбаунд', async () => {
|
||||||
mockAxiosInstance.post.mockResolvedValue({ data: { success: true } });
|
mockSettingsRepo.find.mockResolvedValue([
|
||||||
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
|
{ key: 'xui_login', value: 'admin' },
|
||||||
|
{ key: 'xui_password', value: 'password' },
|
||||||
|
]);
|
||||||
|
mockAxiosInstance.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
headers: { 'set-cookie': ['session=abc123'] },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { success: true } });
|
||||||
|
|
||||||
await service.deleteInbound(101);
|
await service.deleteInbound(101);
|
||||||
|
|
||||||
@@ -159,16 +168,35 @@ describe('XuiService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('должен обработать ошибку удаления', async () => {
|
it('должен обработать ошибку удаления', async () => {
|
||||||
mockAxiosInstance.post.mockRejectedValue(new Error('Not found'));
|
mockSettingsRepo.find.mockResolvedValue([
|
||||||
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
|
{ key: 'xui_login', value: 'admin' },
|
||||||
|
{ key: 'xui_password', value: 'password' },
|
||||||
|
]);
|
||||||
|
mockAxiosInstance.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
headers: { 'set-cookie': ['session=abc123'] },
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new Error('Not found'));
|
||||||
|
|
||||||
await service.deleteInbound(999);
|
await service.deleteInbound(999);
|
||||||
|
|
||||||
expect(mockAxiosInstance.post).toHaveBeenCalled();
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||||
|
'/panel/api/inbounds/del/999',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getNewX25519Cert', () => {
|
describe('getNewX25519Cert', () => {
|
||||||
it('должен получить Reality ключи', async () => {
|
it('должен получить Reality ключи', async () => {
|
||||||
|
mockSettingsRepo.find.mockResolvedValue([
|
||||||
|
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||||
|
{ key: 'xui_login', value: 'admin' },
|
||||||
|
{ key: 'xui_password', value: 'password' },
|
||||||
|
]);
|
||||||
|
mockAxiosInstance.post.mockResolvedValueOnce({
|
||||||
|
headers: { 'set-cookie': ['session=abc123'] },
|
||||||
|
});
|
||||||
mockAxiosInstance.get.mockResolvedValue({
|
mockAxiosInstance.get.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -250,6 +250,40 @@ ensure_bus_location() {
|
|||||||
mv "$tmp_file" "$nginx_conf"
|
mv "$tmp_file" "$nginx_conf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove_hysteria_mount() {
|
||||||
|
local compose_file="$1"
|
||||||
|
[[ -f "$compose_file" ]] || return 0
|
||||||
|
|
||||||
|
local tmp_file
|
||||||
|
tmp_file="$(mktemp)"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]*volumes:[[:space:]]*$/ {
|
||||||
|
pending_volumes = $0
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
/\/etc\/hysteria\/config\.yaml:\/etc\/hysteria\/config\.yaml:ro/ {
|
||||||
|
pending_volumes = ""
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if (pending_volumes != "") {
|
||||||
|
print pending_volumes
|
||||||
|
pending_volumes = ""
|
||||||
|
}
|
||||||
|
print $0
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
if (pending_volumes != "") print pending_volumes
|
||||||
|
}
|
||||||
|
' "$compose_file" > "$tmp_file"
|
||||||
|
|
||||||
|
mv "$tmp_file" "$compose_file"
|
||||||
|
}
|
||||||
|
|
||||||
need_root() {
|
need_root() {
|
||||||
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
||||||
}
|
}
|
||||||
@@ -287,6 +321,7 @@ check_and_fix_credentials || true
|
|||||||
#################################
|
#################################
|
||||||
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
|
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
|
||||||
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
|
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
|
||||||
|
remove_hysteria_mount "$PROJECT_DIR/docker-compose.yml"
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# REBUILD BACKEND
|
# REBUILD BACKEND
|
||||||
|
|||||||
Reference in New Issue
Block a user