42 Commits

Author SHA1 Message Date
DenPiligrim 1f9d54ee0b Merge pull request #8 from denpiligrim/dp-gui
GUI
2026-02-01 13:37:04 +03:00
Den Piligrim 10208930ff v2.0.0 2026-02-01 13:35:32 +03:00
Den Piligrim ff7b1646b8 nest proto 2026-02-01 12:50:45 +03:00
Den Piligrim 9dee9bc216 fix frontend port 2026-02-01 12:34:20 +03:00
Den Piligrim 083fdf98b3 fix port 2026-02-01 12:25:18 +03:00
Den Piligrim 80a98002d6 fix backend ssl 2026-02-01 12:01:59 +03:00
Den Piligrim a6c951938c fix endpoint 2026-02-01 11:26:11 +03:00
Den Piligrim 8cb9aa46d8 fix api endpoint 2026-02-01 11:17:06 +03:00
Den Piligrim a470b6112f fix docker compose 2026-02-01 11:10:40 +03:00
Den Piligrim 27d64b3c5f fix env 2026-02-01 10:58:51 +03:00
Den Piligrim 2183490efb fix docker file 2026-01-31 22:26:37 +03:00
Den Piligrim 23dc7bff1f fix docker file 2026-01-31 22:23:55 +03:00
Den Piligrim 899949a150 fix nginx 2026-01-31 22:03:11 +03:00
Den Piligrim ad3127f8c1 fix ram 2026-01-31 21:27:44 +03:00
Den Piligrim a55b3e89ac fix port 2026-01-31 21:24:57 +03:00
Den Piligrim b178df908b fix port 2026-01-31 21:21:56 +03:00
Den Piligrim 236d0b2126 fix port 2026-01-31 21:20:12 +03:00
Den Piligrim ba4b7ef0a4 fix port 2026-01-31 21:18:01 +03:00
Den Piligrim 27310c0bc0 func optimization 2026-01-31 18:44:09 +03:00
Den Piligrim 6db895bcc7 update 2026-01-30 10:13:13 +03:00
Den Piligrim ba6db6edd5 gui version 2026-01-28 12:05:10 +03:00
DenPiligrim f574931c25 Merge pull request #7 from denpiligrim/dp-fix
fix: index.js
2026-01-08 00:36:31 +03:00
Den Piligrim 6f02e501e6 fix: index.js 2026-01-08 00:35:47 +03:00
DenPiligrim fed99d8e5c Merge pull request #6 from denpiligrim/dp-fix
fix: url validation, upload rotate.js (v1.0.2)
2026-01-05 23:44:29 +03:00
Den Piligrim 299ec3ecac fix: url validation, upload rotate.js 2026-01-05 23:43:22 +03:00
DenPiligrim 0c16656c4f Merge pull request #5 from denpiligrim/dp-fix
v1.0.2
2026-01-05 14:29:03 +03:00
Den Piligrim 45f67d3848 add: self-signed cert, docker, cmd rotate 2026-01-05 14:27:03 +03:00
Den Piligrim b77e5560c8 fix url 2026-01-03 22:09:46 +03:00
Den Piligrim c48e3df360 fix var 2026-01-03 22:04:50 +03:00
Den Piligrim 17f7baaf61 fix: docker panel, selfsigned cert 2026-01-03 16:05:00 +03:00
DenPiligrim 1738fe4d64 Merge pull request #3 from denpiligrim/dp-fix
v1.0.1
2025-12-27 19:31:37 +03:00
Den Piligrim 36163093ea v1.0.1 2025-12-27 19:00:29 +03:00
Den Piligrim e4015d0adf fix port 2025-12-27 18:22:21 +03:00
Den Piligrim 88cc96a75d few fixes 2025-12-27 18:16:04 +03:00
Den Piligrim fa3e984189 update shell scripts 2025-12-27 17:27:53 +03:00
Den Piligrim 94fb12194e update readme 2025-12-27 16:57:25 +03:00
Den Piligrim 1906ed4969 new whitelist 2025-12-27 16:28:24 +03:00
Den Piligrim 28c59783c9 fix docker installation 2025-12-27 16:00:16 +03:00
Den Piligrim 39eab5bf4e fix gpg key 2025-12-27 15:36:34 +03:00
Den Piligrim f385b57b1b sub token 2025-12-27 15:28:46 +03:00
Den Piligrim f20f722743 branch 2025-12-27 15:16:44 +03:00
Den Piligrim b6ffaa722c fix install.sh/index.js 2025-12-27 15:14:11 +03:00
118 changed files with 20088 additions and 1903 deletions
+11
View File
@@ -0,0 +1,11 @@
# Внести вклад
Буду рад любому вкладу в разработку проекта! Простой процесс для контрибьюторов:
1. Форкните репозиторий на GitHub.
2. Создайте ветку с осмысленным именем, например `feature/add-README` или `fix/whitelist-load`.
3. Внесите изменения и добавьте короткое описание в коммите.
4. Запустите локально базовые проверки (если есть).
5. Отправьте ветку в ваш форк и создайте Pull Request в основной репозиторий.
<sup>Советы: описывайте изменения в PR, указывайте цель и тестовые шаги. Если изменения большие — разделяйте на маленькие коммиты.</sup>
+41 -14
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
@@ -10,11 +10,13 @@
**Поддержать проект**
- Реквизиты / донаты:
- Банковским переводом:
- Карта МИР: `2204320436318077`
- Карта MasterCard: `5395452209474530`
- На электронный кошелек:
- ЮМоney: `4100116897060652`
- PayPal: `vasiljevdenisx@gmail.com`
- Криптовалютой:
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
@@ -32,7 +34,7 @@
Все подключения объединяются в одну подписку со статичным URL. Бот работает с панелью `3x-ui` и не вмешивается в её работу напрямую, используя открытое API панели.
Вторичная цель — стабильность подключения: клиент получает 10 вариантов поключений и может выбрать любое из них.
Вторичная цель — стабильность подключения: клиент получает 10 вариантов подключений и может выбрать любое из них.
Дополнительно: бот можно использовать в каскадной схеме. Сервис перенаправления автоматически настроит переадресацию подписки и трафика к основному серверу.
@@ -51,21 +53,31 @@
## Требования
- Ubuntu 20.04 (и выше)
- Панель `3x-ui`
- Ubuntu 20.04 (и выше), Debian 12.11 (и выше)
- Панель `3x-ui` v2.8.4 (и выше)
- Домен + SSL сертификат (опционально)
---
## Установка
> [!WARNING]
> Утилита работает на основном сервере
У вас должны быть установлены пакеты на сервере `curl`, `jq` командой: `apt install curl jq` и панель управления `3x-ui`, которую можно установить командой: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
Установите проект на сервер командой:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
<small>Краткое описание: запускает скрипт установки и разворачивает контейнеры и сервисы.</small>
<sup>Краткое описание: запускает скрипт установки и разворачивает контейнеры и сервисы.</sup>
Если панель 3x-ui находится в Docker контейнере, установите командой:
```bash
REMOTE_PANEL=true bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
## Обновление
@@ -75,7 +87,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)
```
<small>Краткое описание: подтягивает последние изменения и перезапускает контейнеры.</small>
<sup>Краткое описание: подтягивает последние изменения и перезапускает контейнеры.</sup>
## Удаление
@@ -85,19 +97,22 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
```
<small>Краткое описание: удаляет контейнеры и файлы конфигурации, возвращая систему к состоянию до установки.</small>
<sup>Краткое описание: удаляет контейнеры и файлы конфигурации, возвращая систему к состоянию до установки.</sup>
---
## Установка сервиса перенаправления (forwarding)
> [!WARNING]
> Сервис перенаправления работает на промежуточном сервере
Сервис перенаправления позволяет проксировать входящие порты с промежуточного сервера на основной.
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
```
<small>Краткое описание: добавляет правила перенаправления и создает сервис для обновления подписки.</small>
<sup>Краткое описание: добавляет правила перенаправления и создает сервис для обновления подписки.</sup>
## Удаление перенаправления
@@ -105,7 +120,7 @@ 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)
```
<small>Краткое описание: удаляет правила и отключает сервис перенаправления.</small>
<sup>Краткое описание: удаляет правила и отключает сервис перенаправления.</sup>
---
@@ -117,7 +132,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
```
<small>Краткое описание: выводит статичный URL подписки, который можно использовать в клиентах. Работает как на основном, так и на промежуточном сервере.</small>
<sup>Краткое описание: выводит статичный URL подписки, который можно использовать в клиентах. Работает как на основном, так и на промежуточном сервере.</sup>
## Сбор доменов из мульти-подписок
@@ -127,7 +142,7 @@ cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -
node get_domains.js
```
<small>Краткое описание: добавьте ссылку на мульти-подписку в скрипт и запустите команду — на выходе получите список доменов. Неободим `Node.js` для работы скрипта.</small>
<sup>Краткое описание: добавьте ссылку на мульти-подписку в скрипт и запустите команду — на выходе получите список доменов. Необходим `Node.js` для работы скрипта.</sup>
## Использование собственного белого списка
@@ -138,7 +153,19 @@ node get_domains.js
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
```
<small>Краткое описание: добавляет ваш файл доменов в контейнер приложения. Чтобы сразу же сгенерировать инбаунды с новыи списком, выполните `docker exec -it node sh` и затем `node index.js`.</small>
<sup>Краткое описание: добавляет ваш файл доменов в контейнер приложения. Чтобы сразу же сгенерировать инбаунды с новым списком, выполните `docker exec -it node sh` и затем `node rotate.js`.</sup>
## Ручной запуск генерации
Чтобы создать новые инбаунды, выполните команды поочередно:
```bash
cd /opt/3dp-manager
docker exec -it node sh
node rotate.js
```
<sup>Краткое описание: выполняет немедленную генерацию инбаундов, не влияя на заданный интервал ротации.</sup>
---
@@ -158,7 +185,7 @@ cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.t
4. Запустите локально базовые проверки (если есть).
5. Отправьте ветку в ваш форк и создайте Pull Request в основной репозиторий.
<small>Советы: описывайте изменения в PR, указывайте цель и тестовые шаги. Если изменения большие — разделяйте на маленькие коммиты.</small>
<sup>Советы: описывайте изменения в PR, указывайте цель и тестовые шаги. Если изменения большие — разделяйте на маленькие коммиты.</sup>
---
+10 -10
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
@@ -64,7 +64,7 @@
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
<small>简要说明:运行安装脚本并部署容器和服务。</small>
<sup>简要说明:运行安装脚本并部署容器和服务。</sup>
## 更新
@@ -74,7 +74,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)
```
<small>简要说明:拉取最新更改并重启容器。</small>
<sup>简要说明:拉取最新更改并重启容器。</sup>
## 删除
@@ -84,7 +84,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
```
<small>简要说明:删除容器和配置文件,恢复到安装前状态。</small>
<sup>简要说明:删除容器和配置文件,恢复到安装前状态。</sup>
---
@@ -96,7 +96,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
```
<small>简要说明:添加转发规则并创建用于更新订阅的服务。</small>
<sup>简要说明:添加转发规则并创建用于更新订阅的服务。</sup>
## 删除转发
@@ -104,7 +104,7 @@ 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)
```
<small>简要说明:删除规则并禁用转发服务。</small>
<sup>简要说明:删除规则并禁用转发服务。</sup>
---
@@ -116,7 +116,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
```
<small>简要说明:打印可在客户端使用的静态订阅 URL,适用于主服务器和中继服务器。</small>
<sup>简要说明:打印可在客户端使用的静态订阅 URL,适用于主服务器和中继服务器。</sup>
## 从多订阅收集域名
@@ -126,7 +126,7 @@ cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -
node get_domains.js
```
<small>简要说明:在脚本中添加多订阅链接并运行命令 — 输出为域名列表。运行脚本需要 `Node.js`。</small>
<sup>简要说明:在脚本中添加多订阅链接并运行命令 — 输出为域名列表。运行脚本需要 `Node.js`。</sup>
## 使用自定义白名单
@@ -137,7 +137,7 @@ node get_domains.js
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
```
<small>简要说明:将您的域名文件添加到应用容器中。</small>
<sup>简要说明:将您的域名文件添加到应用容器中。</sup>
---
@@ -157,7 +157,7 @@ cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.t
4. 在本地运行检查(如有)。
5. 推送分支到您的 fork 并创建 Pull Request。
<small>建议:在 PR 中说明更改和测试步骤;较大更改请拆成小提交。</small>
<sup>建议:在 PR 中说明更改和测试步骤;较大更改请拆成小提交。</sup>
---
+10 -10
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
@@ -64,7 +64,7 @@ Install the project on your server with:
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
<small>Short description: runs the installer script and deploys containers and services.</small>
<sup>Short description: runs the installer script and deploys containers and services.</sup>
## Update
@@ -74,7 +74,7 @@ Update to the latest version:
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
```
<small>Short description: pulls the latest changes and restarts containers.</small>
<sup>Short description: pulls the latest changes and restarts containers.</sup>
## Removal
@@ -84,7 +84,7 @@ Remove the service completely:
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
```
<small>Short description: removes containers and configuration files, restoring the system to the pre-install state.</small>
<sup>Short description: removes containers and configuration files, restoring the system to the pre-install state.</sup>
---
@@ -96,7 +96,7 @@ The forwarding service allows proxying incoming ports from the intermediate serv
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
```
<small>Short description: adds forwarding rules and creates a service to update the subscription.</small>
<sup>Short description: adds forwarding rules and creates a service to update the subscription.</sup>
## Remove forwarding
@@ -104,7 +104,7 @@ 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)
```
<small>Short description: removes rules and disables the forwarding service.</small>
<sup>Short description: removes rules and disables the forwarding service.</sup>
---
@@ -116,7 +116,7 @@ Command to print the current subscription URL from the container environment:
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
```
<small>Short description: prints the static subscription URL that can be used in clients. Works on both main and intermediate servers.</small>
<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
@@ -126,7 +126,7 @@ The utility extracts domains from subscriptions and builds a `whitelist` for the
node get_domains.js
```
<small>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.</small>
<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>
## Use your own whitelist
@@ -137,7 +137,7 @@ node get_domains.js
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
```
<small>Short description: adds your domain file into the application container.</small>
<sup>Short description: adds your domain file into the application container.</sup>
---
@@ -157,7 +157,7 @@ Contributions are welcome! Simple contributor workflow:
4. Run local checks if available.
5. Push the branch to your fork and create a Pull Request to the main repository.
<small>Tips: describe changes and testing steps in the PR. For large changes, split into smaller commits.</small>
<sup>Tips: describe changes and testing steps in the PR. For large changes, split into smaller commits.</sup>
---
+10 -10
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
@@ -64,7 +64,7 @@
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
<small>توضیح کوتاه: اسکریپت نصب را اجرا کرده و کانتینرها و سرویس‌ها را راه‌اندازی می‌کند.</small>
<sup>توضیح کوتاه: اسکریپت نصب را اجرا کرده و کانتینرها و سرویس‌ها را راه‌اندازی می‌کند.</sup>
## به‌روزرسانی
@@ -74,7 +74,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)
```
<small>توضیح کوتاه: تغییرات جدید را می‌کشد و کانتینرها را ری‌استارت می‌کند.</small>
<sup>توضیح کوتاه: تغییرات جدید را می‌کشد و کانتینرها را ری‌استارت می‌کند.</sup>
## حذف
@@ -84,7 +84,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
```
<small>توضیح کوتاه: کانتینرها و فایل‌های پیکربندی را حذف می‌کند و سیستم را به وضعیت پیش از نصب برمی‌گرداند.</small>
<sup>توضیح کوتاه: کانتینرها و فایل‌های پیکربندی را حذف می‌کند و سیستم را به وضعیت پیش از نصب برمی‌گرداند.</sup>
---
@@ -96,7 +96,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
```
<small>توضیح کوتاه: قوانین فورواردینگ را اضافه کرده و سرویسی برای به‌روزرسانی اشتراک ایجاد می‌کند.</small>
<sup>توضیح کوتاه: قوانین فورواردینگ را اضافه کرده و سرویسی برای به‌روزرسانی اشتراک ایجاد می‌کند.</sup>
## حذف فورواردینگ
@@ -104,7 +104,7 @@ 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)
```
<small>توضیح کوتاه: قوانین را حذف کرده و سرویس فورواردینگ را غیرفعال می‌کند.</small>
<sup>توضیح کوتاه: قوانین را حذف کرده و سرویس فورواردینگ را غیرفعال می‌کند.</sup>
---
@@ -116,7 +116,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
```
<small>توضیح کوتاه: URL ثابت اشتراک را نمایش می‌دهد که می‌توان در کلاینت‌ها استفاده کرد. هم در سرور اصلی و هم در سرور واسط کار می‌کند.</small>
<sup>توضیح کوتاه: URL ثابت اشتراک را نمایش می‌دهد که می‌توان در کلاینت‌ها استفاده کرد. هم در سرور اصلی و هم در سرور واسط کار می‌کند.</sup>
## جمع‌آوری دامنه‌ها از چندین اشتراک
@@ -126,7 +126,7 @@ cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -
node get_domains.js
```
<small>توضیح کوتاه: لینک چندین اشتراک را در اسکریپت قرار داده و فرمان را اجرا کنید — خروجی فهرست دامنه‌ها خواهد بود. نیازمند `Node.js` است.</small>
<sup>توضیح کوتاه: لینک چندین اشتراک را در اسکریپت قرار داده و فرمان را اجرا کنید — خروجی فهرست دامنه‌ها خواهد بود. نیازمند `Node.js` است.</sup>
## استفاده از whitelist شخصی
@@ -137,7 +137,7 @@ node get_domains.js
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
```
<small>توضیح کوتاه: فایل دامنه شما را به کانتینر برنامه اضافه می‌کند.</small>
<sup>توضیح کوتاه: فایل دامنه شما را به کانتینر برنامه اضافه می‌کند.</sup>
---
@@ -157,7 +157,7 @@ cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.t
4. در صورت وجود، بررسی‌های محلی را اجرا کنید.
5. شاخه را به fork خود پوش کرده و Pull Request ایجاد کنید.
<small>نکات: تغییرات را در PR شرح دهید و مراحل تست را بنویسید. در صورت تغییرات بزرگ، آنها را به کامیت‌های کوچک تقسیم کنید.</small>
<sup>نکات: تغییرات را در PR شرح دهید و مراحل تست را بنویسید. در صورت تغییرات بزرگ، آنها را به کامیت‌های کوچک تقسیم کنید.</sup>
---
+4 -4
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
@@ -64,7 +64,7 @@ Taslamany serwera gurnamak üçin şu komandany ýerine ýetiriň:
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
<small>Gysgaça: gurnama skripti işledilýär we konteýnerler we hyzmatlar döredilýär.</small>
<sup>Gysgaça: gurnama skripti işledilýär we konteýnerler we hyzmatlar döredilýär.</sup>
## Täzelik
@@ -74,7 +74,7 @@ Soňky wersiýa çenli täzeläň:
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
```
<small>Gysgaça: iň soňky üýtgeşmeleri çekýär we konteýnerleri gaýtadan işledýär.</small>
<sup>Gysgaça: iň soňky üýtgeşmeleri çekýär we konteýnerleri gaýtadan işledýär.</sup>
## Pozmak
@@ -84,7 +84,7 @@ Hyzmaty doly pozmak üçin:
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
```
<small>Gysgaça: konteýnerleri we konfigurasiýa faýllaryny pozup, systemany gurnamadan öňki ýagdaýyna getirer.</small>
<sup>Gysgaça: konteýnerleri we konfigurasiýa faýllaryny pozup, systemany gurnamadan öňki ýagdaýyna getirer.</sup>
---
-10
View File
@@ -1,10 +0,0 @@
SUB_TOKEN=$SUB_TOKEN
UI_URL=$UI_URL
UI_LOGIN=$UI_LOGIN
UI_PASSWORD=$UI_PASSWORD
COUNTRY_FLAG=$COUNTRY_FLAG
NGINX_PORT=$NGINX_PORT
UI_HOST=$UI_HOST
UI_PROTO=$UI_PROTO
ROTATE_INTERVAL=$ROTATE_INTERVAL
SUB_URL=$SUB_URL
-11
View File
@@ -1,11 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY index.js .
COPY builders ./builders
CMD ["node", "index.js"]
-162
View File
@@ -1,162 +0,0 @@
const flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
export function buildInboundLink(inbound, domain, idOrPass) {
let link = "";
switch (inbound.protocol) {
case "vless": {
link = buildVlessLink(inbound, domain, idOrPass);
break;
}
case "vmess": {
link = buildVmessLink(inbound, domain, idOrPass);
break;
}
case "shadowsocks":
link = buildSsLink(inbound, domain, idOrPass);
break;
case "trojan":
link = buildTrojanLink(inbound, domain, idOrPass);
break;
}
return link;
}
function buildVlessLink(inbound, domain, uuid) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
const network = stream.network;
const security = stream.security || "none";
const params = new URLSearchParams();
// Базовые параметры
params.set("type", network);
params.set("encryption", "none");
params.set("security", security);
/**
* ===== REALITY =====
*/
if (security === "reality") {
const r = stream.realitySettings;
params.set("pbk", r.settings.publicKey);
params.set("fp", r.settings.fingerprint || "random");
params.set("sni", r.serverNames?.[0] || "");
params.set("sid", r.shortIds?.[0] || "");
params.set("spx", '/');
// TCP Reality flow
if (network === "tcp") {
const client = settings.clients?.[0];
if (client?.flow) {
params.set("flow", client.flow);
}
}
// XHTTP Reality
if (network === "xhttp") {
const x = stream.xhttpSettings || {};
params.set("path", x.path || "/");
params.set("host", x.host || r.serverNames?.[0]);
params.set("mode", x.mode || "auto");
}
// gRPC Reality
if (network === "grpc") {
const g = stream.grpcSettings || {};
params.set("serviceName", g.serviceName || "grpc");
params.set("authority", g.authority || r.serverNames?.[0]);
}
}
/**
* ===== WS NONE =====
*/
if (network === "ws") {
const ws = stream.wsSettings || {};
params.set("path", ws.path || "/");
if (ws.headers?.Host) {
params.set("host", ws.headers.Host);
}
}
return (
`vless://${uuid}@${domain}:${inbound.port}` +
`?${params.toString()}` +
`#${flag}%20${encodeURIComponent(inbound.remark)}`
);
}
function buildVmessLink(inbound, domain, uuid) {
const stream = JSON.parse(inbound.streamSettings);
const vmessObj = {
add: domain,
aid: '',
alpn: "",
fp: "",
host: "",
id: uuid,
net: stream.network || "tcp",
path: "/",
port: inbound.port,
ps: decodeURIComponent(flag) + ' ' + inbound.remark,
scy: "",
sni: "",
tls: stream.security || "none",
type: "none",
v: "2"
};
const base64 = Buffer
.from(JSON.stringify(vmessObj), "utf8")
.toString("base64");
return `vmess://${base64}`;
}
function buildSsLink(inbound, domain) {
const settings = JSON.parse(inbound.settings);
const method = settings.method;
const serverPassword = settings.password;
const clientPassword = settings.clients[0].password;
// method:serverPassword:clientPassword
const userInfo = `${method}:${serverPassword}:${clientPassword}`;
const base64 = Buffer
.from(userInfo, "utf8")
.toString("base64");
return `ss://${base64}@${domain}:${inbound.port}?type=tcp#${flag}%20${inbound.remark}`;
}
function buildTrojanLink(inbound, domain, password) {
const stream = JSON.parse(inbound.streamSettings);
const reality = stream.realitySettings;
const pbk = reality.settings.publicKey;
const sni = reality.serverNames?.[0] || domain;
const sid = reality.shortIds?.[0] || "";
const spx = '%2F';
return (
`trojan://${password}@${domain}:${inbound.port}` +
`?type=tcp` +
`&security=reality` +
`&pbk=${pbk}` +
`&fp=random` +
`&sni=${sni}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${flag}%20${inbound.remark}`
);
}
-43
View File
@@ -1,43 +0,0 @@
import crypto from "crypto";
export function buildShadowsocksTcp({ port, uuid }) {
return {
enable: true,
port,
protocol: "shadowsocks",
remark: "shadowsocks-tcp",
settings: JSON.stringify({
clients: [{
id: "",
flow: "",
email: uuid,
password: crypto.randomBytes(32).toString("base64"),
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
ivCheck: false,
method: "2022-blake3-aes-256-gcm",
network: "tcp",
password: crypto.randomBytes(32).toString("base64")
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "none",
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-65
View File
@@ -1,65 +0,0 @@
import crypto from "crypto";
export function buildTrojanRealityTcp({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "trojan",
remark: "trojan-reality-tcp",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
password: crypto.randomBytes(8).toString("hex"),
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-67
View File
@@ -1,67 +0,0 @@
import crypto from "crypto";
export function buildVlessRealityGrpc({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-grpc",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "grpc",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
grpcSettings: {
serviceName: "myservice",
authority: domain,
multiMode: false,
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-66
View File
@@ -1,66 +0,0 @@
import crypto from "crypto";
export function buildVlessRealityTcp({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-tcp",
settings: JSON.stringify({
clients: [{
id: uuid,
flow: "xtls-rprx-vision",
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-72
View File
@@ -1,72 +0,0 @@
import crypto from "crypto";
export function buildVlessRealityXhttp({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-xhttp",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "xhttp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
xhttpSettings: {
host: domain,
path: "/",
mode: "auto",
noSSEHeader: false,
scMaxBufferedPosts: 30,
scMaxEachPostBytes: "1000000",
scStreamUpServerSecs: "20-80",
xPaddingBytes: "100-1000"
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-42
View File
@@ -1,42 +0,0 @@
export function buildVlessWs({ port, uuid, domain }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-ws",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "ws",
security: "none",
externalProxy: [],
wsSettings: {
host: domain,
path: "/",
acceptProxyProtocol: false,
heartbeatPeriod: 0,
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-37
View File
@@ -1,37 +0,0 @@
export function buildVmessTcp({ port, uuid }) {
return {
enable: true,
port,
protocol: "vmess",
remark: "vmess-tcp",
settings: JSON.stringify({
clients: [{
id: uuid,
flow: "",
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "0",
alterId: "0",
reset: 0
}],
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "none",
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-18
View File
@@ -1,18 +0,0 @@
services:
node:
build: ./app
env_file: .env
volumes:
- ./subscriptions:/subscriptions
- ./whitelist.txt:/app/whitelist.txt:ro
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on: [node]
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./subscriptions:/subscriptions
-217
View File
@@ -1,217 +0,0 @@
import axios from "axios";
import cron from "node-cron";
import fs from "fs";
import net from "net";
import { buildVlessRealityTcp } from "./builders/buildVlessRealityTcp.js";
import { buildVlessRealityXhttp } from "./builders/buildVlessRealityXhttp.js";
import { buildTrojanRealityTcp } from "./builders/buildTrojanRealityTcp.js";
import { buildShadowsocksTcp } from "./builders/buildShadowsocksTcp.js";
import { buildVmessTcp } from "./builders/buildVmessTcp.js";
import { buildVlessRealityGrpc } from "./builders/buildVlessRealityGrpc.js";
import { buildVlessWs } from "./builders/buildVlessWs.js";
import { buildInboundLink } from "./builders/buildInboundLink.js";
const { UI_URL, UI_LOGIN, UI_PASSWORD, UI_HOST } = process.env;
const SUB_FILE = "/subscriptions/list.txt";
const LAST_INBOUNDS = '/subscriptions/latestInbounds.json';
const cookieJar = {};
const api = axios.create({ baseURL: UI_URL, timeout: 15000, withCredentials: true });
api.interceptors.request.use(config => {
if (cookieJar.value) config.headers['Cookie'] = cookieJar.value;
return config;
});
const WHITELIST_FILE = "/app/whitelist.txt";
const WHITELIST_REPO_URL = "https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/whitelist.txt";
// Helper functions
async function updateWhitelist() {
try {
const res = await axios.get(WHITELIST_REPO_URL, { timeout: 10000 });
fs.writeFileSync(WHITELIST_FILE, res.data, "utf8");
console.log("✔ whitelist.txt обновлен из репозитория");
} catch (err) {
console.warn("⚠ Не удалось обновить whitelist.txt, будет использоваться локальный файл:", err.message);
}
}
function loadWhitelist() {
let fileToUse = WHITELIST_FILE;
if (fs.existsSync("/app/my_whitelist.txt")) {
console.log('Найден кастомный whitelist: /app/my_whitelist.txt. Используется он.');
fileToUse = "/app/my_whitelist.txt";
} else {
console.log(`Кастомный whitelist не найден. Используется дефолтный: ${WHITELIST_FILE}`);
if (!fs.existsSync(WHITELIST_FILE)) {
throw new Error("Дефолтный whitelist.txt не найден");
}
}
return fs.readFileSync(fileToUse, "utf8")
.split("\n")
.map(v => v.trim())
.filter(Boolean);
}
function pickDomain(list) {
return list[Math.floor(Math.random() * list.length)];
}
async function isPortFree(port) {
return new Promise(resolve => {
const s = net.createServer()
.once("error", () => resolve(false))
.once("listening", () => {
s.close();
resolve(true);
})
.listen(port, "0.0.0.0");
});
}
async function getFreePort(used) {
while (true) {
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
if (used.has(p)) continue;
if (await isPortFree(p)) return p;
}
}
async function generateRealityKeys() {
try {
const res = await api.get("/panel/api/server/getNewX25519Cert");
if (res.data?.success && res.data?.obj) {
return {
privateKey: res.data.obj.privateKey,
publicKey: res.data.obj.publicKey
};
}
throw new Error("Invalid key response");
} catch (e) {
console.error("Failed to get Reality keys:", e.message);
throw e;
}
}
async function uuid() {
try {
const res = await api.get("/panel/api/server/getNewUUID");
if (res.data?.success && res.data?.obj?.uuid) {
return res.data.obj.uuid;
}
throw new Error("Invalid UUID response");
} catch (e) {
console.error("Failed to get UUID:", e.message);
throw e;
}
}
// API 3x-ui
async function login() {
try {
const res = await api.post("/login", { username: UI_LOGIN, password: UI_PASSWORD });
if (res.headers['set-cookie']) cookieJar.value = res.headers['set-cookie'].join('; ');
console.log("Login success");
} catch (e) { console.error("Login failed:", e.message); throw e; }
}
async function getInbounds() {
try {
const res = await api.get("/panel/api/inbounds/list");
return res.status === 200 ? res.data.obj || [] : [];
} catch (e) { console.error("Get inbounds failed:", e.message); return []; }
}
async function deleteInbounds() {
try {
const data = fs.readFileSync(LAST_INBOUNDS, 'utf-8');
const oldIds = JSON.parse(data);
if (Array.isArray(oldIds) && oldIds.length > 0) {
console.log(`Удаляем старые инбаунды: ${oldIds.length} шт.`);
for (const id of oldIds) {
try {
await deleteInbound(id);
} catch (err) {
console.error(`Ошибка при удалении инбаунда ${id}:`, err.message);
}
}
}
} catch (error) {
console.log("Старых ID не найдено, пропускаем удаление.");
}
}
async function deleteInbound(id) {
try { await api.post(`/panel/api/inbounds/del/${id}`); } catch { }
}
async function addInbound(config) {
try {
const res = await api.post("/panel/api/inbounds/add", config);
return res.data?.obj?.id || null;
} catch (e) {
console.error("Add inbound failed:", e.message);
return null;
}
}
// Main rotation of inbound entries
async function rotate() {
await login();
await updateWhitelist();
const whitelist = loadWhitelist();
const usedPorts = new Set();
const subs = [];
await deleteInbounds();
const builders = [
async (d) => buildVlessRealityTcp({ port: await isPortFree(8443) ? 8443 : await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityXhttp({ port: await isPortFree(443) ? 443 : await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityGrpc({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessWs({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVmessTcp({ port: await getFreePort(usedPorts), uuid: await uuid() }),
async (d) => buildShadowsocksTcp({ port: await getFreePort(usedPorts), uuid: await uuid() }),
async (d) => buildTrojanRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
];
const inboundIds = [];
for (const b of builders) {
const domain = pickDomain(whitelist);
const inbound = await b(domain);
usedPorts.add(inbound.port);
const idOrPass = inbound.settings ? JSON.parse(inbound.settings).clients?.[0]?.id || JSON.parse(inbound.settings).clients?.[0]?.password : "";
// Build the link depending on the protocol
const link = buildInboundLink(inbound, UI_HOST, idOrPass);
if (link) subs.push(link);
const id = await addInbound(inbound);
inboundIds.push(id);
}
if (inboundIds.length > 0) {
fs.writeFileSync(LAST_INBOUNDS, JSON.stringify(inboundIds, null, 2));
console.log(`Новые ID (${inboundIds.length} шт.) сохранены в ${LAST_INBOUNDS}`);
}
fs.writeFileSync(SUB_FILE, subs.join("\n") + "\n", "utf8");
console.log("✔ 10 inbound created, подписка обновлена");
}
// Initial run
rotate();
let interval = parseInt(process.env.ROTATE_INTERVAL) || 30;
if (interval < 10) interval = 10;
const cronExpression = `*/${interval} * * * *`;
cron.schedule(cronExpression, rotate);
-18
View File
@@ -1,18 +0,0 @@
events {}
http {
server {
listen $NGINX_PORT;
server_name $UI_HOST;
location = /bus/$SUB_TOKEN {
alias /subscriptions/list.txt;
default_type text/plain;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
location / {
return 404;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"type": "module",
"dependencies": {
"axios": "^1.13.2",
"node-cron": "^4.2.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
build
npm-debug.log
.git
.env
Dockerfile
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+21
View File
@@ -0,0 +1,21 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV VITE_API_URL=/api
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link data-rh="true" rel="icon" href="/img/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3DP-MANAGER</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+4290
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/inter": "^5.2.8",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-qr-code": "^2.0.18",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+44
View File
@@ -0,0 +1,44 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import SubscriptionsPage from './pages/SubscriptionsPage';
import SettingsPage from './pages/SettingsPage';
import DomainsPage from './pages/DomainsPage';
import LoginPage from './pages/LoginPage';
import { ThemeProvider } from './ThemeContext';
import { AuthProvider } from './auth/AuthContext';
import RequireAuth from './auth/RequireAuth';
import NotFoundPage from './pages/NotFoundPage';
import { AxiosInterceptor } from './auth/AxiosInterceptor';
import PublicRoute from './auth/PublicRoute';
import TunnelsPage from './pages/TunnelsPage';
function App() {
return (
<AuthProvider>
<ThemeProvider>
<BrowserRouter>
<AxiosInterceptor />
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<LoginPage />} />
</Route>
<Route path="/" element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route index element={<SubscriptionsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="domains" element={<DomainsPage />} />
<Route path="tunnels" element={<TunnelsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
</AuthProvider>
);
}
export default App;
+59
View File
@@ -0,0 +1,59 @@
import React, { createContext, useState, useMemo, useContext, useEffect } from 'react';
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import useMediaQuery from '@mui/material/useMediaQuery';
import { getDesignTokens } from './theme';
type ColorMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
mode: ColorMode;
toggleColorMode: () => void;
}
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
export const useThemeContext = () => useContext(ThemeContext);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setMode] = useState<ColorMode>(() => {
return (localStorage.getItem('themeMode') as ColorMode) || 'system';
});
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
useEffect(() => {
localStorage.setItem('themeMode', mode);
}, [mode]);
const toggleColorMode = () => {
setMode((prevMode) => {
if (prevMode === 'light') return 'dark';
if (prevMode === 'dark') return 'system';
return 'light';
});
};
const theme = useMemo(() => {
let activeMode: ColorMode;
if (mode === 'system') {
activeMode = prefersDarkMode ? 'dark' : 'light';
} else {
activeMode = mode;
}
const themeOptions = getDesignTokens(activeMode);
return createTheme(themeOptions);
}, [mode, prefersDarkMode]);
return (
<ThemeContext.Provider value={{ mode, toggleColorMode }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};
+7
View File
@@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
});
export default api;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+56
View File
@@ -0,0 +1,56 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import api from '../api';
interface AuthContextType {
token: string | null;
isAuthenticated: boolean;
login: (token: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [token, setToken] = useState<string | null>(() => {
const savedToken = localStorage.getItem('token');
if (savedToken) {
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`;
}
return savedToken;
});
const login = (newToken: string) => {
localStorage.setItem('token', newToken);
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
setToken(newToken);
};
const logout = () => {
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
setToken(null);
};
useEffect(() => {
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete api.defaults.headers.common['Authorization'];
}
}, [token]);
return (
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
{children}
</AuthContext.Provider>
);
};
+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from './AuthContext';
export function AxiosInterceptor() {
const { logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const interceptor = api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
console.warn('Session expired or unauthorized. Logging out...');
logout();
navigate('/login');
}
return Promise.reject(error);
}
);
return () => {
api.interceptors.response.eject(interceptor);
};
}, [logout, navigate]);
return null;
}
+12
View File
@@ -0,0 +1,12 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './AuthContext';
export default function PublicRoute() {
const { isAuthenticated } = useAuth();
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}
+14
View File
@@ -0,0 +1,14 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
import type React from 'react';
export default function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
+79
View File
@@ -0,0 +1,79 @@
import { Box, Container, Grid, IconButton, Link, Stack } from '@mui/material';
import { GitHub, YouTube, Telegram } from '@mui/icons-material';
export default function Footer() {
return (
<Box
component="footer"
sx={{
py: 3,
px: 2,
mt: 'auto',
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
: theme.palette.grey[900],
}}
>
<Container maxWidth={false}>
<Grid container spacing={4} justifyContent="space-between" alignItems="center">
<Grid size={{ xs: 12, sm: 4 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
<Link
href="https://3dp-manager.com/docs/intro"
target="_blank"
rel="noopener"
color="text.primary"
underline="hover"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, fontWeight: 500 }}
>
Документация
</Link>
</Grid>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'right' } }}>
<Stack direction="row" spacing={1} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}>
<IconButton
component="a"
href="https://github.com/denpiligrim"
target="_blank"
aria-label="GitHub"
color="inherit"
>
<GitHub />
</IconButton>
<IconButton
component="a"
href="https://youtube.com/@denpiligrim"
target="_blank"
aria-label="YouTube"
color="inherit"
>
<YouTube />
</IconButton>
<IconButton
component="a"
href="https://t.me/denpiligrim_web"
target="_blank"
aria-label="Telegram"
color="inherit"
>
<Telegram />
</IconButton>
</Stack>
</Grid>
</Grid>
</Container>
</Box>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import {
AppBar, Toolbar, Typography, IconButton, Tooltip, Box,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, List, ListItem, ListItemText
} from '@mui/material';
import {
Brightness7, Brightness4, BrightnessAuto,
Logout, HelpOutline
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useThemeContext } from '../ThemeContext';
import { useAuth } from '../auth/AuthContext';
export default function Header() {
const { mode, toggleColorMode } = useThemeContext();
const { logout } = useAuth();
const navigate = useNavigate();
const [helpOpen, setHelpOpen] = useState(false);
const handleLogout = () => {
if (confirm('Вы действительно хотите выйти?')) {
logout();
navigate('/login');
}
};
const getThemeIcon = () => {
switch (mode) {
case 'light': return <Brightness7 />;
case 'dark': return <Brightness4 />;
case 'system': return <BrightnessAuto />;
}
};
const getThemeLabel = () => {
switch (mode) {
case 'light': return 'Светлая тема';
case 'dark': return 'Темная тема';
case 'system': return 'Системная тема';
}
};
return (
<>
<AppBar
position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
3DP-MANAGER
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Справка о программе">
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
<HelpOutline />
</IconButton>
</Tooltip>
<Tooltip title={`Режим: ${getThemeLabel()}`}>
<IconButton color="inherit" onClick={toggleColorMode}>
{getThemeIcon()}
</IconButton>
</Tooltip>
<Tooltip title="Выйти из системы">
<IconButton color="inherit" onClick={handleLogout}>
<Logout />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</AppBar>
<Dialog
open={helpOpen}
onClose={() => setHelpOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Об утилите 3DP-MANAGER</DialogTitle>
<DialogContent dividers>
<DialogContentText paragraph>
Утилита для автогенерации инбаундов к панели 3x-ui, формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной.
</DialogContentText>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Основные возможности:
</Typography>
<List dense>
<ListItem>
<ListItemText
primary="Автоматическая генерация"
secondary="Система создает новые инбаунды в заданном интервале."
/>
</ListItem>
<ListItem>
<ListItemText
primary="Управление подписками"
secondary="Создание пользователей с уникальными UUID. Одна подписка генерирует множество подключений."
/>
</ListItem>
<ListItem>
<ListItemText
primary="Белый список доменов"
secondary="Для работы инбаундов необходим список доменов, под которые маскируется трафик."
/>
</ListItem>
<ListItem>
<ListItemText
primary="Перенаправление"
secondary="Если вы используете Каскадную схему подключения, то вы сможете добавить свои промежуточные сервера."
/>
</ListItem>
</List>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Версия: 2.0.0<br />
Разработчик: DenPiligrim
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setHelpOpen(false)}>Понятно</Button>
</DialogActions>
</Dialog>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import {
Toolbar, Drawer, List, ListItem,
ListItemButton, ListItemIcon, ListItemText, Box
} from '@mui/material';
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
const drawerWidth = 240;
export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{ text: 'Подписки', icon: <People />, path: '/' },
{ text: 'Домены', icon: <Dns />, path: '/domains' },
{ text: 'Перенаправление', icon: <SwapHoriz />, path: '/tunnels' },
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
];
return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
<Header />
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100%'
}}
>
<Toolbar />
<Box sx={{ flexGrow: 1, p: 3 }}>
<Outlet />
</Box>
<Footer />
</Box>
</Box>
);
}
View File
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
import api from '../api';
interface Domain { id: number; name: string; }
export default function DomainsPage() {
const [domains, setDomains] = useState<Domain[]>([]);
const [newDomain, setNewDomain] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const loadDomains = async () => {
try {
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
setDomains(data.data);
setTotalCount(data.total);
} catch (e) {
console.error(e);
}
};
useEffect(() => {
loadDomains();
}, [page, rowsPerPage]);
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const handleAdd = async () => {
if (!newDomain) return;
await api.post('/domains', { name: newDomain });
setNewDomain('');
loadDomains();
};
const handleDelete = async (id: number) => {
await api.delete(`/domains/${id}`);
loadDomains();
};
const handleDeleteAll = async () => {
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
if (confirm('Это действие необратимо. Точно удалить?')) {
try {
await api.delete('/domains/all');
loadDomains();
} catch (_e) { alert('Ошибка удаления'); }
}
}
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target?.result as string;
if (!text) return;
const lines = text.split(/\r?\n/);
try {
const { data } = await api.post('/domains/upload', { domains: lines });
alert(`Успешно добавлено доменов: ${data.count}`);
loadDomains();
} catch (_err) {
alert('Ошибка при загрузке списка');
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
reader.readAsText(file);
};
return (
<Box>
<Typography variant="h4" gutterBottom>Белый список доменов (SNI)</Typography>
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
<TextField
label="Добавить домен" size="small" fullWidth
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
/>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: '170px' }}
onClick={() => fileInputRef.current?.click()}
>
Из файла
</Button>
<input
type="file"
accept=".txt"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</Paper>
{domains.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%' }}>
<Button
variant="text"
color="error"
size='small'
startIcon={<Remove />}
onClick={handleDeleteAll}
>
Удалить все
</Button>
</Box>
)}
<Paper>
<List>
{domains.map((d) => (
<ListItem key={d.id} secondaryAction={
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
}>
<ListItemText primary={d.name} />
</ListItem>
))}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Список пуст</Typography>}
</List>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Доменов на странице:"
labelDisplayedRows={({ from, to, count }) => `${from}${to} из ${count !== -1 ? count : `более ${to}`}`}
/>
</Paper>
</Box>
);
}
+50
View File
@@ -0,0 +1,50 @@
import React, { useState } from 'react';
import { Box, Paper, TextField, Button, Typography, Alert } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
export default function LoginPage() {
const [creds, setCreds] = useState({ login: '', password: '' });
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await api.post('/auth/login', creds);
login(res.data.access_token);
navigate('/');
} catch (e) {
setError('Неверный логин или пароль');
}
};
return (
<Box sx={{
height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'background.default'
}}>
<Paper sx={{ p: 4, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" gutterBottom align="center">Вход в 3DP-MANAGER</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<form onSubmit={handleSubmit}>
<TextField
fullWidth margin="normal" label="Логин"
value={creds.login} onChange={(e) => setCreds({ ...creds, login: e.target.value })}
/>
<TextField
fullWidth margin="normal" label="Пароль" type="password"
value={creds.password} onChange={(e) => setCreds({ ...creds, password: e.target.value })}
/>
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3 }}>
Войти
</Button>
</form>
</Paper>
</Box>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Box, Typography, Button, Container } from '@mui/material';
import { useNavigate } from 'react-router-dom';
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: 'background.default',
}}
>
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
<Typography variant="h1" color="primary" sx={{ fontWeight: 'bold' }}>
404
</Typography>
<Typography variant="h5" color="text.secondary" gutterBottom>
Страница не найдена
</Typography>
<Button
variant="contained"
size="large"
onClick={() => navigate('/')}
>
На главную
</Button>
</Container>
</Box>
);
}
+202
View File
@@ -0,0 +1,202 @@
import React, { useEffect, useState } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip } from '@mui/material';
import api from '../api';
const ROTATION_PRESETS = [
{ label: 'Сутки', value: 1440 },
{ label: '3 дня', value: 4320 },
{ label: 'Неделя', value: 10080 },
];
export default function SettingsPage() {
const [settings, setSettings] = useState({
xui_url: '',
xui_login: '',
xui_password: '',
rotation_interval: '30',
});
const [adminProfile, setAdminProfile] = useState({
login: '',
password: '',
});
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [intervalError, setIntervalError] = useState('');
useEffect(() => {
loadSettings();
}, []);
useEffect(() => {
const val = parseInt(settings.rotation_interval, 10);
if (isNaN(val) || val < 10) {
setIntervalError('Минимальный интервал — 10 минут');
} else {
setIntervalError('');
}
}, [settings.rotation_interval]);
const loadSettings = async () => {
try {
const { data } = await api.get('/settings');
setSettings((prev) => ({ ...prev, ...data }));
if (data.admin_login) {
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
}
} catch (e) {
console.error(e);
}
};
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setSettings({ ...settings, [prop]: event.target.value });
};
const handlePresetClick = (minutes: number) => {
setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() }));
};
const handleSaveSettings = async () => {
if (intervalError) {
setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' });
return;
}
try {
await api.post('/settings', settings);
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' });
}
};
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
};
const handleSaveAdmin = async () => {
try {
await api.post('/auth/update-profile', adminProfile);
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
setAdminProfile(prev => ({ ...prev, password: '' }));
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
}
};
const handleForceRotate = async () => {
if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) {
try {
await api.post('/rotation/rotate-all');
setMsg({ open: true, type: 'success', text: 'Ротация успешно выполнена!' });
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка при запуске ротации' });
}
}
};
return (
<Box>
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="URL панели"
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
helperText="Например: https://my-vpn.com:2053/panel_path"
/>
<TextField
fullWidth margin="normal" label="Логин 3x-ui"
value={settings.xui_login} onChange={handleSettingChange('xui_login')}
/>
<TextField
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
/>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Сохранить подключение
</Button>
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Интервал генерации"
type="number"
value={settings.rotation_interval}
onChange={handleSettingChange('rotation_interval')}
slotProps={{
input: { endAdornment: <InputAdornment position="end">мин</InputAdornment> }
}}
helperText="Как часто менять инбаунды (минимум 10 мин)"
/>
<Stack direction="row" spacing={1} sx={{ mt: 1, mb: 2 }}>
{ROTATION_PRESETS.map((preset) => (
<Chip
key={preset.value}
label={preset.label}
onClick={() => handlePresetClick(preset.value)}
color={settings.rotation_interval === preset.value.toString() ? "primary" : "default"}
variant={settings.rotation_interval === preset.value.toString() ? "filled" : "outlined"}
clickable
/>
))}
</Stack>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Применить интервал
</Button>
<Button
variant="outlined"
color="warning"
onClick={handleForceRotate}
sx={{ mt: 2, ml: 2 }}
>
Сгенерировать сейчас
</Button>
</Paper>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Логин администратора"
value={adminProfile.login}
onChange={handleAdminChange('login')}
/>
<TextField
fullWidth margin="normal" label="Новый пароль" type="password"
value={adminProfile.password}
onChange={handleAdminChange('password')}
helperText="Оставьте пустым, если не хотите менять"
/>
<Button variant="contained" color="warning" sx={{ mt: 2 }} onClick={handleSaveAdmin}>
Обновить профиль
</Button>
</Paper>
</Box>
</Grid>
</Grid>
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({ ...msg, open: false })}>
<Alert severity={msg.type}>{msg.text}</Alert>
</Snackbar>
</Box>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions,
FormControl,
Select,
InputAdornment,
MenuItem,
type SelectChangeEvent
} from '@mui/material';
import { Delete, Add, Link as LinkIcon, Refresh, OpenInNew, ContentCopy, Dns, Router } from '@mui/icons-material';
import api from '../api';
interface Subscription {
id: string;
name: string;
uuid: string;
inbounds: any[];
}
interface Tunnel {
id: number;
name: string;
ip: string;
domain: string;
isInstalled: boolean;
}
const patchLink = function (link: string, newHost: string): string {
if (link.startsWith('vmess://')) {
try {
const base64Part = link.substring(8);
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
const config = JSON.parse(jsonStr);
config.add = newHost;
const newJsonStr = JSON.stringify(config);
const newBase64 = Buffer.from(newJsonStr).toString('base64');
return `vmess://${newBase64}`;
} catch (e) {
return link;
}
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
return link.replace(/@.*?:/, `@${newHost}:`);
} else if (link.startsWith('ss://')) {
if (link.includes('@')) {
return link.replace(/@.*?:/, `@${newHost}:`);
}
return link;
}
return link;
}
export default function SubscriptionsPage() {
const [subs, setSubs] = useState<Subscription[]>([]);
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [selectedServer, setSelectedServer] = useState<string | number>('main');
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
useEffect(() => { loadSubs(); }, []);
const loadSubs = async () => {
const { data } = await api.get('/subscriptions');
setSubs(data);
const tunnelsRes = await api.get('/tunnels');
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
};
const handleCreate = async () => {
await api.post('/subscriptions', { name });
setOpen(false);
setName('');
loadSubs();
};
const handleDelete = async (id: string) => {
if (confirm('Удалить подписку и все соединения?')) {
await api.delete(`/subscriptions/${id}`);
loadSubs();
}
};
const showLinks = (sub: Subscription) => {
let links = [];
if (selectedServer === 'main') {
links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
} else {
const host = tunnels[+selectedServer - 1].domain.length > 0 ? tunnels[+selectedServer - 1].domain : tunnels[+selectedServer - 1].ip;
links = sub.inbounds?.map(i => patchLink(i.link, host)).filter(Boolean) || [];
}
if (links.length === 0) {
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
} else {
setCurrentLinks(links);
}
setLinksOpen(true);
};
const handleServerChange = (event: SelectChangeEvent<any>) => {
setSelectedServer(event.target.value as string);
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Подписки</Typography>
{tunnels.length > 0 && (
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
<Select
labelId="server-select-label"
value={selectedServer}
onChange={handleServerChange}
startAdornment={
<InputAdornment position="start">
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
</InputAdornment>
}
>
<MenuItem value="main">
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</Box>
</MenuItem>
{tunnels.map((t) => (
<MenuItem key={t.id} value={t.id.toString()}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
)}
<Box>
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
</Box>
</Box>
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Имя</TableCell>
<TableCell>UUID</TableCell>
<TableCell>Инбаунды</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{subs.map((sub) => (
<TableRow key={sub.id}>
<TableCell sx={{ fontWeight: 700 }}>{sub.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
<TableCell>{sub.inbounds?.length || 0}</TableCell>
<TableCell align="right">
<IconButton
color="primary"
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
title="Копировать ссылку"
>
<ContentCopy />
</IconButton>
<IconButton
color="primary"
onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
title="Открыть подписку"
>
<OpenInNew />
</IconButton>
<IconButton color="primary" onClick={() => showLinks(sub)} title="Показать конфиги">
<LinkIcon />
</IconButton>
<IconButton color="primary" onClick={() => handleDelete(sub.id)} title="Удалить">
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новая подписка</DialogTitle>
<DialogContent>
<TextField
autoFocus margin="dense" label="Имя пользователя" fullWidth
value={name} onChange={(e) => setName(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button onClick={handleCreate}>Создать</Button>
</DialogActions>
</Dialog>
<Dialog open={linksOpen} onClose={() => setLinksOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Активные ссылки</DialogTitle>
<DialogContent>
<TextField
multiline fullWidth rows={10}
value={currentLinks.join('\n\n')}
slotProps={{ input: { readOnly: true, sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => navigator.clipboard.writeText(currentLinks.join('\n'))}>Копировать всё</Button>
<Button onClick={() => setLinksOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+145
View File
@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, Chip, CircularProgress
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import api from '../api';
interface Tunnel {
id: number;
name: string;
ip: string;
sshPort: number;
username: string;
isInstalled: boolean;
}
export default function TunnelsPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [open, setOpen] = useState(false);
const [loadingId, setLoadingId] = useState<number | null>(null);
const [form, setForm] = useState({
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
});
useEffect(() => { loadTunnels(); }, []);
const loadTunnels = async () => {
try {
const { data } = await api.get('/tunnels');
setTunnels(data);
} catch (e) { console.error(e); }
};
const handleCreate = async () => {
await api.post('/tunnels', form);
setOpen(false);
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
loadTunnels();
};
const handleDelete = async (id: number) => {
if (confirm('Удалить сервер из списка?')) {
await api.delete(`/tunnels/${id}`);
loadTunnels();
}
};
const handleInstall = async (id: number) => {
if (!confirm('Начать установку перенаправления на этот сервер?')) return;
setLoadingId(id);
try {
await api.post(`/tunnels/${id}/install`);
alert('Скрипт успешно установлен! Трафик перенаправляется.');
loadTunnels();
} catch (e: any) {
alert('Ошибка: ' + (e.response?.data?.message || e.message));
} finally {
setLoadingId(null);
}
};
const handleChange = (prop: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [prop]: e.target.value });
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Relay серверы</Typography>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
</Box>
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Адрес</TableCell>
<TableCell>Статус</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tunnels.map((t) => (
<TableRow key={t.id}>
<TableCell>{t.name}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Dns fontSize="small" color="action" />
{t.ip}
</Box>
</TableCell>
<TableCell>
{t.isInstalled ?
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" /> :
<Chip icon={<Error />} label="Не настроен" color="warning" size="small" variant="outlined" />
}
</TableCell>
<TableCell align="right">
{!t.isInstalled && (
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null}
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
{loadingId === t.id ? 'Установка...' : 'Установить'}
</Button>
)}
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center">Список пуст</TableCell></TableRow>}
</TableBody>
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новый редирект сервер</DialogTitle>
<DialogContent>
<TextField margin="dense" label="Название" fullWidth value={form.name} onChange={handleChange('name')} />
<TextField margin="dense" label="IP адрес" fullWidth value={form.ip} onChange={handleChange('ip')} />
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
</Box>
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button variant="contained" onClick={handleCreate}>Сохранить</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+147
View File
@@ -0,0 +1,147 @@
import type { PaletteMode } from '@mui/material';
const lightPalette = {
primary: {
main: '#2563eb',
light: '#60a5fa',
dark: '#1e40af',
},
secondary: {
main: '#7c3aed',
},
background: {
default: '#f3f4f6',
paper: '#ffffff',
},
text: {
primary: '#111827',
secondary: '#6b7280',
},
};
const darkPalette = {
primary: {
main: '#3b82f6',
light: '#60a5fa',
dark: '#1d4ed8',
},
secondary: {
main: '#8b5cf6',
},
background: {
default: '#0B0F19',
paper: '#111827',
},
text: {
primary: '#f9fafb',
secondary: '#9ca3af',
},
};
export const getDesignTokens = (mode: PaletteMode) => ({
palette: {
mode,
...(mode === 'light' ? lightPalette : darkPalette),
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h1: { fontWeight: 700 },
h2: { fontWeight: 700 },
h3: { fontWeight: 600 },
h4: { fontWeight: 600 },
h5: { fontWeight: 600 },
h6: { fontWeight: 600 },
button: {
textTransform: 'none' as const,
fontWeight: 600,
},
},
shape: {
borderRadius: 12,
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarColor: mode === 'dark' ? '#374151 #111827' : '#d1d5db #f3f4f6',
'&::-webkit-scrollbar, & *::-webkit-scrollbar': {
backgroundColor: 'transparent',
width: '8px',
height: '8px',
},
'&::-webkit-scrollbar-thumb, & *::-webkit-scrollbar-thumb': {
borderRadius: 8,
backgroundColor: mode === 'dark' ? '#374151' : '#d1d5db',
minHeight: 24,
border: '2px solid transparent',
backgroundClip: 'content-box',
},
'&::-webkit-scrollbar-thumb:focus, & *::-webkit-scrollbar-thumb:focus': {
backgroundColor: mode === 'dark' ? '#4b5563' : '#9ca3af',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
boxShadow: 'none',
'&:hover': {
boxShadow: 'none',
},
},
containedPrimary: {
'&:hover': {
backgroundColor: mode === 'dark' ? '#2563eb' : '#1d4ed8',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
elevation1: {
boxShadow: mode === 'light'
? '0px 2px 4px -1px rgba(0,0,0,0.05), 0px 4px 6px -1px rgba(0,0,0,0.05)'
: '0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 6px -1px rgba(0,0,0,0.2)',
border: mode === 'light' ? '1px solid #e5e7eb' : '1px solid #374151',
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: mode === 'light' ? '#e5e7eb' : '#374151',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: mode === 'light' ? '#9ca3af' : '#6b7280',
},
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: mode === 'light' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(17, 24, 39, 0.8)',
backdropFilter: 'blur(8px)',
borderBottom: `1px solid ${mode === 'light' ? '#e5e7eb' : '#374151'}`,
boxShadow: 'none',
color: mode === 'light' ? '#111827' : '#f9fafb',
},
},
},
MuiTableRow: {
styleOverrides: {
root: {
"&:last-child td": {
borderBottom: 0,
},
}
}
}
},
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "../server/src/auth"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()]
})
+1 -1
View File
@@ -17,7 +17,7 @@ need_root() {
[[ $EUID -eq 0 ]] || die "Запускать только от root"
}
read -r -p "Вы уверены, что хотите удалить? (y/N): " answer
read -r -p "Вы уверены, что хотите удалить? (y/n): " answer
case "$answer" in
y|Y)
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:18-alpine
container_name: 3dp-postgres
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
POSTGRES_DB: ${POSTGRES_DB:-3dp_manager}
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- app-network
backend:
build: ./server
container_name: 3dp-backend
restart: always
depends_on:
- postgres
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: ${POSTGRES_USER:-admin}
DB_PASSWORD: ${POSTGRES_PASSWORD:-admin}
DB_NAME: ${POSTGRES_DB:-3dp_manager}
JWT_SECRET: ${JWT_SECRET:-secretKey}
ports:
- "3000:3000"
networks:
- app-network
frontend:
build: ./client
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "80:80"
networks:
- app-network
volumes:
pg_data:
networks:
app-network:
driver: bridge
+1 -1
View File
@@ -14,7 +14,7 @@ warn() { echo -e "\033[1;33m[WARN]\033[0m $1"; }
[[ $EUID -eq 0 ]] || { echo "Запускать нужно от root"; exit 1; }
read -r -p "Вы уверены, что хотите удалить? (y/N): " answer
read -r -p "Вы уверены, что хотите удалить? (y/n): " answer
case "$answer" in
y|Y)
+8 -284
View File
@@ -15,6 +15,12 @@ die() { echo -e "\033[1;31m[ERROR]\033[0m $1"; exit 1; }
[[ $EUID -eq 0 ]] || die "Запускать нужно от root"
# Check OS and set release variable
. /etc/os-release
if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
die "Этот скрипт поддерживает только Ubuntu или Debian: $ID"
fi
#################################
# ASCII-баннер
#################################
@@ -30,287 +36,10 @@ echo " 3DP-MANAGER SUBSCRIPTION FORWARDER "
echo "==================================================="
echo ""
#################################
# INPUT
#################################
read -rp "URL исходной подписки: " ORIGIN_SUB_URL
[[ "$ORIGIN_SUB_URL" =~ ^https?:// ]] || die "Некорректный URL подписки"
read -rp "Домен или IP этого сервера (Enter = авто IP): " LOCAL_HOST
if [[ -z "$LOCAL_HOST" ]]; then
LOCAL_HOST=$(hostname -I | awk '{print $1}' | tr -d '[:space:]')
fi
#################################
# PARSE URL
#################################
ORIGIN_PROTO=$(echo "$ORIGIN_SUB_URL" | awk -F: '{print $1}')
ORIGIN_HOST=$(echo "$ORIGIN_SUB_URL" | awk -F[/:] '{print $4}')
ORIGIN_PORT=$(echo "$ORIGIN_SUB_URL" | awk -F[:] '{print $3}' | awk -F/ '{print $1}')
SUB_PATH="/$(echo "$ORIGIN_SUB_URL" | cut -d/ -f4-)"
#################################
# Определяем ORIGIN_IP
#################################
if [[ "$ORIGIN_HOST" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
ORIGIN_IP="$ORIGIN_HOST"
log "Используем прямой IP из URL: $ORIGIN_IP"
else
log "Разрешаем домен $ORIGIN_HOST в IP..."
ORIGIN_IP=$(getent ahosts "$ORIGIN_HOST" | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $1; exit}')
[[ -z "$ORIGIN_IP" ]] && ORIGIN_IP=$(getent hosts "$ORIGIN_HOST" | awk '{print $1; exit}')
if [[ -z "$ORIGIN_IP" ]] && command -v dig >/dev/null; then
ORIGIN_IP=$(dig +short "$ORIGIN_HOST" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
fi
[[ -n "$ORIGIN_IP" ]] || die "Не удалось разрешить домен в IP: $ORIGIN_HOST. Проверьте DNS или укажите IP вручную."
log "Домен $ORIGIN_HOST разрешён в IP: $ORIGIN_IP"
fi
[[ -n "$ORIGIN_IP" && -n "$ORIGIN_PORT" && -n "$SUB_PATH" ]] || die "Ошибка парсинга URL подписки"
#################################
# HTTPS CHECK
#################################
USE_HTTPS=false
CERT_PATH=""
KEY_PATH=""
if [[ "$LOCAL_HOST" != "$(hostname -I | awk '{print $1}' | tr -d '[:space:]')" ]]; then
if [[ -d "/etc/letsencrypt/live/$LOCAL_HOST" ]]; then
CERT_PATH="/etc/letsencrypt/live/$LOCAL_HOST/fullchain.pem"
KEY_PATH="/etc/letsencrypt/live/$LOCAL_HOST/privkey.pem"
USE_HTTPS=true
log "Найдены сертификаты Let's Encrypt"
else
warn "Сертификаты Let's Encrypt не найдены"
read -rp "Путь к fullchain.pem (Enter = HTTP): " CERT_PATH
if [[ -n "$CERT_PATH" ]]; then
read -rp "Путь к privkey.pem: " KEY_PATH
[[ -f "$CERT_PATH" && -f "$KEY_PATH" ]] || die "Файлы сертификатов не найдены"
USE_HTTPS=true
fi
fi
fi
#################################
# PROJECT DIR
#################################
BASE_DIR="/opt/3dp-manager"
NODE_DIR="$BASE_DIR/node"
mkdir -p "$NODE_DIR"
cd "$BASE_DIR"
#################################
# DOWNLOAD FILES
#################################
REPO="https://raw.githubusercontent.com/denpiligrim/3dp-manager/main"
NGINX_PORT=$ORIGIN_PORT
if [[ "$USE_HTTPS" == "true" ]]; then
NGINX_PROTO="https"
else
NGINX_PROTO="http"
fi
SUB_URL="$NGINX_PROTO://$LOCAL_HOST:$NGINX_PORT$SUB_PATH"
#################################
# ENV
#################################
cat > .env <<EOF
ORIGIN_SUB_URL=$ORIGIN_SUB_URL
ORIGIN_HOST=$ORIGIN_HOST
ORIGIN_PORT=$ORIGIN_PORT
LOCAL_HOST=$LOCAL_HOST
SUB_URL=$SUB_URL
EOF
#################################
# node files
#################################
cat > node/package.json <<EOF
{
"type": "module",
"dependencies": {
"axios": "^1.13.2",
"express": "^5.2.1"
}
}
EOF
cat > node/Dockerfile <<EOF
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install --production
COPY index.js .
CMD ["node", "index.js"]
EOF
cat > node/index.js <<'EOF'
import express from "express";
import axios from "axios";
const app = express();
const {
ORIGIN_SUB_URL,
LOCAL_HOST
} = process.env;
app.get("/bus/:token", async (req, res) => {
try {
const url = ORIGIN_SUB_URL;
const r = await axios.get(url, { timeout: 15000 });
let data = r.data;
// vmess base64
const lines = data.split("\n").map(l => {
if (l.startsWith("vmess://")) {
const obj = JSON.parse(Buffer.from(l.slice(8), "base64").toString());
obj.add = LOCAL_HOST;
return "vmess://" + Buffer.from(JSON.stringify(obj)).toString("base64");
}
// остальные протоколы — замена хоста
return l.replace(/@([^:/?#]+)/, `@${LOCAL_HOST}`);
});
res.type("text/plain").send(lines.join("\n"));
} catch (e) {
res.status(500).send("subscription error");
}
});
app.listen(3000, () => {
console.log("sub-forwarder started");
});
EOF
#################################
# NGINX & DOCKER COMPOSE
#################################
if $USE_HTTPS; then
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT ssl;
server_name $LOCAL_HOST;
ssl_certificate $CERT_PATH;
ssl_certificate_key $KEY_PATH;
location $SUB_PATH {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
}
}
EOF
cat > docker-compose.yml <<EOF
services:
node:
build: ./node
env_file: .env
restart: unless-stopped
network_mode: host
container_name: node
nginx:
image: nginx:alpine
restart: unless-stopped
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- $CERT_PATH:$CERT_PATH:ro
- $KEY_PATH:$KEY_PATH:ro
network_mode: host
depends_on:
- node
EOF
else
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT;
server_name $LOCAL_HOST;
location $SUB_PATH {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
}
}
EOF
cat > docker-compose.yml <<EOF
services:
node:
build: ./node
env_file: .env
restart: unless-stopped
network_mode: host
container_name: node
nginx:
image: nginx:alpine
restart: unless-stopped
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
network_mode: host
depends_on:
- node
EOF
fi
#################################
# DOCKER
#################################
log "Проверка Docker"
if command -v docker >/dev/null 2>&1; then
log "Docker уже установлен"
else
log "Docker не найден, будет установлен"
apt update
apt install -y docker.io || die "Ошибка установки docker.io"
systemctl enable docker
systemctl restart docker
fi
if docker compose version >/dev/null 2>&1; then
log "docker compose v2 доступен"
else
log "Устанавливаем docker-compose-v2"
apt install -y --allow-downgrades --allow-remove-essential --allow-change-held-packages \
docker-compose-v2 || warn "docker-compose-v2 не установлен, возможно уже есть плагин"
fi
#################################
# START
#################################
docker compose up -d --build
ORIGIN_IP=${ORIGIN_IP:-false}
#################################
# UFW NAT
@@ -365,8 +94,6 @@ cat <<EOF > /tmp/ufw_nat_rules
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
# Исключаем порт Nginx менеджера
-A PREROUTING -p tcp --dport $NGINX_PORT -j RETURN
# Проброс портов
-A PREROUTING -p tcp -m multiport --dports 443,8443,10000:60000 -j DNAT --to-destination $ORIGIN_IP
-A PREROUTING -p udp -m multiport --dports 443,8443,10000:60000 -j DNAT --to-destination $ORIGIN_IP
@@ -407,7 +134,6 @@ ufw allow 443/tcp
ufw allow 443/udp
ufw allow 8443/tcp
ufw allow 8443/udp
ufw allow "$NGINX_PORT"/tcp
ufw allow 10000:60000/tcp
ufw allow 10000:60000/udp
@@ -422,6 +148,4 @@ echo "Готово! Система оптимизирована, порты от
# RESULT
#################################
echo
log "Готово"
echo "Подписка:"
echo "$SUB_URL"
log "Готово"
+378 -282
View File
@@ -2,28 +2,20 @@
set -euo pipefail
#################################
# DEBUG TRAP
# КОНФИГУРАЦИЯ И ПЕРЕМЕННЫЕ
#################################
trap 'echo -e "\033[1;31m[ERROR]\033[0m Ошибка в строке $LINENO"; exit 1' ERR
REPO_URL="https://github.com/denpiligrim/3dp-manager/archive/refs/heads/main.tar.gz"
PROJECT_DIR="/opt/3dp-manager"
#################################
# HELPER FUNCTIONS
#################################
log() { echo -e "\033[1;32m[INFO]\033[0m $1"; }
die() { echo -e "\033[1;31m[ERROR]\033[0m $1"; exit 1; }
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
need_root() {
[[ $EUID -eq 0 ]] || die "Запускать только от root"
}
validate_url() {
[[ "$1" =~ ^https?://[^/]+:[0-9]+/.+ ]]
}
#################################
# CHECKS
#################################
need_root
log() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
#################################
# ASCII-баннер
@@ -41,323 +33,427 @@ echo "==================================================="
echo ""
#################################
# INPUT / USER DATA
# ПРОВЕРКИ И УСТАНОВКА ЗАВИСИМОСТЕЙ
#################################
# Function to get panel URL from 3x-ui
get_xui_url() {
local output=$(x-ui settings 2>/dev/null)
echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "Access URL:" | grep -oE 'https?://[^[:space:]]+' | head -n1
}
echo "Определяем URL панели 3x-ui..."
UI_URL=$(get_xui_url)
if [[ -z "$UI_URL" ]]; then
echo "Не удалось автоматически получить URL"
read -rp "Введите URL панели 3x-ui вручную: " UI_URL
if [[ $EUID -ne 0 ]]; then
error "Этот скрипт должен быть запущен от имени root"
fi
UI_URL=$(echo "$UI_URL" | sed -E 's/[[:space:]]*$//; s|/*$||')
echo "URL панели 3x-ui: $UI_URL"
# Validate URL correctness
validate_url "$UI_URL" || die "Некорректный URL панели 3x-ui: $UI_URL"
read -rp "Логин 3x-ui: " UI_LOGIN
read -rsp "Пароль 3x-ui: " UI_PASSWORD
echo
[[ -z "$UI_LOGIN" || -z "$UI_PASSWORD" ]] && die "Логин/пароль обязательны"
# Check login
LOGIN_RESPONSE=$(curl -s -X POST "$UI_URL/login" -H "Content-Type: application/json" -d "{\"username\":\"$UI_LOGIN\",\"password\":\"$UI_PASSWORD\"}")
# Проверка успешного логина по ключевому полю "success" или наличию куки
if ! echo "$LOGIN_RESPONSE" | grep -q '"success":true'; then
echo "Не удалось залогиниться в 3x-ui. Проверьте URL, логин и пароль."
exit 1
. /etc/os-release
if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
die "Этот скрипт поддерживает только Ubuntu или Debian: $ID"
fi
echo "✔ Успешный логин в 3x-ui"
# Parse UI_URL
UI_HOST=$(echo "$UI_URL" | awk -F[/:] '{print $4}') # domain or IP
UI_PROTO=$(echo "$UI_URL" | awk -F: '{print $1}') # http or https
if [[ "$UI_PROTO" == "https" ]]; then
log "HTTPS панель обнаружена, проверяем SSL сертификаты"
DEFAULT_CERT="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
DEFAULT_KEY="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
if [[ -f "$DEFAULT_CERT" && -f "$DEFAULT_KEY" ]]; then
CERT_PATH="$DEFAULT_CERT"
KEY_PATH="$DEFAULT_KEY"
log "Найдены сертификаты Let's Encrypt для $UI_HOST"
else
log "⚠ Сертификаты Let's Encrypt для $UI_HOST не найдены"
read -rp "Введите полный путь к SSL сертификату (fullchain.pem): " CERT_PATH
read -rp "Введите полный путь к SSL ключу (privkey.pem): " KEY_PATH
[[ -f "$CERT_PATH" ]] || die "Файл сертификата не найден: $CERT_PATH"
[[ -f "$KEY_PATH" ]] || die "Файл ключа не найден: $KEY_PATH"
fi
if [ $(free -m | grep Mem: | awk '{print $2}') -lt 2000 ]; then
if [ $(free -m | grep Swap: | awk '{print $2}') -eq 0 ]; then
log "Мало RAM и нет Swap. Создаем swap-файл 2GB..."
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
log "Swap создан."
fi
fi
log "Проверка зависимостей..."
if ! command -v curl &> /dev/null; then apt-get update && apt-get install -y curl; fi
if ! command -v jq &> /dev/null; then apt-get install -y jq; fi
if ! command -v openssl &> /dev/null; then apt-get install -y openssl; fi
if ! command -v tar &> /dev/null; then apt-get install -y tar; fi
if ! command -v hostname &> /dev/null; then apt-get install -y net-tools || apt-get install -y hostname; fi
# Input rotation interval
read -rp "Интервал генерации инбаундов в минутах (от 10, по умолчанию 30): " ROTATE_INTERVAL
ROTATE_INTERVAL="${ROTATE_INTERVAL:-30}" # по умолчанию 30
# Check that it's a number and ≥10
if ! [[ "$ROTATE_INTERVAL" =~ ^[0-9]+$ ]] || [ "$ROTATE_INTERVAL" -lt 10 ]; then
echo "Неверное значение. Используется значение по умолчанию 30 минут."
ROTATE_INTERVAL=30
fi
echo "Интервал ротации установлен: $ROTATE_INTERVAL минут"
PROJECT_DIR="/opt/3dp-manager"
log "Используется директория проекта: $PROJECT_DIR"
mkdir -p "$PROJECT_DIR"
cd "$PROJECT_DIR"
#################################
# Get country flag
#################################
REPO_BASE="https://raw.githubusercontent.com/denpiligrim/3dp-manager/main"
COUNTRY_FLAG=""
# Get countryCode
IP_JSON=$(curl -s --fail http://ip-api.com/json/ || true)
[ -z "$IP_JSON" ] && exit 0
COUNTRY_CODE=$(echo "$IP_JSON" | jq -r '.countryCode // empty' 2>/dev/null)
[ -z "$COUNTRY_CODE" ] && exit 0
# Flags JSON URL
FLAGS_JSON_URL="$REPO_BASE/app/assets/flags.json"
FLAGS_JSON=$(curl -s "$FLAGS_JSON_URL" || true)
[ -z "$FLAGS_JSON" ] && exit 0
# Ищем emoji
COUNTRY_FLAG=$(echo "$FLAGS_JSON" | jq -r --arg code "$COUNTRY_CODE" '.[] | select(.code == $code) | .emoji // empty' 2>/dev/null | head -n1)
#################################
# Whitelist
#################################
curl -fsSL "$REPO_BASE/whitelist.txt" -o whitelist.txt
log "whitelist.txt скопирован"
#################################
# TOKEN GENERATION
#################################
SUB_TOKEN="$(openssl rand -hex 24)"
log "Сгенерирован токен подписки"
#################################
# DOCKER
#################################
# Установка Docker
log "Проверка Docker"
if command -v docker >/dev/null 2>&1; then
log "Docker уже установлен"
else
log "Docker не найден, будет установлен"
log "Docker не найден, будет установлен из официального репозитория"
# Add Docker's official GPG key:
apt update
apt install -y docker.io || die "Ошибка установки docker.io"
apt install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
if [[ "$ID" == "ubuntu" ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc || die "Ошибка добавления ключа Docker"
else
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc || die "Ошибка добавления ключа Docker"
fi
chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
CODENAME=${UBUNTU_CODENAME:-$VERSION_CODENAME}
tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/$ID
Suites: $CODENAME
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt update
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
systemctl restart docker
systemctl start docker
fi
# Check docker compose v2
if docker compose version >/dev/null 2>&1; then
log "docker compose v2 доступен"
#################################
# ЗАГРУЗКА ПРОЕКТА
#################################
log "Подготовка директории $PROJECT_DIR..."
mkdir -p "$PROJECT_DIR"
log "Скачивание последней версии проекта..."
curl -L "$REPO_URL" | tar xz -C "$PROJECT_DIR" --strip-components=1
cd "$PROJECT_DIR"
#################################
# СБОР ДАННЫХ
#################################
read -rp "Введите домен сервера (если пропустить, будет использоваться IP без HTTPS): " INPUT_HOST
USE_SSL=false
CERT_PATH=""
KEY_PATH=""
SKIP_SSL_SETUP=false
if [ -z "$INPUT_HOST" ]; then
UI_HOST=$(hostname -I | awk '{print $1}')
log "Домен не указан. Используется локальный IP: $UI_HOST"
log "Режим HTTPS принудительно отключен для IP-адреса."
USE_SSL=false
SKIP_SSL_SETUP=true
else
log "Устанавливаем docker-compose-v2"
# решаем конфликт с docker-compose-plugin
apt install -y --allow-downgrades --allow-remove-essential --allow-change-held-packages \
docker-compose-v2 || warn "docker-compose-v2 не установлен, возможно уже есть плагин"
UI_HOST=$INPUT_HOST
SKIP_SSL_SETUP=false
fi
#################################
# STRUCTURE
#################################
mkdir -p app app/builders subscriptions
# --- 2. Настройка SSL (Только если введен домен) ---
if [[ "$SKIP_SSL_SETUP" == "false" ]]; then
# Пытаемся автоматически найти сертификаты Let's Encrypt
LE_CERT="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
LE_KEY="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
if [[ -f "$LE_CERT" && -f "$LE_KEY" ]]; then
log "Найдены сертификаты Let's Encrypt."
USE_SSL=true
CERT_PATH="$LE_CERT"
KEY_PATH="$LE_KEY"
else
# Спрашиваем пользователя, если авто-поиск не дал результата
read -rp "Использовать SSL (свои сертификаты)? (y/n): " ssl_ans
if [[ "$ssl_ans" =~ ^[Yy]$ ]]; then
read -rp "Путь к fullchain.pem: " user_cert
read -rp "Путь к privkey.pem: " user_key
if [[ -f "$user_cert" && -f "$user_key" ]]; then
USE_SSL=true
CERT_PATH="$user_cert"
KEY_PATH="$user_key"
else
warn "Файлы сертификатов не найдены. Будет использоваться HTTP."
fi
fi
fi
fi
# Generate a random free port for subscription/Nginx
get_random_port() {
while :; do
PORT=$((RANDOM % 50000 + 10000)) # range 10000-60000
PORT=$((RANDOM % 4000 + 3000))
if ! ss -ltn | awk '{print $4}' | grep -q ":$PORT\$"; then
echo "$PORT"
return
fi
done
}
NGINX_PORT=$(get_random_port)
FINAL_PORT=$(get_random_port)
# Generate subscription URL
SUB_URL="$UI_PROTO://$UI_HOST:$NGINX_PORT/bus/$SUB_TOKEN"
# --- 4. Генерация паролей ---
DB_PASS=$(openssl rand -base64 12)
JWT_SECRET=$(openssl rand -base64 32)
log "Сгенерированы секретные ключи для БД и JWT."
#################################
# ENV
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
#################################
cat > .env <<EOF
SUB_TOKEN=$SUB_TOKEN
UI_URL=$UI_URL
UI_LOGIN=$UI_LOGIN
UI_PASSWORD=$UI_PASSWORD
COUNTRY_FLAG=$COUNTRY_FLAG
NGINX_PORT=$NGINX_PORT
UI_HOST=$UI_HOST
UI_PROTO=$UI_PROTO
ROTATE_INTERVAL=$ROTATE_INTERVAL
SUB_URL=$SUB_URL
# --- 1. Dockerfile для Client ---
cat > client/Dockerfile <<EOF
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV VITE_API_URL=/api
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-client.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
#################################
# Dockerfile
#################################
curl -fsSL "$REPO_BASE/app/Dockerfile" -o app/Dockerfile
#################################
# package.json
#################################
curl -fsSL "$REPO_BASE/app/package.json" -o app/package.json
#################################
# JS Files
#################################
curl -fsSL "$REPO_BASE/app/index.js" -o app/index.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessRealityTcp.js" -o app/builders/buildVlessRealityTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessRealityXhttp.js" -o app/builders/buildVlessRealityXhttp.js
curl -fsSL "$REPO_BASE/app/builders/buildTrojanRealityTcp.js" -o app/builders/buildTrojanRealityTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildShadowsocksTcp.js" -o app/builders/buildShadowsocksTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildVmessTcp.js" -o app/builders/buildVmessTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessRealityGrpc.js" -o app/builders/buildVlessRealityGrpc.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessWs.js" -o app/builders/buildVlessWs.js
curl -fsSL "$REPO_BASE/app/builders/buildInboundLink.js" -o app/builders/buildInboundLink.js
#################################
# NGINX & DOCKER COMPOSE
#################################
# Generate nginx.conf
if [[ "$UI_PROTO" == "https" ]]; then
cat > docker-compose.yml <<EOF
services:
node:
build: ./app
env_file: .env
container_name: node
volumes:
- ./subscriptions:/subscriptions
- ./whitelist.txt:/app/whitelist.txt
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on: [node]
ports:
- "$NGINX_PORT:$NGINX_PORT"
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./subscriptions:/subscriptions
- $CERT_PATH:$CERT_PATH:ro
- $KEY_PATH:$KEY_PATH:ro
# --- 3. Dockerfile для Server ---
cat > server/Dockerfile <<EOF
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/main"]
EOF
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT ssl;
cat > server/.env <<EOF
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=admin
DB_PASSWORD=${DB_PASS}
DB_NAME=3dp_manager
EOF
if [[ "$USE_SSL" == "true" ]]; then
# === ВАРИАНТ С SSL ===
# 1. Nginx Config
cat > client/nginx-client.conf <<EOF
server {
listen $FINAL_PORT ssl;
server_name $UI_HOST;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50M;
ssl_certificate $CERT_PATH;
ssl_certificate_key $KEY_PATH;
location = /bus/$SUB_TOKEN {
alias /subscriptions/list.txt;
default_type text/plain;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
return 404;
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
}
server {
listen 3000 ssl;
server_name $UI_HOST;
client_max_body_size 50M;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://backend:3000/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
}
EOF
# 2. Docker Compose
cat > docker-compose.yml <<EOF
services:
postgres:
image: postgres:18-alpine
container_name: 3dp-postgres
restart: always
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: 3dp_manager
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
backend:
build: ./server
container_name: 3dp-backend
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: admin
DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager
JWT_SECRET: ${JWT_SECRET}
PORT: 3000
networks:
- app-network
frontend:
build: ./client
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "${FINAL_PORT}:${FINAL_PORT}"
- "3000:3000"
volumes:
- ${CERT_PATH}:/etc/nginx/certs/fullchain.pem:ro
- ${KEY_PATH}:/etc/nginx/certs/privkey.pem:ro
networks:
- app-network
volumes:
pg_data:
networks:
app-network:
driver: bridge
EOF
else
cat > docker-compose.yml <<EOF
services:
node:
build: ./app
env_file: .env
container_name: node
volumes:
- ./subscriptions:/subscriptions
- ./whitelist.txt:/app/whitelist.txt
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on: [node]
ports:
- "$NGINX_PORT:$NGINX_PORT"
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./subscriptions:/subscriptions
EOF
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT;
server_name $UI_HOST;
location = /bus/$SUB_TOKEN {
alias /subscriptions/list.txt;
default_type text/plain;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
# === ВАРИАНТ БЕЗ SSL (HTTP) ===
# 1. Nginx Config
cat > client/nginx-client.conf <<EOF
server {
listen ${FINAL_PORT};
server_name localhost;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50M;
location / {
return 404;
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_cache_bypass \$http_upgrade;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
}
}
server {
listen 3000;
server_name localhost;
location / {
proxy_pass http://backend:3000/;
proxy_set_header Host \$host;
}
}
EOF
# 2. Docker Compose
cat > docker-compose.yml <<EOF
services:
postgres:
image: postgres:18-alpine
container_name: 3dp-postgres
restart: always
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: 3dp_manager
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
backend:
build: ./server
container_name: 3dp-backend
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: admin
DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager
JWT_SECRET: ${JWT_SECRET}
PORT: 3000
networks:
- app-network
frontend:
build: ./client
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "${FINAL_PORT}:${FINAL_PORT}"
- "3000:3000"
networks:
- app-network
volumes:
pg_data:
networks:
app-network:
driver: bridge
EOF
fi
#################################
# RUN
# ЗАПУСК
#################################
log "Сборка контейнеров"
docker compose build
log "Сборка и запуск контейнеров..."
# Останавливаем старые, если были
docker compose down --remove-orphans || true
log "Запуск контейнеров"
docker compose up -d
# Запускаем сборку и старт
docker compose up --build -d
docker compose ps | grep node >/dev/null || die "Backend не запущен"
docker compose ps | grep nginx >/dev/null || die "Nginx не запущен"
log "Очистка кэша сборки..."
docker image prune -f
#################################
# RESULT
#################################
log "✔ Установка завершена"
echo
echo "URL подписки:"
echo "$SUB_URL"
echo ""
echo "==================================================="
if [[ "$USE_SSL" == "true" ]]; then
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: https://${UI_HOST}:${FINAL_PORT}${NC}"
else
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: http://${UI_HOST}:${FINAL_PORT}${NC}"
fi
echo "Логин: admin"
echo "Пароль: admin"
echo ""
echo "Немедленно измените пароль в Настройках утилиты!"
echo "==================================================="
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.git
.env
Dockerfile
+56
View File
@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+27
View File
@@ -0,0 +1,27 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/main"]
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+11153
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.17.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ssh2": "^1.17.0",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.6",
"@types/ssh2": "^1.15.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { Setting } from './settings/entities/setting.entity';
import { Domain } from './domains/entities/domain.entity';
import { Subscription } from './subscriptions/entities/subscription.entity';
import { Inbound } from './inbounds/entities/inbound.entity';
import { XuiModule } from './xui/xui.module';
import { InboundsModule } from './inbounds/inbounds.module';
import { RotationModule } from './rotation/rotation.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { DomainsModule } from './domains/domains.module';
import { SettingsModule } from './settings/settings.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { ClientModule } from './client/client.module';
import { TunnelsModule } from './tunnels/tunnels.module';
import { Tunnel } from './tunnels/entities/tunnel.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [Setting, Domain, Subscription, Inbound, Tunnel],
synchronize: true,
}),
XuiModule,
InboundsModule,
RotationModule,
SubscriptionsModule,
DomainsModule,
SettingsModule,
AuthModule,
ClientModule,
TunnelsModule
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule { }
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('login')
async login(@Body() req) {
const user = await this.authService.validateUser(req.login, req.password);
if (!user) {
throw new Error('Invalid credentials');
}
return this.authService.login(user);
}
@Post('change-password')
async changePassword(@Body('password') password: string) {
await this.authService.changePassword(password);
return { success: true };
}
@Post('update-profile')
async updateProfile(@Body() body: { login: string; password?: string }) {
await this.authService.updateAdminProfile(body.login, body.password);
return { success: true };
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Setting } from '../settings/entities/setting.entity';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([Setting]),
PassportModule,
JwtModule.register({
secret: 'SECRET_KEY_CHANGE_ME',
signOptions: { expiresIn: '24h' },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
+102
View File
@@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private jwtService: JwtService,
) {}
async validateUser(login: string, pass: string): Promise<any> {
this.logger.log(`Попытка входа с логином: ${login}`);
const dbLogin = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
const dbPass = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!dbLogin) {
this.logger.error('Пользователь admin_login не найден в базе данных!');
return null;
}
if (!dbPass) {
this.logger.error('Пароль admin_password не найден в базе данных!');
return null;
}
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
const isMatch = await bcrypt.compare(pass, dbPass.value);
if (isMatch) {
this.logger.log('Пароль верный!');
return { login: dbLogin.value };
} else {
this.logger.warn('Пароль неверный.');
return null;
}
}
async login(user: any) {
const payload = { username: user.login };
return {
access_token: this.jwtService.sign(payload),
};
}
async changePassword(newPass: string) {
const hash = await bcrypt.hash(newPass, 10);
let setting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!setting) {
setting = this.settingsRepo.create({ key: 'admin_password' });
}
setting.value = hash;
await this.settingsRepo.save(setting);
this.logger.log('Пароль администратора изменен.');
}
async updateAdminProfile(login: string, password?: string) {
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
loginSetting.value = login;
await this.settingsRepo.save(loginSetting);
if (password && password.trim().length > 0) {
const hash = await bcrypt.hash(password, 10);
let passSetting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!passSetting) passSetting = this.settingsRepo.create({ key: 'admin_password' });
passSetting.value = hash;
await this.settingsRepo.save(passSetting);
}
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
}
async seedAdmin() {
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
if (!login) {
this.logger.log('Инициализация администратора (admin / admin)...');
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
await this.settingsRepo.save(loginSetting);
const hash = await bcrypt.hash('admin', 10);
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
await this.settingsRepo.save(passSetting);
this.logger.log('Администратор успешно создан.');
} else {
this.logger.log('Администратор уже существует в базе.');
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'SECRET_KEY_CHANGE_ME',
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
+2
View File
@@ -0,0 +1,2 @@
import { SetMetadata } from '@nestjs/common';
export const Public = () => SetMetadata('isPublic', true);
+281
View File
@@ -0,0 +1,281 @@
import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject, Query } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { Response, Request } from 'express';
import * as QRCode from 'qrcode';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Public } from '../auth/public.decorator';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
@Controller()
export class ClientController {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Tunnel)
private tunnelRepo: Repository<Tunnel>,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) { }
@Public()
@Get('bus/:uuid')
async getSubscription(
@Param('uuid') uuid: string,
@Req() req: Request,
@Res() res: Response
) {
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
});
if (!sub || !sub.isEnabled) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
const links = sub.inbounds
?.map(i => i.link)
.filter(l => l && l.length > 0) || [];
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
const userAgent = req.headers['user-agent'] || '';
const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent);
if (!isBrowser) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(base64Config);
} else {
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}`;
const cacheKey = `qr_${uuid}`;
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
if (!qrDataUrl) {
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
} else {
console.log(`Взяли QR из кэша для ${uuid}`);
}
const html = `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sub.name} | 3DP-MANAGER</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
h2 { margin-top: 0; color: #333; }
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
button:hover { background-color: #1565c0; }
button:active { transform: scale(0.98); }
.note { margin-top: 20px; font-size: 12px; color: #999; }
#subscription-links { display: none; }
</style>
</head>
<body>
<div class="card">
<h2>Ваша подписка</h2>
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
<div class="qr-box">
<img src="${qrDataUrl}" alt="QR Code" />
</div>
<div class="link-box" id="link-text">${currentUrl}</div>
<button onclick="copyLink()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
Копировать ссылку
</button>
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
</div>
<textarea id="subscription-links">${base64Config}</textarea>
<script>
function copyLink() {
const link = document.getElementById('link-text').innerText;
navigator.clipboard.writeText(link).then(() => {
const btn = document.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = 'Скопировано!';
btn.style.backgroundColor = '#2e7d32';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.backgroundColor = '#1976d2';
}, 2000);
});
}
</script>
</body>
</html>
`;
res.setHeader('Content-Type', 'text/html');
res.send(html);
}
}
@Public()
@Get('bus/:uuid/:tunnelId')
async getRelaySubscription(
@Param('uuid') uuid: string,
@Param('tunnelId') tunnelId: string,
@Query('format') format: string,
@Req() req: Request,
@Res() res: Response
) {
const tunnel = await this.tunnelRepo.findOne({ where: { id: +tunnelId } });
if (!tunnel) {
return res.status(HttpStatus.NOT_FOUND).send('Relay server not found');
}
const relayHost = tunnel.domain || tunnel.ip;
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
});
if (!sub || !sub.isEnabled) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
let links = sub.inbounds
?.map(i => i.link)
.filter(l => l && l.length > 0) || [];
links = links.map(link => this.patchLink(link, relayHost));
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
const userAgent = req.headers['user-agent'] || '';
const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent);
if (!isBrowser) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(base64Config);
} else {
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}/${tunnelId}`;
const cacheKey = `qr_${uuid}_${relayHost || 'direct'}`;
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
if (!qrDataUrl) {
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
} else {
console.log(`Взяли QR из кэша для ${uuid}`);
}
const html = `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sub.name} | 3DP-MANAGER</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
h2 { margin-top: 0; color: #333; }
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
button:hover { background-color: #1565c0; }
button:active { transform: scale(0.98); }
.note { margin-top: 20px; font-size: 12px; color: #999; }
#subscription-links { display: none; }
</style>
</head>
<body>
<div class="card">
<h2>Ваша подписка</h2>
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
<div class="qr-box">
<img src="${qrDataUrl}" alt="QR Code" />
</div>
<div class="link-box" id="link-text">${currentUrl}</div>
<button onclick="copyLink()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
Копировать ссылку
</button>
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
</div>
<textarea id="subscription-links">${base64Config}</textarea>
<script>
function copyLink() {
const link = document.getElementById('link-text').innerText;
navigator.clipboard.writeText(link).then(() => {
const btn = document.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = 'Скопировано!';
btn.style.backgroundColor = '#2e7d32';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.backgroundColor = '#1976d2';
}, 2000);
});
}
</script>
</body>
</html>
`;
res.setHeader('Content-Type', 'text/html');
res.send(html);
}
}
private patchLink(link: string, newHost: string): string {
if (link.startsWith('vmess://')) {
try {
const base64Part = link.substring(8);
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
const config = JSON.parse(jsonStr);
config.add = newHost;
const newJsonStr = JSON.stringify(config);
const newBase64 = Buffer.from(newJsonStr).toString('base64');
return `vmess://${newBase64}`;
} catch (e) {
return link;
}
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
return link.replace(/@.*?:/, `@${newHost}:`);
} else if (link.startsWith('ss://')) {
if (link.includes('@')) {
return link.replace(/@.*?:/, `@${newHost}:`);
}
return link;
}
return link;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClientController } from './client.controller';
import { CacheModule } from '@nestjs/cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
@Module({
imports: [TypeOrmModule.forFeature([Subscription, Tunnel]), CacheModule.register()],
controllers: [ClientController],
})
export class ClientModule {}
+43
View File
@@ -0,0 +1,43 @@
import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common';
import { DomainsService } from './domains.service';
@Controller('domains')
export class DomainsController {
constructor(private readonly domainsService: DomainsService) { }
@Post()
create(@Body() body: { name: string }) {
return this.domainsService.create(body);
}
@Post('upload')
uploadMany(@Body() body: { domains: string[] }) {
return this.domainsService.createMany(body.domains);
}
@Get()
findAll(
@Query('page') page: number,
@Query('limit') limit: number
) {
const pageNum = page ? +page : 1;
const limitNum = limit ? +limit : 10;
return this.domainsService.findAll(pageNum, limitNum);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.domainsService.findOne(+id);
}
@Delete('all')
removeAll() {
return this.domainsService.removeAll();
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.domainsService.remove(+id);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DomainsService } from './domains.service';
import { DomainsController } from './domains.controller';
import { Domain } from './entities/domain.entity';
@Module({
imports: [TypeOrmModule.forFeature([Domain])],
controllers: [DomainsController],
providers: [DomainsService],
exports: [DomainsService],
})
export class DomainsModule {}
+96
View File
@@ -0,0 +1,96 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Domain } from './entities/domain.entity';
@Injectable()
export class DomainsService implements OnModuleInit {
constructor(
@InjectRepository(Domain)
private repo: Repository<Domain>,
) { }
async onModuleInit() {
await this.seedDefaultDomains();
}
private async seedDefaultDomains() {
const count = await this.repo.count();
if (count === 0) {
const defaultDomains = [
'ya.ru',
'vk.com',
'ok.ru',
'gosuslugi.ru',
'ozon.ru',
'max.ru',
'vkvideo.ru',
'rutube.ru',
'kinopoisk.ru',
'avito.ru'
];
const entities = defaultDomains.map(name => this.repo.create({ name }));
await this.repo.save(entities);
}
}
async create(createDomainDto: { name: string }) {
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
if (exists) return exists;
const domain = this.repo.create(createDomainDto);
return this.repo.save(domain);
}
async findAll(page: number = 1, limit: number = 10) {
const skip = (page - 1) * limit;
const [result, total] = await this.repo.findAndCount({
take: limit,
skip: skip,
order: { id: 'DESC' },
});
return {
data: result,
total: total,
};
}
findOne(id: number) {
return this.repo.findOneBy({ id });
}
remove(id: number) {
return this.repo.delete(id);
}
async removeAll() {
await this.repo.clear();
return { success: true };
}
async createMany(names: string[]) {
if (!names || names.length === 0) return { count: 0 };
const cleanNames = names
.map(n => n.trim())
.filter(n => n.length > 0);
const existing = await this.repo.find();
const existingSet = new Set(existing.map(d => d.name));
const uniqueNewNames = [...new Set(cleanNames)]
.filter(name => !existingSet.has(name));
if (uniqueNewNames.length === 0) return { count: 0 };
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
await this.repo.save(entities);
return { count: entities.length };
}
}
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Domain {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column({ default: true })
isEnabled: boolean;
}
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Subscription } from '../../subscriptions/entities/subscription.entity';
@Entity()
export class Inbound {
@PrimaryGeneratedColumn()
id: number;
@Column()
xuiId: number;
@Column()
port: number;
@Column()
protocol: string;
@Column({ nullable: true })
remark: string;
@Column({ type: 'text', nullable: true })
link: string;
@ManyToOne(() => Subscription, (sub) => sub.inbounds, { onDelete: 'CASCADE' })
subscription: Subscription;
}
@@ -0,0 +1,343 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class InboundBuilderService {
private flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
buildVlessRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-tcp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
tcpSettings: { acceptProxyProtocol: false, header: { type: 'none' } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityXhttp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-xhttp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'xhttp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
xhttpSettings: { path: '/', mode: 'auto' }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityGrpc(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-grpc-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'grpc',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
grpcSettings: { serviceName: 'grpc', multiMode: false }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildVlessWs(params: { port: number; uuid: string; domain: string }) {
const { port, uuid, domain } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-ws`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'ws',
security: 'none',
externalProxy: [],
wsSettings: { path: '/', headers: { Host: domain } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildVmessTcp(params: { port: number; uuid: string }) {
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'vmess',
remark: 'vmess-tcp',
settings: JSON.stringify({
clients: [{ id: uuid, alterId: 0, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
disableInsecureEncryption: false
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none', tcpSettings: { header: { type: 'http', request: { method: 'GET', path: ['/'], headers: { Host: [] } } } } }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildShadowsocksTcp(params: { port: number; uuid: string }) {
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'shadowsocks',
remark: 'shadowsocks-tcp',
settings: JSON.stringify({
method: 'aes-256-gcm',
password: uuid,
network: 'tcp,udp',
clients: []
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildTrojanRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'trojan',
remark: `trojan-tcp-reality`,
settings: JSON.stringify({
clients: [{ password: uuid, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
}
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
generateUuid() {
return uuidv4();
}
buildInboundLink(inbound: any, domain: string, idOrPass: string, flagEmoji: string): string {
this.flag = flagEmoji;
let link = "";
switch (inbound.protocol) {
case "vless":
link = this.buildVlessLink(inbound, domain, idOrPass);
break;
case "vmess":
link = this.buildVmessLink(inbound, domain, idOrPass);
break;
case "shadowsocks":
link = this.buildSsLink(inbound, domain, idOrPass);
break;
case "trojan":
link = this.buildTrojanLink(inbound, domain, idOrPass);
break;
}
return link;
}
private buildVlessLink(inbound: any, domain: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
const network = stream.network;
const security = stream.security || "none";
const params = new URLSearchParams();
params.set("type", network);
params.set("encryption", "none");
params.set("security", security);
if (security === "reality") {
const r = stream.realitySettings;
params.set("pbk", r.settings.publicKey);
params.set("fp", r.settings.fingerprint || "random");
params.set("sni", r.serverNames?.[0] || "");
params.set("sid", r.shortIds?.[0] || "");
params.set("spx", '/');
if (network === "tcp") {
const client = settings.clients?.[0];
if (client?.flow) {
params.set("flow", client.flow);
}
}
if (network === "xhttp") {
const x = stream.xhttpSettings || {};
params.set("path", x.path || "/");
params.set("host", x.host || r.serverNames?.[0]);
params.set("mode", x.mode || "auto");
}
if (network === "grpc") {
const g = stream.grpcSettings || {};
params.set("serviceName", g.serviceName || "grpc");
params.set("authority", g.authority || r.serverNames?.[0]);
}
}
if (network === "ws") {
const ws = stream.wsSettings || {};
params.set("path", ws.path || "/");
if (ws.headers?.Host) {
params.set("host", ws.headers.Host);
}
}
return (
`vless://${uuid}@${domain}:${inbound.port}` +
`?${params.toString()}` +
`#${this.flag}%20${encodeURIComponent(inbound.remark)}`
);
}
private buildVmessLink(inbound: any, domain: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const vmessObj = {
add: domain,
aid: '',
alpn: "",
fp: "",
host: "",
id: uuid,
net: stream.network || "tcp",
path: "/",
port: inbound.port.toString(),
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
scy: "",
sni: "",
tls: stream.security || "none",
type: "none",
v: "2"
};
const base64 = Buffer
.from(JSON.stringify(vmessObj), "utf8")
.toString("base64");
return `vmess://${base64}`;
}
private buildSsLink(inbound: any, domain: string, idOrPass: string) {
const settings = JSON.parse(inbound.settings);
const method = settings.method;
const serverPassword = settings.password;
const finalPass = serverPassword || idOrPass;
const userInfo = `${method}:${finalPass}`;
const base64 = Buffer
.from(userInfo, "utf8")
.toString("base64");
return `ss://${base64}@${domain}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
}
private buildTrojanLink(inbound: any, domain: string, password: string) {
const stream = JSON.parse(inbound.streamSettings);
const reality = stream.realitySettings;
const pbk = reality.settings.publicKey;
const sni = reality.serverNames?.[0] || domain;
const sid = reality.shortIds?.[0] || "";
const spx = '%2F';
return (
`trojan://${password}@${domain}:${inbound.port}` +
`?type=tcp` +
`&security=reality` +
`&pbk=${pbk}` +
`&fp=random` +
`&sni=${sni}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${this.flag}%20${inbound.remark}`
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Inbound } from './entities/inbound.entity';
import { InboundBuilderService } from './inbound-builder.service';
@Module({
imports: [TypeOrmModule.forFeature([Inbound])],
providers: [InboundBuilderService],
exports: [InboundBuilderService],
})
export class InboundsModule {}
+25
View File
@@ -0,0 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { AuthService } from './auth/auth.service';
import { RequestMethod } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1);
const authService = app.get(AuthService);
await authService.seedAdmin();
app.enableCors();
app.setGlobalPrefix('api', {
exclude: [
{ path: 'bus/:uuid', method: RequestMethod.GET },
{ path: 'bus/:uuid/:tunnelId', method: RequestMethod.GET },
]
});
await app.listen(3000);
}
bootstrap();
@@ -0,0 +1,12 @@
import { Controller, Post } from '@nestjs/common';
import { RotationService } from './rotation.service';
@Controller('rotation')
export class RotationController {
constructor(private readonly rotationService: RotationService) {}
@Post('rotate-all')
async rotateAll() {
return this.rotationService.performRotation();
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { RotationService } from './rotation.service';
import { XuiModule } from '../xui/xui.module';
import { InboundsModule } from '../inbounds/inbounds.module';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
import { RotationController } from './rotation.controller';
@Module({
imports: [
TypeOrmModule.forFeature([Subscription, Inbound, Domain, Setting]),
ScheduleModule.forRoot(),
XuiModule,
InboundsModule,
],
providers: [RotationService],
controllers: [RotationController],
})
export class RotationModule {}
+171
View File
@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
import { XuiService } from '../xui/xui.service';
import { InboundBuilderService } from '../inbounds/inbound-builder.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RotationService {
private readonly logger = new Logger(RotationService.name);
constructor(
@InjectRepository(Subscription) private subRepo: Repository<Subscription>,
@InjectRepository(Inbound) private inboundRepo: Repository<Inbound>,
@InjectRepository(Domain) private domainRepo: Repository<Domain>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
private xuiService: XuiService,
private inboundBuilder: InboundBuilderService,
) {}
@Cron(CronExpression.EVERY_MINUTE)
async handleTicker() {
const intervalSetting = await this.settingRepo.findOne({ where: { key: 'rotation_interval' } });
const intervalMinutes = intervalSetting ? parseInt(intervalSetting.value, 10) : 30;
const lastRunSetting = await this.settingRepo.findOne({ where: { key: 'last_rotation_timestamp' } });
const lastRun = lastRunSetting ? parseInt(lastRunSetting.value, 10) : 0;
const now = Date.now();
const diffMinutes = (now - lastRun) / 1000 / 60;
if (diffMinutes < intervalMinutes) {
return;
}
await this.performRotation();
await this.saveSetting('last_rotation_timestamp', now.toString());
}
private async saveSetting(key: string, value: string) {
let s = await this.settingRepo.findOne({ where: { key } });
if (!s) s = this.settingRepo.create({ key });
s.value = value;
await this.settingRepo.save(s);
}
async performRotation() {
this.logger.log('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
if (!isLoginSuccess) {
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
return;
}
const subscriptions = await this.subRepo.find({ where: { isEnabled: true }, relations: ['inbounds'] });
if (subscriptions.length === 0) return;
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
if (domains.length === 0) {
this.logger.warn('Список доменов пуст! Ротация невозможна.');
return;
}
for (const sub of subscriptions) {
await this.rotateSubscription(sub, domains);
}
this.logger.log('Ротация завершена.');
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
this.logger.log(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
if (sub.inbounds && sub.inbounds.length > 0) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
await this.inboundRepo.delete(inbound.id);
}
}
const keys = await this.xuiService.getNewX25519Cert();
if (!keys) {
this.logger.error("Не удалось получить Reality ключи, пропускаем подписку");
return;
}
const usedPorts = new Set<number>();
const tasks = [
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityXhttp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityGrpc({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessWs({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains) }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVmessTcp({ port: 0, uuid: uuidv4() }),
() => this.inboundBuilder.buildShadowsocksTcp({ port: 0, uuid: uuidv4() }),
() => this.inboundBuilder.buildTrojanRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
];
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const serverAddress = host?.value || 'localhost';
const flag = await this.settingRepo.findOne({ where: { key: 'xui_geo_flag' } });
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
for (const [index, task] of tasks.entries()) {
let config = task();
let port = 0;
if (index === 0) port = await this.getFreePort(8443, usedPorts);
else if (index === 1) port = await this.getFreePort(443, usedPorts);
else port = await this.getFreePort(0, usedPorts);
config.port = port;
usedPorts.add(port);
const xuiId = await this.xuiService.addInbound(config);
if (xuiId) {
const remarkParts = config.remark.split('-');
let domainForLink = 'unknown';
try {
const ss = JSON.parse(config.streamSettings || '{}');
if (ss.realitySettings?.serverNames?.[0]) domainForLink = ss.realitySettings.serverNames[0];
else if (ss.wsSettings?.headers?.Host) domainForLink = ss.wsSettings.headers.Host;
else if (ss.tcpSettings?.header?.request?.headers?.Host?.[0]) domainForLink = ss.tcpSettings.header.request.headers.Host[0];
} catch (e) { }
const idOrPass = config.settings ? JSON.parse(config.settings).clients?.[0]?.id || JSON.parse(config.settings).clients?.[0]?.password : "";
const fullLink = this.inboundBuilder.buildInboundLink(config, serverAddress, idOrPass, flagEmoji);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
port: port,
protocol: config.protocol,
remark: config.remark,
link: fullLink,
subscription: sub
});
await this.inboundRepo.save(newInbound);
}
}
}
private pickDomain(list: Domain[]): string {
return list[Math.floor(Math.random() * list.length)].name;
}
private async getFreePort(preferred: number, currentBatch: Set<number>): Promise<number> {
if (preferred > 0 && !currentBatch.has(preferred)) {
const exists = await this.inboundRepo.findOne({ where: { port: preferred } });
if (!exists) return preferred;
}
while (true) {
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
if (currentBatch.has(p)) continue;
const exists = await this.inboundRepo.findOne({ where: { port: p } });
if (!exists) return p;
}
}
}
@@ -1,4 +1,4 @@
[
export const COUNTRIES = [
{
"name": "Ascension Island",
"code": "AC",
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity()
export class Setting {
@PrimaryColumn()
key: string;
@Column()
value: string;
@Column({ nullable: true })
description: string;
}
@@ -0,0 +1,72 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
import * as dns from 'dns/promises';
import { COUNTRIES } from './countries';
@Controller('settings')
export class SettingsController {
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
) {}
@Get()
async findAll() {
const settings = await this.settingsRepo.find();
return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
}
@Post()
async update(@Body() settings: Record<string, string>) {
if (settings.xui_url) {
try {
const parsed = new URL(settings.xui_url);
settings['xui_host'] = parsed.hostname;
const { address } = await dns.lookup(parsed.hostname);
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
if (address && address !== '127.0.0.1' && address !== 'localhost') {
try {
console.log(`Определяем страну для IP: ${address}...`);
const geoRes = await fetch(`http://ip-api.com/json/${address}`);
const geoData: any = await geoRes.json();
if (geoData.status === 'success') {
const countryCode = geoData.countryCode;
const countryInfo = COUNTRIES.find(c => c.code === countryCode);
if (countryInfo) {
const flagEmoji = countryInfo.emoji;
settings['xui_geo_country'] = countryInfo.name;
settings['xui_geo_flag'] = flagEmoji;
console.log(`GeoIP Success: ${countryInfo.name} ${flagEmoji}`);
} else {
console.warn(`Страна с кодом ${countryCode} не найдена в countries.ts`);
settings['xui_geo_country'] = geoData.country;
settings['xui_geo_flag'] = '';
}
} else {
console.warn(`GeoIP Error: ${geoData.message}`);
}
} catch (geoError) {
console.error(`Ошибка запроса к ip-api.com: ${geoError.message}`);
}
}
} catch (e) {
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
}
}
for (const [key, value] of Object.entries(settings)) {
await this.settingsRepo.save({ key, value });
}
return { success: true };
}
}

Some files were not shown because too many files have changed in this diff Show More