Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd3771e7fd | |||
| dd64e4dcb5 | |||
| 53ea8902e6 | |||
| 9f4ef52aaa | |||
| e64773f703 | |||
| 69d030e3b4 | |||
| 1908317d92 | |||
| 9861b87465 | |||
| 9aff2e78d8 | |||
| 98ff14d464 | |||
| 653896ae56 | |||
| eb21838ce6 | |||
| 007afd3b83 | |||
| 1f9d54ee0b | |||
| 10208930ff | |||
| ff7b1646b8 | |||
| 9dee9bc216 | |||
| 083fdf98b3 | |||
| 80a98002d6 | |||
| a6c951938c | |||
| 8cb9aa46d8 | |||
| a470b6112f | |||
| 27d64b3c5f | |||
| 2183490efb | |||
| 23dc7bff1f | |||
| 899949a150 | |||
| ad3127f8c1 | |||
| a55b3e89ac | |||
| b178df908b | |||
| 236d0b2126 | |||
| ba4b7ef0a4 | |||
| 27310c0bc0 | |||
| 6db895bcc7 | |||
| ba6db6edd5 | |||
| f574931c25 | |||
| 6f02e501e6 | |||
| fed99d8e5c | |||
| 299ec3ecac | |||
| 0c16656c4f | |||
| 45f67d3848 | |||
| b77e5560c8 | |||
| c48e3df360 | |||
| 17f7baaf61 | |||
| 1738fe4d64 | |||
| 36163093ea | |||
| e4015d0adf | |||
| 88cc96a75d | |||
| fa3e984189 | |||
| 94fb12194e | |||
| 1906ed4969 | |||
| 28c59783c9 | |||
| 39eab5bf4e | |||
| f385b57b1b | |||
| f20f722743 | |||
| b6ffaa722c |
@@ -0,0 +1,42 @@
|
||||
name: Docker Build & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Server image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-server:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push Client image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-client:${{ github.ref_name }}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Внести вклад
|
||||
|
||||
Буду рад любому вкладу в разработку проекта! Простой процесс для контрибьюторов:
|
||||
|
||||
1. Форкните репозиторий на GitHub.
|
||||
2. Создайте ветку с осмысленным именем, например `feature/add-README` или `fix/whitelist-load`.
|
||||
3. Внесите изменения и добавьте короткое описание в коммите.
|
||||
4. Запустите локально базовые проверки (если есть).
|
||||
5. Отправьте ветку в ваш форк и создайте Pull Request в основной репозиторий.
|
||||
|
||||
<sup>Советы: описывайте изменения в PR, указывайте цель и тестовые шаги. Если изменения большие — разделяйте на маленькие коммиты.</sup>
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной.
|
||||
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной. Утилита, начиная с версии 2.0.0 имеет графический интерфейс и простые пользовательские настройки.
|
||||
|
||||
**Поддержать проект**
|
||||
|
||||
- Реквизиты / донаты:
|
||||
- Банковским переводом:
|
||||
- Карта МИР: `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,22 @@
|
||||
|
||||
## Требования
|
||||
|
||||
- Ubuntu 20.04 (и выше)
|
||||
- Панель `3x-ui`
|
||||
- Ubuntu 20.04 (и выше), Debian 12.11 (и выше)
|
||||
- Панель `3x-ui` v2.8.4 (и выше)
|
||||
- Домен + SSL сертификат (опционально)
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
У вас должна быть установлена панель управления `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>
|
||||
|
||||
## Обновление
|
||||
|
||||
@@ -75,7 +78,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 +88,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,20 +111,10 @@ 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>
|
||||
|
||||
---
|
||||
|
||||
## Показать URL подписки
|
||||
|
||||
Команда для вывода текущего URL подписки из среды контейнера:
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
||||
```
|
||||
|
||||
<small>Краткое описание: выводит статичный URL подписки, который можно использовать в клиентах. Работает как на основном, так и на промежуточном сервере.</small>
|
||||
|
||||
## Сбор доменов из мульти-подписок
|
||||
|
||||
Утилита извлекает домены из подписок и формирует `whitelist` для генератора.
|
||||
@@ -127,18 +123,7 @@ cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -
|
||||
node get_domains.js
|
||||
```
|
||||
|
||||
<small>Краткое описание: добавьте ссылку на мульти-подписку в скрипт и запустите команду — на выходе получите список доменов. Неободим `Node.js` для работы скрипта.</small>
|
||||
|
||||
## Использование собственного белого списка
|
||||
|
||||
1. Подготовьте файл в формате `whitelist.txt`.
|
||||
2. Переименуйте на `my_whitelist.txt` и скопируйте в папку `/opt/3dp-manager/app`.
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
||||
```
|
||||
|
||||
<small>Краткое описание: добавляет ваш файл доменов в контейнер приложения. Чтобы сразу же сгенерировать инбаунды с новыи списком, выполните `docker exec -it node sh` и затем `node index.js`.</small>
|
||||
<sup>Краткое описание: добавьте ссылку на мульти-подписку в скрипт и запустите команду — на выходе получите список доменов. Необходим `Node.js` для работы скрипта.</sup>
|
||||
|
||||
---
|
||||
|
||||
@@ -158,7 +143,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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"node-cron": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
Dockerfile
|
||||
@@ -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?
|
||||
@@ -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;"]
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:3000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_set_header Host \$http_host;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 707 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/3dp-manager"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton } from '@mui/material';
|
||||
import api from '../api';
|
||||
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material';
|
||||
|
||||
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',
|
||||
rotation_status: 'active',
|
||||
last_rotation_timestamp: '',
|
||||
});
|
||||
|
||||
const [adminProfile, setAdminProfile] = useState({
|
||||
login: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
|
||||
const [intervalError, setIntervalError] = useState<string>('');
|
||||
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const val = parseInt(settings.rotation_interval, 10);
|
||||
if (isNaN(val) || val < 10) {
|
||||
setIntervalError('Минимальный интервал — 10 минут');
|
||||
} else {
|
||||
setIntervalError('');
|
||||
}
|
||||
}, [settings.rotation_interval]);
|
||||
|
||||
const cleanData = () => {
|
||||
const cleaned = { ...settings };
|
||||
|
||||
if (cleaned.xui_url) {
|
||||
cleaned.xui_url = cleaned.xui_url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
if (cleaned.xui_login) cleaned.xui_login = cleaned.xui_login.trim();
|
||||
if (cleaned.xui_password) cleaned.xui_password = cleaned.xui_password.trim();
|
||||
|
||||
setSettings(prev => ({ ...prev, ...cleaned }));
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
const handleCheckConnection = async () => {
|
||||
const data = cleanData(); // Сначала чистим
|
||||
|
||||
try {
|
||||
setMsg({ open: true, type: 'success', text: 'Проверка...' });
|
||||
const res = await api.post('/settings/check', {
|
||||
xui_url: data.xui_url,
|
||||
xui_login: data.xui_login,
|
||||
xui_password: data.xui_password
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
setMsg({ open: true, type: 'success', text: 'Подключение успешно!' });
|
||||
} else {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка: Неверные данные или нет доступа' });
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка сети при проверке' });
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const data = cleanData();
|
||||
|
||||
try {
|
||||
await api.post('/settings', data);
|
||||
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 {
|
||||
setLoadingRotate(true);
|
||||
const res = await api.post('/rotation/rotate-all');
|
||||
|
||||
setLoadingRotate(false);
|
||||
if (res.data && res.data.success) {
|
||||
setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' });
|
||||
} else {
|
||||
setMsg({
|
||||
open: true,
|
||||
type: 'error',
|
||||
text: res.data?.message || 'Ошибка выполнения ротации'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoadingRotate(false);
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка сети или сервера' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePause = async () => {
|
||||
const newStatus = settings.rotation_status === 'active' ? 'stopped' : 'active';
|
||||
const updatedSettings = { ...settings, rotation_status: newStatus };
|
||||
|
||||
setSettings(updatedSettings);
|
||||
|
||||
try {
|
||||
await api.post('/settings', updatedSettings);
|
||||
|
||||
} catch (e) {
|
||||
setSettings((prev: any) => ({ ...prev, rotation_status: settings.rotation_status }));
|
||||
setMsg({ open: true, type: 'error', text: 'Не удалось изменить статус' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (isoString: string) => {
|
||||
if (!isoString) return 'Нет данных';
|
||||
return new Date(+isoString).toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const getNextRotationDate = () => {
|
||||
if (settings.rotation_status === 'stopped') return 'Пауза';
|
||||
if (!settings.last_rotation_timestamp) return 'Ожидание...';
|
||||
|
||||
const last = new Date(+settings.last_rotation_timestamp);
|
||||
const intervalMinutes = parseInt(settings.rotation_interval) || 60;
|
||||
const next = new Date(last.getTime() + intervalMinutes * 60000);
|
||||
|
||||
return next.toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const isPaused = settings.rotation_status === 'stopped';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
Статус сервиса
|
||||
</Typography>
|
||||
{isPaused ?
|
||||
<Chip icon={<PauseCircleFilled />} label="Остановлен" color="warning" size="small" variant="outlined" /> :
|
||||
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" />
|
||||
}
|
||||
|
||||
<Tooltip title={isPaused ? "Возобновить ротацию" : "Поставить на паузу"}>
|
||||
<IconButton
|
||||
onClick={togglePause}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': { bgcolor: 'background.paper' },
|
||||
ml: 1
|
||||
}}
|
||||
>
|
||||
{isPaused ? <PlayCircleFilled fontSize="large" /> : <PauseCircleFilled fontSize="large" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
{/* Последняя генерация */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textSecondary">
|
||||
Последняя генерация
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, mt: 2 }}>
|
||||
{formatDate(settings.last_rotation_timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Следующая генерация */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textSecondary">
|
||||
Следующая генерация
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, mt: 2 }}>
|
||||
{getNextRotationDate()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<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/wfgpoVHaOF"
|
||||
/>
|
||||
<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>
|
||||
{settings.xui_url && settings.xui_login && settings.xui_password && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="info"
|
||||
sx={{ mt: 2, ml: 2 }}
|
||||
onClick={handleCheckConnection}
|
||||
>
|
||||
Проверить
|
||||
</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"
|
||||
loading={loadingRotate}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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)} disableRestoreFocus>
|
||||
<DialogTitle>Новая подписка</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus margin="dense" label="Имя пользователя" fullWidth
|
||||
value={name} onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "Готово"
|
||||
@@ -2,28 +2,23 @@
|
||||
set -euo pipefail
|
||||
|
||||
#################################
|
||||
# DEBUG TRAP
|
||||
# КОНФИГУРАЦИЯ И ПЕРЕМЕННЫЕ
|
||||
#################################
|
||||
trap 'echo -e "\033[1;31m[ERROR]\033[0m Ошибка в строке $LINENO"; exit 1' ERR
|
||||
PROJECT_DIR="/opt/3dp-manager"
|
||||
DOCKER_USER="denpiligrim"
|
||||
DOCKER_TAG="main"
|
||||
IMAGE_SERVER="ghcr.io/${DOCKER_USER}/3dp-manager-server:${DOCKER_TAG}"
|
||||
IMAGE_CLIENT="ghcr.io/${DOCKER_USER}/3dp-manager-client:${DOCKER_TAG}"
|
||||
|
||||
#################################
|
||||
# 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 +36,384 @@ 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"
|
||||
mkdir -p "$PROJECT_DIR/server"
|
||||
mkdir -p "$PROJECT_DIR/client"
|
||||
|
||||
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
|
||||
cat > server/.env <<EOF
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=admin
|
||||
DB_PASSWORD=${DB_PASS}
|
||||
DB_NAME=3dp_manager
|
||||
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
|
||||
EOF
|
||||
|
||||
cat > nginx.conf <<EOF
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen $NGINX_PORT ssl;
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
# === ВАРИАНТ С SSL ===
|
||||
|
||||
# 1. Nginx Config
|
||||
cat > client/nginx-client.conf <<EOF
|
||||
server {
|
||||
listen 443 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 \$http_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 \$http_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:
|
||||
image: ${IMAGE_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:
|
||||
image: ${IMAGE_CLIENT}
|
||||
container_name: 3dp-frontend
|
||||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FINAL_PORT}:443"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${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 80;
|
||||
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 \$http_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 \$http_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:
|
||||
image: ${IMAGE_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:
|
||||
image: ${IMAGE_CLIENT}
|
||||
container_name: 3dp-frontend
|
||||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FINAL_PORT}:80"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
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 "==================================================="
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
Dockerfile
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('Администратор уже существует в базе.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,481 @@
|
||||
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: false, 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: '', 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: {
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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-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: privateKey,
|
||||
shortIds: [crypto.randomBytes(4).toString('hex')],
|
||||
settings: { publicKey: 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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
buildShadowsocksTcp(params: { port: number; uuid: string }) {
|
||||
const { port, uuid } = params;
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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: [{
|
||||
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: 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: 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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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: '0',
|
||||
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 clientPassword = settings.clients[0].password;
|
||||
|
||||
const userInfo = `${method}:${serverPassword}:${clientPassword}`;
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Injectable, Logger, OnModuleInit } 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 implements OnModuleInit {
|
||||
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,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initDefaultSettings();
|
||||
}
|
||||
|
||||
private async initDefaultSettings() {
|
||||
const key = 'rotation_status';
|
||||
const existing = await this.settingRepo.findOne({ where: { key } });
|
||||
|
||||
if (!existing) {
|
||||
this.logger.log(`Инициализация настройки: ${key} = active`);
|
||||
const newSetting = this.settingRepo.create({
|
||||
key: key,
|
||||
value: 'active',
|
||||
});
|
||||
await this.settingRepo.save(newSetting);
|
||||
} else {
|
||||
this.logger.log(`Текущий статус ротации: ${existing.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
const statusSetting = await this.settingRepo.findOne({ where: { key: 'rotation_status' } });
|
||||
const isStopped = statusSetting?.value === 'stopped';
|
||||
|
||||
if (diffMinutes < intervalMinutes || isStopped) {
|
||||
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 { success: false, message: 'Не удалось войти в панель 3x-ui' };
|
||||
}
|
||||
|
||||
const subscriptions = await this.subRepo.find({ where: { isEnabled: true }, relations: ['inbounds'] });
|
||||
if (subscriptions.length === 0) {
|
||||
return { success: false, message: 'Нет активных подписок для ротации' };
|
||||
}
|
||||
|
||||
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
|
||||
if (domains.length === 0) {
|
||||
this.logger.warn('Список доменов пуст! Ротация невозможна.');
|
||||
return { success: false, message: 'Список доменов пуст!' };
|
||||
}
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
await this.rotateSubscription(sub, domains);
|
||||
}
|
||||
|
||||
this.logger.log('Ротация завершена.');
|
||||
return { success: true, message: 'Ротация успешно выполнена' };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||