39 Commits

Author SHA1 Message Date
DenPiligrim 612f0468f7 Merge pull request #27 from denpiligrim/dp-new-release
New release v2.0.2
2026-03-21 15:43:21 +03:00
Den Piligrim e7970fd8dd v2.0.2 2026-03-21 15:41:43 +03:00
Den Piligrim bca723f8a0 fix patch links 2026-03-21 15:29:44 +03:00
Den Piligrim 21e4907aa2 update 2026-03-21 15:16:09 +03:00
Den Piligrim 29e776a0ec fix sni 2026-03-21 15:06:13 +03:00
Den Piligrim 82811645bf fix hy2 2026-03-21 14:10:33 +03:00
Den Piligrim 63adfcb98f fix 2026-03-21 13:58:00 +03:00
Den Piligrim 5b2870e04a update 2026-03-21 13:24:47 +03:00
Den Piligrim 21ae964012 hysteria 2026-03-21 09:55:34 +03:00
Den Piligrim d171d0440c responsive 2026-03-11 23:37:52 +03:00
DenPiligrim 81dcbc28ae Merge pull request #26 from denpiligrim/denpiligrim-patch-2
Configure production environment in Docker workflow
2026-02-26 10:12:47 +03:00
DenPiligrim 8e0bf98813 Configure production environment in Docker workflow
Add production environment configuration for Docker publish workflow
2026-02-26 10:12:25 +03:00
DenPiligrim a6515d73e6 Merge pull request #25 from denpiligrim/denpiligrim-patch-1
Initialize package.json for 3dp-manager project
2026-02-26 00:04:53 +03:00
DenPiligrim 2a7d351abb Initialize package.json for 3dp-manager project 2026-02-26 00:03:59 +03:00
DenPiligrim 0decb0bc82 Merge pull request #23 from denpiligrim/denpiligrim-patch-1
Add Patreon username for funding support
2026-02-12 15:26:54 +03:00
DenPiligrim 8e9e7b6c66 Add Patreon username for funding support 2026-02-12 15:26:30 +03:00
DenPiligrim 2147701d5a Merge pull request #22 from denpiligrim/dp-fix
fix: forwarding ip, installation
2026-02-07 23:34:50 +03:00
Den Piligrim fee9cd543c fix: forwarding ip, installation 2026-02-07 23:34:04 +03:00
Den Piligrim f05ca55201 fix 2026-02-06 14:45:15 +03:00
Den Piligrim f568de41de fix hardcode credentials 2026-02-06 14:43:00 +03:00
DenPiligrim f7495d691b Merge pull request #21 from denpiligrim/revert-1-advisory-fix-1
Revert "fix: hardcode credentials"
2026-02-06 12:54:50 +03:00
DenPiligrim 5d8ca39f6c Revert "fix: hardcode credentials" 2026-02-06 12:54:29 +03:00
DenPiligrim 9bbbc8cd97 Merge commit from fork
fix: hardcode credentials
2026-02-06 12:44:25 +03:00
Den Piligrim 460e31260e fix: hardcode credentials 2026-02-06 12:42:25 +03:00
DenPiligrim 1b2112e6d9 Merge pull request #16 from denpiligrim/update-version-to-2.0.1-4380354273118155952
Update version to 2.0.1
2026-02-04 11:27:17 +03:00
google-labs-jules[bot] e5f0410197 Update version to 2.0.1 in Header component
Changed the hardcoded version string in the Help modal window of the Header component from 2.0.0 to 2.0.1.

Co-authored-by: denpiligrim <89912505+denpiligrim@users.noreply.github.com>
2026-02-04 08:24:27 +00:00
DenPiligrim bd3771e7fd Merge pull request #15 from denpiligrim/dp-gui
new readme
2026-02-03 19:46:28 +03:00
Den Piligrim dd64e4dcb5 new readme 2026-02-03 19:45:40 +03:00
DenPiligrim 53ea8902e6 Merge pull request #14 from denpiligrim/dp-gui
fix: installation
2026-02-03 19:26:21 +03:00
Den Piligrim 9f4ef52aaa fix: installation 2026-02-03 19:25:42 +03:00
DenPiligrim e64773f703 Merge pull request #13 from denpiligrim/dp-gui
fix install
2026-02-03 19:21:26 +03:00
Den Piligrim 69d030e3b4 fix install 2026-02-03 19:20:22 +03:00
DenPiligrim 1908317d92 Merge pull request #12 from denpiligrim/dp-gui
Dp GUI patch
2026-02-03 19:11:39 +03:00
Den Piligrim 9861b87465 fix install 2026-02-03 19:09:33 +03:00
Den Piligrim 9aff2e78d8 fix install 2026-02-03 19:08:28 +03:00
Den Piligrim 98ff14d464 add: rotation status 2026-02-03 18:42:53 +03:00
Den Piligrim 653896ae56 github workflow 2026-02-03 12:45:32 +03:00
Den Piligrim eb21838ce6 dp-gui 2026-02-03 10:43:16 +03:00
Den Piligrim 007afd3b83 fix errors 2026-02-03 10:34:24 +03:00
42 changed files with 1528 additions and 479 deletions
+1
View File
@@ -0,0 +1 @@
patreon: DenPiligrim
+47
View File
@@ -0,0 +1,47 @@
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
environment:
name: production
url: https://github.com/denpiligrim/3dp-manager/pkgs/container/3dp-manager-server
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 }}
+15 -57
View File
@@ -2,32 +2,32 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной.
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной. Утилита, начиная с версии 2.0.0 имеет графический интерфейс и простые пользовательские настройки.
**Поддержать проект**
- Банковским переводом:
- Карта МИР: `2204320436318077`
- Карта MasterCard: `5395452209474530`
- :credit_card: Карта МИР: `2204320436318077`
- :credit_card: Карта MasterCard: `5395452209474530`
- На электронный кошелек:
- ЮМоney: `4100116897060652`
- PayPal: `vasiljevdenisx@gmail.com`
- :moneybag: ЮМоney: `4100116897060652`
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
- Криптовалютой:
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
- Bybit ID: `165292278`
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
- :coin: Bybit ID: `165292278`
## Описание
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. Бот генерирует по заданному интервалу 10 подключений с разными параметрами:
- протоколы: `vless`, `vmess`, `shadowsocks`, `trojan`;
- протоколы: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
- порты: `443`, `8443` (фиксированные) и случайные из диапазона `10000-60000`;
- транспорт: `tcp`, `websocket`, `grpc`, `xhttp`;
- SNI берутся из белого списка доменов (whitelist); можно использовать свой список.
@@ -61,10 +61,7 @@
## Установка
> [!WARNING]
> Утилита работает на основном сервере
У вас должны быть установлены пакеты на сервере `curl`, `jq` командой: `apt install curl jq` и панель управления `3x-ui`, которую можно установить командой: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
У вас должна быть установлена панель управления `3x-ui`, которую можно поставить командой: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
Установите проект на сервер командой:
```bash
@@ -73,12 +70,6 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
<sup>Краткое описание: запускает скрипт установки и разворачивает контейнеры и сервисы.</sup>
Если панель 3x-ui находится в Docker контейнере, установите командой:
```bash
REMOTE_PANEL=true bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
```
## Обновление
Обновление до последней версии:
@@ -112,7 +103,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)
```
<sup>Краткое описание: добавляет правила перенаправления и создает сервис для обновления подписки.</sup>
<sup>Краткое описание: добавляет правила перенаправления.</sup>
## Удаление перенаправления
@@ -120,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)
```
<sup>Краткое описание: удаляет правила и отключает сервис перенаправления.</sup>
<sup>Краткое описание: удаляет правила перенаправления.</sup>
---
## Показать URL подписки
Команда для вывода текущего URL подписки из среды контейнера:
```bash
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
```
<sup>Краткое описание: выводит статичный URL подписки, который можно использовать в клиентах. Работает как на основном, так и на промежуточном сервере.</sup>
## Сбор доменов из мульти-подписок
Утилита извлекает домены из подписок и формирует `whitelist` для генератора.
@@ -144,29 +125,6 @@ node get_domains.js
<sup>Краткое описание: добавьте ссылку на мульти-подписку в скрипт и запустите команду — на выходе получите список доменов. Необходим `Node.js` для работы скрипта.</sup>
## Использование собственного белого списка
1. Подготовьте файл в формате `whitelist.txt`.
2. Переименуйте на `my_whitelist.txt` и скопируйте в папку `/opt/3dp-manager/app`.
```bash
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
```
<sup>Краткое описание: добавляет ваш файл доменов в контейнер приложения. Чтобы сразу же сгенерировать инбаунды с новым списком, выполните `docker exec -it node sh` и затем `node rotate.js`.</sup>
## Ручной запуск генерации
Чтобы создать новые инбаунды, выполните команды поочередно:
```bash
cd /opt/3dp-manager
docker exec -it node sh
node rotate.js
```
<sup>Краткое описание: выполняет немедленную генерацию инбаундов, не влияя на заданный интервал ротации.</sup>
---
## Замечания и текущие ограничения
+1 -1
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
+1 -1
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
+1 -1
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
+1 -1
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
+21 -6
View File
@@ -1,17 +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 / {
root /usr/share/nginx/html;
index index.html index.htm;
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 $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host \$http_host;
}
}
+10 -6
View File
@@ -1,7 +1,11 @@
import { Box, Container, Grid, IconButton, Link, Stack } from '@mui/material';
import { GitHub, YouTube, Telegram } from '@mui/icons-material';
export default function Footer() {
interface FooterProps {
isMobile?: boolean;
}
export default function Footer({ isMobile }: FooterProps) {
return (
<Box
component="footer"
@@ -19,12 +23,12 @@ export default function Footer() {
<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 direction="row" alignItems="center" spacing={1} justifyContent={isMobile ? 'center' : 'start'}>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: isMobile ? 0 : 14 }} />
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: 'center' }}>
<Link
href="https://3dp-manager.com/docs/intro"
target="_blank"
@@ -38,11 +42,11 @@ export default function Footer() {
</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' }}>
<Stack direction="row" spacing={1} justifyContent={{ xs: 'center', sm: 'flex-end' }}>
<IconButton
component="a"
href="https://github.com/denpiligrim"
href="https://github.com/denpiligrim/3dp-manager"
target="_blank"
aria-label="GitHub"
color="inherit"
+15 -4
View File
@@ -10,8 +10,14 @@ import {
import { useNavigate } from 'react-router-dom';
import { useThemeContext } from '../ThemeContext';
import { useAuth } from '../auth/AuthContext';
import { Menu as MenuIcon } from '@mui/icons-material';
export default function Header() {
interface HeaderProps {
onMenuClick?: () => void;
isMobile?: boolean;
}
export default function Header({ onMenuClick, isMobile }: HeaderProps) {
const { mode, toggleColorMode } = useThemeContext();
const { logout } = useAuth();
const navigate = useNavigate();
@@ -48,12 +54,17 @@ export default function Header() {
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
{isMobile && (
<IconButton color="inherit" edge="start" onClick={onMenuClick} sx={{ mr: 1 }}>
<MenuIcon />
</IconButton>
)}
<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' }}>
<Typography variant={isMobile ? 'body1' : 'h6'} noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
3DP-MANAGER
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ display: 'flex', gap: isMobile ? 0.25 : 1 }}>
<Tooltip title="Справка о программе">
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
@@ -121,7 +132,7 @@ export default function Header() {
</List>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Версия: 2.0.0<br />
Версия: 2.0.2<br />
Разработчик: DenPiligrim
</Typography>
</DialogContent>
+41 -25
View File
@@ -1,9 +1,10 @@
import {
Toolbar, Drawer, List, ListItem,
ListItemButton, ListItemIcon, ListItemText, Box
ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
} from '@mui/material';
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useState } from 'react';
import Header from './Header';
import Footer from './Footer';
@@ -13,6 +14,13 @@ const drawerWidth = 240;
export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const menuItems = [
{ text: 'Подписки', icon: <People />, path: '/' },
@@ -21,35 +29,44 @@ export default function Layout() {
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
];
const drawerContent = (
<Box sx={{ overflow: 'auto' }}>
<Toolbar />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => {
navigate(item.path);
if (isMobile) setMobileOpen(false);
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
);
return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
<Header />
{/* Передаем функцию открытия в Header */}
<Header onMenuClick={handleDrawerToggle} isMobile={isMobile} />
<Drawer
variant="permanent"
variant={isMobile ? "temporary" : "permanent"}
open={isMobile ? mobileOpen : true}
onClose={handleDrawerToggle}
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>
{drawerContent}
</Drawer>
<Box
@@ -59,16 +76,15 @@ export default function Layout() {
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100%'
width: '100%',
overflowX: 'hidden'
}}
>
<Toolbar />
<Box sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ flexGrow: 1, p: { xs: 2, md: 3 } }}>
<Outlet />
</Box>
<Footer />
<Footer isMobile={isMobile} />
</Box>
</Box>
);
+35
View File
@@ -0,0 +1,35 @@
.animated-container {
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
}
@keyframes gradientBG {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
input:autofill {
background: transparent;
}
+29 -21
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery } from '@mui/material';
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
import api from '../api';
@@ -10,6 +10,8 @@ export default function DomainsPage() {
const [newDomain, setNewDomain] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [totalCount, setTotalCount] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
@@ -52,12 +54,10 @@ export default function DomainsPage() {
const handleDeleteAll = async () => {
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
if (confirm('Это действие необратимо. Точно удалить?')) {
try {
await api.delete('/domains/all');
loadDomains();
} catch (_e) { alert('Ошибка удаления'); }
}
try {
await api.delete('/domains/all');
loadDomains();
} catch (_e) { alert('Ошибка удаления'); }
}
};
@@ -87,21 +87,31 @@ export default function DomainsPage() {
return (
<Box>
<Typography variant="h4" gutterBottom>Белый список доменов (SNI)</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
<TextField
label="Добавить домен" size="small" fullWidth
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>
{isMobile ? (
<>
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
</>
) : (
<>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: isMobile ? 'auto' : '170px' }}
onClick={() => fileInputRef.current?.click()}
>
{isMobile ? '' : 'Из файла'}
</Button>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</>
)}
<input
type="file"
accept=".txt"
@@ -109,7 +119,6 @@ export default function DomainsPage() {
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</Paper>
{domains.length > 0 && (
@@ -124,10 +133,9 @@ export default function DomainsPage() {
Удалить все
</Button>
</Box>
)}
<Paper>
<Paper sx={{ mt: domains.length > 0 ? 0 : 3 }}>
<List>
{domains.map((d) => (
<ListItem key={d.id} secondaryAction={
@@ -136,7 +144,7 @@ export default function DomainsPage() {
<ListItemText primary={d.name} />
</ListItem>
))}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Список пуст</Typography>}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
</List>
<TablePagination
component="div"
+14 -5
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Box, Paper, TextField, Button, Typography, Alert } from '@mui/material';
import { Box, Paper, TextField, Button, Typography, Alert, Chip } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
@@ -22,12 +22,21 @@ export default function LoginPage() {
};
return (
<Box sx={{
<Box className="animated-container" 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>
<Paper sx={{
p: 4,
width: '100%',
maxWidth: 400,
background: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 1.5s ease-out',
boxShadow: '0 15px 25px rgba(0,0,0,0.5)'
}}>
<Typography variant="h5" gutterBottom align="center"><span style={{ verticalAlign: 'middle' }}>Вход в 3DP-MANAGER</span> <Chip label="v2.0.2" size="small" sx={{ verticalAlign: 'middle' }} /></Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
@@ -40,7 +49,7 @@ export default function LoginPage() {
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 fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3, transition: 'transform 0.3s ease', '&:hover': { transform: 'scale(1.05)' } }}>
Войти
</Button>
</form>
+169 -9
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip } from '@mui/material';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery } from '@mui/material';
import api from '../api';
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material';
const ROTATION_PRESETS = [
{ label: 'Сутки', value: 1440 },
@@ -14,6 +15,8 @@ export default function SettingsPage() {
xui_login: '',
xui_password: '',
rotation_interval: '30',
rotation_status: 'active',
last_rotation_timestamp: '',
});
const [adminProfile, setAdminProfile] = useState({
@@ -22,7 +25,10 @@ export default function SettingsPage() {
});
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [intervalError, setIntervalError] = useState('');
const [intervalError, setIntervalError] = useState<string>('');
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useEffect(() => {
loadSettings();
@@ -37,6 +43,42 @@ export default function SettingsPage() {
}
}, [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');
@@ -64,8 +106,10 @@ export default function SettingsPage() {
return;
}
const data = cleanData();
try {
await api.post('/settings', settings);
await api.post('/settings', data);
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' });
@@ -89,20 +133,125 @@ export default function SettingsPage() {
const handleForceRotate = async () => {
if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) {
try {
await api.post('/rotation/rotate-all');
setMsg({ open: true, type: 'success', text: 'Ротация успешно выполнена!' });
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) {
setMsg({ open: true, type: 'error', text: 'Ошибка при запуске ротации' });
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>
<Typography variant={isMobile ? 'h5' : '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>
@@ -111,7 +260,7 @@ export default function SettingsPage() {
<TextField
fullWidth margin="normal" label="URL панели"
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
helperText="Например: https://my-vpn.com:2053/panel_path"
helperText="Например: https://my-vpn.com:2053/wfgpoVHaOF"
/>
<TextField
fullWidth margin="normal" label="Логин 3x-ui"
@@ -125,6 +274,16 @@ export default function SettingsPage() {
<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: isMobile ? 1 : 2 }}
onClick={handleCheckConnection}
>
Проверить
</Button>
)}
</Paper>
</Grid>
@@ -162,9 +321,10 @@ export default function SettingsPage() {
</Button>
<Button
variant="outlined"
loading={loadingRotate}
color="warning"
onClick={handleForceRotate}
sx={{ mt: 2, ml: 2 }}
sx={{ mt: 2, ml: isMobile ? 0 : 2 }}
>
Сгенерировать сейчас
</Button>
+344 -61
View File
@@ -2,14 +2,15 @@ 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
DialogContent, TextField, DialogActions, FormControl, Select,
InputAdornment, InputLabel, MenuItem,
useTheme,
useMediaQuery,
Menu,
ListItemIcon,
ListItemText
} from '@mui/material';
import { Delete, Add, Link as LinkIcon, Refresh, OpenInNew, ContentCopy, Dns, Router } from '@mui/icons-material';
import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove } from '@mui/icons-material';
import api from '../api';
interface Subscription {
@@ -17,6 +18,7 @@ interface Subscription {
name: string;
uuid: string;
inbounds: any[];
inboundsConfig?: any[];
}
interface Tunnel {
@@ -27,15 +29,35 @@ interface Tunnel {
isInstalled: boolean;
}
interface InboundConfigUI {
id: string;
type: string;
port: string;
sni: string;
link?: string;
}
interface Domain { id: number; name: string; }
const CONNECTION_OPTIONS = [
'vless-tcp-reality',
'vless-xhttp-reality',
'vless-grpc-reality',
'vless-ws',
'hysteria2-udp',
'vmess-tcp',
'shadowsocks-tcp',
'trojan-tcp-reality',
'custom',
];
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}`;
@@ -50,35 +72,168 @@ const patchLink = function (link: string, newHost: string): string {
}
return link;
}
return link;
}
};
const generateId = () => Math.random().toString(36).substring(7);
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 [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeSub, setActiveSub] = useState<Subscription | null>(null);
const [domains, setDomains] = useState<Domain[]>([]);
const openActionMenu = Boolean(menuAnchorEl);
// Состояния модального окна конструктора
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [inbounds, setInbounds] = useState<InboundConfigUI[]>([]);
const [portErrors, setPortErrors] = useState<Record<string, string>>({});
// Состояния ссылок
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
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 allDomains = await api.get('/domains/all');
setDomains(allDomains.data);
};
const handleCreate = async () => {
await api.post('/subscriptions', { name });
setOpen(false);
const handleActionMenuClick = (event: React.MouseEvent<HTMLButtonElement>, sub: Subscription) => {
setMenuAnchorEl(event.currentTarget);
setActiveSub(sub);
};
const handleActionMenuClose = () => {
setMenuAnchorEl(null);
setActiveSub(null);
};
const handleOpenCreate = () => {
setEditingId(null);
setName('');
loadSubs();
setInbounds([
{ id: generateId(), type: 'hysteria2-udp', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-ws', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random', link: '' },
]);
setPortErrors({});
setOpen(true);
};
const handleOpenEdit = (sub: Subscription) => {
setEditingId(sub.id);
setName(sub.name);
if (sub.inboundsConfig && sub.inboundsConfig.length > 0) {
setInbounds(sub.inboundsConfig.map(i => ({
id: generateId(),
type: i.type || 'vless-tcp-reality',
port: i.port ? i.port.toString() : 'random',
sni: i.sni || 'random',
link: i.link || ''
})));
} else {
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
}
setPortErrors({});
setOpen(true);
};
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
if (field === 'port' || (field === 'type' && value === 'custom')) {
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
}
};
const addInbound = () => {
if (inbounds.length < 20) {
setInbounds([...inbounds, { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
}
};
const removeInbound = (id?: string) => {
if (id) {
if (inbounds.length > 1) {
setInbounds(inbounds.filter(inb => inb.id !== id));
setPortErrors(prev => {
const n = { ...prev };
delete n[id];
return n;
});
}
} else {
setInbounds([
{
id: crypto.randomUUID(),
type: 'vless-tcp-reality',
port: 'random',
sni: 'random',
link: ''
}
]);
setPortErrors({});
}
};
const handleSave = async () => {
if (Object.keys(portErrors).length > 0) {
alert('Пожалуйста, исправьте ошибки с портами');
return;
}
if (!name.trim()) {
alert('Введите имя подписки');
return;
}
const payload = {
name,
inboundsConfig: inbounds.map(i => {
if (i.type === 'custom') {
return { type: i.type, link: i.link };
}
return {
type: i.type,
port: i.port === 'random' ? 'random' : parseInt(i.port),
sni: i.sni
};
})
};
try {
if (editingId) {
await api.put(`/subscriptions/${editingId}`, payload);
} else {
await api.post('/subscriptions', payload);
}
setOpen(false);
loadSubs();
} catch (error: any) {
alert(error.response?.data?.message || 'Произошла ошибка при сохранении');
}
};
const handleDelete = async (id: string) => {
@@ -104,20 +259,15 @@ export default function SubscriptionsPage() {
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>
<Typography variant={isMobile ? 'h5' : 'h4'}>Подписки</Typography>
{tunnels.length > 0 && (
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
<Select
labelId="server-select-label"
value={selectedServer}
onChange={handleServerChange}
onChange={(e) => setSelectedServer(e.target.value)}
startAdornment={
<InputAdornment position="start">
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
@@ -125,29 +275,22 @@ export default function SubscriptionsPage() {
}
>
<MenuItem value="main">
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</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>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
</MenuItem>
))}
</Select>
</FormControl>
)}
<Box>
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
<Button variant="contained" startIcon={<Add />} onClick={handleOpenCreate}>Создать</Button>
</Box>
</Box>
<Paper>
<Paper sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow>
@@ -164,47 +307,187 @@ export default function SubscriptionsPage() {
<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 />
{!isMobile && (
<>
<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 onClick={(e) => handleActionMenuClick(e, sub)}>
<MoreVert />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{subs.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет подписок</Typography>}
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новая подписка</DialogTitle>
<DialogContent>
<Menu
anchorEl={menuAnchorEl}
open={openActionMenu}
onClose={handleActionMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{isMobile && activeSub && (
<MenuItem onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`)}>
<ListItemIcon><ContentCopy fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Копировать ссылку</ListItemText>
</MenuItem>
)}
{isMobile && activeSub && (
<MenuItem onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`, '_blank')}>
<ListItemIcon><OpenInNew fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Открыть подписку</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => showLinks(activeSub)}>
<ListItemIcon><LinkIcon fontSize="small" /></ListItemIcon>
<ListItemText>Показать конфиги</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => handleOpenEdit(activeSub)}>
<ListItemIcon><Edit fontSize="small" /></ListItemIcon>
<ListItemText>Редактировать</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => handleDelete(activeSub.id)}>
<ListItemIcon><Delete fontSize="small" color="error" /></ListItemIcon>
<ListItemText sx={{ color: 'error.main' }}>Удалить</ListItemText>
</MenuItem>
)}
</Menu>
{/* Модальное окно создания / редактирования */}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth disableRestoreFocus>
<DialogTitle variant='h5'>{editingId ? 'Редактировать подписку' : 'Новая подписка'}</DialogTitle>
<DialogContent dividers>
<TextField
autoFocus margin="dense" label="Имя пользователя" fullWidth
autoFocus margin="dense" label="Имя подписки" fullWidth
value={name} onChange={(e) => setName(e.target.value)}
sx={{ mb: 4 }}
/>
<Typography variant="h6" sx={{ mb: 2 }}>
Инбаунды ({inbounds.length}/20)
</Typography>
{inbounds.map((inb, index) => (
<Box key={inb.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2, p: 2 }}>
<Typography sx={{ mt: 1, minWidth: 30, fontWeight: 'bold' }}>#{index + 1}</Typography>
<FormControl size="small" sx={{ minWidth: 185 }}>
<InputLabel>Тип</InputLabel>
<Select
value={inb.type}
label="Тип"
sx={{ minWidth: '185px' }}
onChange={(e) => handleInboundChange(inb.id, 'type', e.target.value)}
>
{CONNECTION_OPTIONS.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
</Select>
</FormControl>
{inb.type === 'custom' ? (
// Поле для кастомной ссылки
<FormControl size="small" sx={{ flexGrow: 1 }}>
<TextField
size="small"
label="Ссылка на подключение"
placeholder="vless://..."
value={inb.link || ''}
onChange={(e) => handleInboundChange(inb.id, 'link', e.target.value)}
fullWidth
/>
</FormControl>
) : (
<>
<FormControl size="small" sx={{ minWidth: 150 }}>
<TextField
size="small"
label="Порт"
placeholder="random или порт"
value={inb.port}
onChange={(e) => handleInboundChange(inb.id, 'port', e.target.value)}
error={!!portErrors[inb.id]}
helperText={portErrors[inb.id] || ""}
sx={{ width: 140 }}
/>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>SNI</InputLabel>
<Select
value={inb.sni}
label="SNI"
onChange={(e) => handleInboundChange(inb.id, 'sni', e.target.value)}
>
<MenuItem value="random">random</MenuItem>
{domains.map(opt => <MenuItem key={opt.id} value={opt.name}>{opt.name}</MenuItem>)}
</Select>
</FormControl>
</>
)}
<IconButton
color="primary"
onClick={() => removeInbound(inb.id)}
disabled={inbounds.length <= 1}
sx={{ mt: 0.5 }}
>
<Delete />
</IconButton>
</Box>
))}
<Button
variant="outlined"
size='small'
startIcon={<Add />}
onClick={addInbound}
disabled={inbounds.length >= 20}
>
Добавить инбаунд
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<Remove />}
sx={{ ml: 0.5 }}
onClick={() => removeInbound()}
>
Удалить все
</Button>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button onClick={handleCreate}>Создать</Button>
<Button onClick={handleSave} variant="contained" color="primary">Сохранить</Button>
</DialogActions>
</Dialog>
{/* Модальное окно ссылок */}
<Dialog open={linksOpen} onClose={() => setLinksOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Активные ссылки</DialogTitle>
<DialogContent>
@@ -215,7 +498,7 @@ export default function SubscriptionsPage() {
/>
</DialogContent>
<DialogActions>
<Button onClick={() => navigator.clipboard.writeText(currentLinks.join('\n'))}>Копировать всё</Button>
<Button onClick={() => navigator.clipboard.writeText(currentLinks.join('\n'))}>Копировать все</Button>
<Button onClick={() => setLinksOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
+71 -22
View File
@@ -2,7 +2,13 @@ import React, { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, Chip, CircularProgress
DialogContent, TextField, DialogActions, Chip, CircularProgress,
useTheme,
useMediaQuery,
FormControl,
RadioGroup,
FormControlLabel,
Radio
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import api from '../api';
@@ -20,9 +26,12 @@ export default function TunnelsPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [open, setOpen] = useState(false);
const [loadingId, setLoadingId] = useState<number | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
const [form, setForm] = useState({
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: ''
});
useEffect(() => { loadTunnels(); }, []);
@@ -35,9 +44,16 @@ export default function TunnelsPage() {
};
const handleCreate = async () => {
await api.post('/tunnels', form);
const payload = {
...form,
password: authMethod === 'password' ? form.password : null,
privateKey: authMethod === 'key' ? form.privateKey : null,
};
await api.post('/tunnels', payload);
setOpen(false);
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' });
setAuthMethod('password');
loadTunnels();
};
@@ -70,11 +86,11 @@ export default function TunnelsPage() {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Relay серверы</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
</Box>
<Paper>
<Paper sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow>
@@ -95,23 +111,35 @@ export default function TunnelsPage() {
</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" />
}
{!isMobile && (t.isInstalled ?
<Chip icon={<CheckCircle />} label={"Активен"} color="success" size="small" variant="outlined" /> :
<Chip icon={<Error />} label={"Не настроен"} color="warning" size="small" variant="outlined" />
)}
{isMobile && (t.isInstalled ?
<CheckCircle color='success' /> :
<Error color='warning' />
)}
</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>
<>
{isMobile ? (
<IconButton disabled={loadingId !== null} color="primary" onClick={() => handleInstall(t.id)}>
{loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
</IconButton>
) : (
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null}
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
{isMobile ? '' : (loadingId === t.id ? 'Установка...' : 'Установить')}
</Button>
)}
</>
)}
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
<Delete />
@@ -119,7 +147,7 @@ export default function TunnelsPage() {
</TableCell>
</TableRow>
))}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center">Список пуст</TableCell></TableRow>}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>Нет серверов</TableCell></TableRow>}
</TableBody>
</Table>
</Paper>
@@ -133,7 +161,28 @@ export default function TunnelsPage() {
<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')} />
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
<FormControlLabel value="password" control={<Radio />} label="По паролю" />
<FormControlLabel value="key" control={<Radio />} label="По SSH ключу" />
</RadioGroup>
</FormControl>
{authMethod === 'password' ? (
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
) : (
<TextField
margin="dense"
label="SSH Private Key (RSA / Ed25519)"
multiline
rows={4}
fullWidth
value={form.privateKey}
onChange={handleChange('privateKey')}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
+2 -2
View File
@@ -2,7 +2,7 @@ import type { PaletteMode } from '@mui/material';
const lightPalette = {
primary: {
main: '#2563eb',
main: '#1395de',
light: '#60a5fa',
dark: '#1e40af',
},
@@ -21,7 +21,7 @@ const lightPalette = {
const darkPalette = {
primary: {
main: '#3b82f6',
main: '#1395de',
light: '#60a5fa',
dark: '#1d4ed8',
},
+2
View File
@@ -27,6 +27,8 @@ services:
DB_PASSWORD: ${POSTGRES_PASSWORD:-admin}
DB_NAME: ${POSTGRES_DB:-3dp_manager}
JWT_SECRET: ${JWT_SECRET:-secretKey}
ADMIN_LOGIN: ${ADMIN_LOGIN:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
ports:
- "3000:3000"
networks:
+103 -63
View File
@@ -4,8 +4,11 @@ set -euo pipefail
#################################
# КОНФИГУРАЦИЯ И ПЕРЕМЕННЫЕ
#################################
REPO_URL="https://github.com/denpiligrim/3dp-manager/archive/refs/heads/main.tar.gz"
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}"
# Цвета для вывода
RED='\033[0;31m'
@@ -103,9 +106,8 @@ fi
#################################
log "Подготовка директории $PROJECT_DIR..."
mkdir -p "$PROJECT_DIR"
log "Скачивание последней версии проекта..."
curl -L "$REPO_URL" | tar xz -C "$PROJECT_DIR" --strip-components=1
mkdir -p "$PROJECT_DIR/server"
mkdir -p "$PROJECT_DIR/client"
cd "$PROJECT_DIR"
@@ -173,62 +175,78 @@ FINAL_PORT=$(get_random_port)
# --- 4. Генерация паролей ---
DB_PASS=$(openssl rand -base64 12)
JWT_SECRET=$(openssl rand -base64 32)
ADMIN_USER=$(openssl rand -base64 8)
ADMIN_PASS=$(openssl rand -base64 12)
log "Сгенерированы секретные ключи для БД и JWT."
#################################
# Hysteria 2
#################################
# Проверка установки Hysteria 2 через наличие systemd сервиса
if ! systemctl cat hysteria-server.service &> /dev/null; then
echo "Сервис Hysteria 2 не найден. Начинаем установку..."
# Установка Hysteria 2 согласно документации
bash <(curl -fsSL https://get.hy2.sh/)
RANDOM_FREE_PORT=$(get_random_port)
# Генерация надежных паролей
GENERATED_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16)
GENERATED_OBFS_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16)
# Запрос данных у пользователя
echo "=== Настройка Hysteria 2 ==="
read -p "Введите email для уведомлений Let's Encrypt: " HYSTERIA_EMAIL
# Создание конфигурационного файла
cat > /etc/hysteria/config.yaml <<EOF
listen: :$RANDOM_FREE_PORT
acme:
domains:
- $UI_HOST
email: $HYSTERIA_EMAIL
auth:
type: password
password: $GENERATED_PASSWORD
obfs:
type: salamander
salamander:
password: $GENERATED_OBFS_PASSWORD
masquerade:
type: proxy
proxy:
url: https://ya.ru/
rewriteHost: true
EOF
# Перезапуск демона и включение сервиса для автозапуска
systemctl daemon-reload
systemctl enable --now hysteria-server.service
systemctl restart hysteria-server.service
echo "Hysteria 2 успешно установлена и запущена на порту $RANDOM_FREE_PORT"
systemctl status hysteria-server.service --no-pager
else
echo "Hysteria 2 уже установлена, пропускаем установку."
fi
#################################
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
#################################
# --- 1. Dockerfile для Client ---
cat > client/Dockerfile <<EOF
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV VITE_API_URL=/api
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-client.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
# --- 3. Dockerfile для Server ---
cat > server/Dockerfile <<EOF
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/main"]
EOF
cat > server/.env <<EOF
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=admin
DB_PASSWORD=${DB_PASS}
DB_NAME=3dp_manager
ADMIN_LOGIN=${ADMIN_USER}
ADMIN_PASSWORD=${ADMIN_PASS}
EOF
if [[ "$USE_SSL" == "true" ]]; then
@@ -237,7 +255,7 @@ if [[ "$USE_SSL" == "true" ]]; then
# 1. Nginx Config
cat > client/nginx-client.conf <<EOF
server {
listen $FINAL_PORT ssl;
listen 443 ssl;
server_name $UI_HOST;
root /usr/share/nginx/html;
index index.html;
@@ -251,7 +269,7 @@ server {
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host \$host;
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;
}
@@ -266,7 +284,7 @@ server {
location / {
proxy_pass http://backend:3000/;
proxy_set_header Host \$host;
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;
@@ -296,7 +314,7 @@ services:
- app-network
backend:
build: ./server
image: ${IMAGE_SERVER}
container_name: 3dp-backend
restart: always
depends_on:
@@ -309,20 +327,25 @@ services:
DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS}
PORT: 3000
volumes:
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
networks:
- app-network
frontend:
build: ./client
image: ${IMAGE_CLIENT}
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "${FINAL_PORT}:${FINAL_PORT}"
- "${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:
@@ -342,7 +365,7 @@ else
# 1. Nginx Config
cat > client/nginx-client.conf <<EOF
server {
listen ${FINAL_PORT};
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
@@ -356,7 +379,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
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;
@@ -367,7 +390,7 @@ server {
server_name localhost;
location / {
proxy_pass http://backend:3000/;
proxy_set_header Host \$host;
proxy_set_header Host \$http_host;
}
}
EOF
@@ -394,7 +417,7 @@ services:
- app-network
backend:
build: ./server
image: ${IMAGE_SERVER}
container_name: 3dp-backend
restart: always
depends_on:
@@ -407,19 +430,25 @@ services:
DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS}
PORT: 3000
volumes:
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
networks:
- app-network
frontend:
build: ./client
image: ${IMAGE_CLIENT}
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "${FINAL_PORT}:${FINAL_PORT}"
- "${FINAL_PORT}:80"
- "3000:3000"
volumes:
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- app-network
@@ -445,6 +474,17 @@ docker compose up --build -d
log "Очистка кэша сборки..."
docker image prune -f
if LC_ALL=C ufw status 2>/dev/null | grep -q "Status: active"; then
echo "Найден активный UFW. Настраиваю правила..."
ufw allow 443/tcp
ufw allow 443/udp
ufw allow 8443/tcp
ufw allow 8443/udp
ufw allow 10000:60000/tcp
ufw allow 10000:60000/udp
fi
echo ""
echo "==================================================="
if [[ "$USE_SSL" == "true" ]]; then
@@ -452,8 +492,8 @@ if [[ "$USE_SSL" == "true" ]]; then
else
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: http://${UI_HOST}:${FINAL_PORT}${NC}"
fi
echo "Логин: admin"
echo "Пароль: admin"
echo -e "${GREEN}Логин: ${ADMIN_USER}${NC}"
echo -e "${GREEN}Пароль: ${ADMIN_PASS}${NC}"
echo ""
echo "Немедленно измените пароль в Настройках утилиты!"
echo "==================================================="
+12
View File
@@ -0,0 +1,12 @@
{
"name": "3dp-manager",
"version": "2.0.2",
"description": "Inbound generator for 3x-ui",
"private": false,
"repository": {
"type": "git",
"url": "git+https://github.com/denpiligrim/3dp-manager.git"
},
"author": "DenPiligrim",
"license": "GPL-3.0"
}
+7
View File
@@ -0,0 +1,7 @@
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=admin
DB_PASSWORD=
DB_NAME=3dp_manager
ADMIN_LOGIN=admin
ADMIN_PASSWORD=
+40
View File
@@ -21,6 +21,8 @@
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.17.1",
@@ -2975,6 +2977,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -4521,6 +4529,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
"validator": "^13.15.22"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -7540,6 +7565,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/libphonenumber-js": {
"version": "1.12.40",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.40.tgz",
"integrity": "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10716,6 +10747,15 @@
"node": ">=10.12.0"
}
},
"node_modules/validator": {
"version": "13.15.26",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+2
View File
@@ -32,6 +32,8 @@
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.17.1",
+7 -3
View File
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Setting } from '../settings/entities/setting.entity';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthService {
@@ -13,6 +14,7 @@ export class AuthService {
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async validateUser(login: string, pass: string): Promise<any> {
@@ -85,12 +87,14 @@ export class AuthService {
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
if (!login) {
this.logger.log('Инициализация администратора (admin / admin)...');
this.logger.log('Инициализация администратора...');
const envLogin = this.configService.get<string>('ADMIN_LOGIN') || 'admin';
const envPass = this.configService.get<string>('ADMIN_PASSWORD') || 'admin';
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: envLogin });
await this.settingsRepo.save(loginSetting);
const hash = await bcrypt.hash('admin', 10);
const hash = await bcrypt.hash(envPass, 10);
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
await this.settingsRepo.save(passSetting);
+9 -6
View File
@@ -155,11 +155,14 @@ export class ClientController {
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 links = sub.inbounds
?.filter(i => i.link && i.link.length > 0)
.map(i => {
if (i.protocol === 'custom') {
return i.link;
}
return this.patchLink(i.link, relayHost);
}) || [];
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
@@ -267,7 +270,7 @@ export class ClientController {
} catch (e) {
return link;
}
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
} else if (link.startsWith('vless://') || link.startsWith('trojan://') || link.startsWith('hy2://')) {
return link.replace(/@.*?:/, `@${newHost}:`);
} else if (link.startsWith('ss://')) {
if (link.includes('@')) {
+5
View File
@@ -15,6 +15,11 @@ export class DomainsController {
return this.domainsService.createMany(body.domains);
}
@Get('all')
findAllWithoutPagination() {
return this.domainsService.findAllUnpaginated();
}
@Get()
findAll(
@Query('page') page: number,
+4
View File
@@ -60,6 +60,10 @@ export class DomainsService implements OnModuleInit {
};
}
async findAllUnpaginated(): Promise<Domain[]> {
return this.repo.find({ order: { name: 'ASC' } });
}
findOne(id: number) {
return this.repo.findOneBy({ id });
}
+255 -77
View File
@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
@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;
buildVlessRealityTcp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
port,
@@ -26,28 +27,28 @@ export class InboundBuilderService {
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
target: `${sni}:443`,
dest: `${sni}:443`,
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
tcpSettings: { acceptProxyProtocol: false, header: { type: 'none' } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
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;
buildVlessRealityXhttp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-xhttp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
@@ -59,72 +60,127 @@ export class InboundBuilderService {
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
target: `${sni}:443`,
dest: `${sni}:443`,
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
xhttpSettings: { path: '/', mode: 'auto' }
xhttpSettings: {
host: sni,
path: "/",
mode: "auto",
noSSEHeader: false,
scMaxBufferedPosts: 30,
scMaxEachPostBytes: "1000000",
scStreamUpServerSecs: "20-80",
xPaddingBytes: "100-1000"
}
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
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;
buildVlessRealityGrpc(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-grpc-reality`,
protocol: "vless",
remark: "vless-grpc-reality",
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
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',
network: "grpc",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
target: `${sni}:443`,
dest: `${sni}:443`,
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
grpcSettings: { serviceName: 'grpc', multiMode: false }
grpcSettings: {
serviceName: "myservice",
authority: sni,
multiMode: false,
}
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
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;
buildVlessWs(params: { port: number; uuid: string; sni: string }) {
const { port, uuid, sni } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-ws`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
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',
network: "ws",
security: "none",
externalProxy: [],
wsSettings: { path: '/', headers: { Host: domain } }
wsSettings: {
host: sni,
path: "/",
acceptProxyProtocol: false,
heartbeatPeriod: 0,
}
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
@@ -136,59 +192,142 @@ export class InboundBuilderService {
protocol: 'vmess',
remark: 'vmess-tcp',
settings: JSON.stringify({
clients: [{ id: uuid, alterId: 0, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
disableInsecureEncryption: false
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: { header: { type: 'http', request: { method: 'GET', path: ['/'], headers: { Host: [] } } } } }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
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;
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'shadowsocks',
remark: 'shadowsocks-tcp',
settings: JSON.stringify({
method: 'aes-256-gcm',
password: uuid,
network: 'tcp,udp',
clients: []
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' }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
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;
buildTrojanRealityTcp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'trojan',
remark: `trojan-tcp-reality`,
settings: JSON.stringify({
clients: [{ password: uuid, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
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',
network: "tcp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
target: `${sni}:443`,
dest: `${sni}:443`,
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
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: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
@@ -196,29 +335,29 @@ export class InboundBuilderService {
return uuidv4();
}
buildInboundLink(inbound: any, domain: string, idOrPass: string, flagEmoji: string): string {
buildInboundLink(inbound: any, sni: string, idOrPass: string, flagEmoji: string): string {
this.flag = flagEmoji;
let link = "";
switch (inbound.protocol) {
case "vless":
link = this.buildVlessLink(inbound, domain, idOrPass);
link = this.buildVlessLink(inbound, sni, idOrPass);
break;
case "vmess":
link = this.buildVmessLink(inbound, domain, idOrPass);
link = this.buildVmessLink(inbound, sni, idOrPass);
break;
case "shadowsocks":
link = this.buildSsLink(inbound, domain, idOrPass);
link = this.buildSsLink(inbound, sni, idOrPass);
break;
case "trojan":
link = this.buildTrojanLink(inbound, domain, idOrPass);
link = this.buildTrojanLink(inbound, sni, idOrPass);
break;
}
return link;
}
private buildVlessLink(inbound: any, domain: string, uuid: string) {
private buildVlessLink(inbound: any, sni: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
@@ -269,18 +408,18 @@ export class InboundBuilderService {
}
return (
`vless://${uuid}@${domain}:${inbound.port}` +
`vless://${uuid}@${sni}:${inbound.port}` +
`?${params.toString()}` +
`#${this.flag}%20${encodeURIComponent(inbound.remark)}`
);
}
private buildVmessLink(inbound: any, domain: string, uuid: string) {
private buildVmessLink(inbound: any, sni: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const vmessObj = {
add: domain,
aid: '',
add: sni,
aid: '0',
alpn: "",
fp: "",
host: "",
@@ -303,41 +442,80 @@ export class InboundBuilderService {
return `vmess://${base64}`;
}
private buildSsLink(inbound: any, domain: string, idOrPass: string) {
private buildSsLink(inbound: any, sni: string, idOrPass: string) {
const settings = JSON.parse(inbound.settings);
const method = settings.method;
const serverPassword = settings.password;
const finalPass = serverPassword || idOrPass;
const clientPassword = settings.clients[0].password;
const userInfo = `${method}:${finalPass}`;
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}`;
return `ss://${base64}@${sni}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
}
private buildTrojanLink(inbound: any, domain: string, password: string) {
private buildTrojanLink(inbound: any, sni: 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 SNI = reality.serverNames?.[0] || sni;
const sid = reality.shortIds?.[0] || "";
const spx = '%2F';
return (
`trojan://${password}@${domain}:${inbound.port}` +
`trojan://${password}@${SNI}:${inbound.port}` +
`?type=tcp` +
`&security=reality` +
`&pbk=${pbk}` +
`&fp=random` +
`&sni=${sni}` +
`&sni=${SNI}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${this.flag}%20${inbound.remark}`
);
}
buildHysteria2Link(serverAddress: string, sni: string, remark: string): string {
let auth = 'YOUR_AUTH';
let obfs = 'salamander';
let obfsPass = 'YOUR_PASS';
let port = 443;
try {
const configPath = '/etc/hysteria/config.yaml';
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
const authMatch = fileContent.match(/password:\s*['"]?([^'"\n]+)['"]?/);
if (authMatch) auth = authMatch[1];
const obfsMatch = fileContent.match(/type:\s*['"]?(salamander)['"]?/);
if (obfsMatch) obfs = obfsMatch[1];
const passMatch = fileContent.match(/salamander:[\s\S]*?password:\s*['"]?([^'"\n]+)['"]?/);
if (passMatch) obfsPass = passMatch[1];
const listenMatch = fileContent.match(/listen:\s*['"]?:(\d+)['"]?/);
if (listenMatch) port = parseInt(listenMatch[1], 10);
} else {
console.warn(`Конфиг Hysteria2 не найден по пути: ${configPath}`);
}
} catch (e) {
console.error('Ошибка чтения конфига Hysteria2', e);
}
const params = new URLSearchParams();
params.set('insecure', '0');
params.set('sni', serverAddress);
params.set('obfs', obfs);
params.set('obfs-password', obfsPass);
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
}
}
+13
View File
@@ -0,0 +1,13 @@
export const CONNECTION_TYPES = [
'vless-tcp-reality',
'vless-xhttp-reality',
'vless-grpc-reality',
'vless-ws',
'vmess-tcp',
'shadowsocks-tcp',
'trojan-tcp-reality',
'hysteria2-udp',
'custom',
] as const;
export type ConnectionType = typeof CONNECTION_TYPES[number];
+120 -43
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -13,7 +13,7 @@ import { InboundBuilderService } from '../inbounds/inbound-builder.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RotationService {
export class RotationService implements OnModuleInit {
private readonly logger = new Logger(RotationService.name);
constructor(
@@ -25,6 +25,26 @@ export class RotationService {
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' } });
@@ -35,8 +55,10 @@ export class RotationService {
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) {
if (diffMinutes < intervalMinutes || isStopped) {
return;
}
@@ -52,22 +74,24 @@ export class RotationService {
await this.settingRepo.save(s);
}
async performRotation() {
async performRotation() {
this.logger.log('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
if (!isLoginSuccess) {
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
return;
return { success: false, message: 'Не удалось войти в панель 3x-ui' };
}
const subscriptions = await this.subRepo.find({ where: { isEnabled: true }, relations: ['inbounds'] });
if (subscriptions.length === 0) return;
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;
return { success: false, message: 'Список доменов пуст!' };
}
for (const sub of subscriptions) {
@@ -75,14 +99,18 @@ export class RotationService {
}
this.logger.log('Ротация завершена.');
return { success: true, message: 'Ротация успешно выполнена' };
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
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);
if (inbound.xuiId && inbound.xuiId > 0) {
await this.xuiService.deleteInbound(inbound.xuiId);
}
await this.inboundRepo.delete(inbound.id);
}
}
@@ -94,55 +122,103 @@ export class RotationService {
}
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();
// Получаем конфиг или пустой массив
const inboundsConfig = sub.inboundsConfig || [];
for (const config of inboundsConfig) {
const type = config.type;
const uuid = uuidv4();
let sni = '';
// === 1. Обработка Custom ===
if (type === 'custom') {
const newInbound = this.inboundRepo.create({
xuiId: 0, // Не привязано к 3x-ui
port: 0,
protocol: 'custom',
remark: 'custom-link',
link: config.link || '',
subscription: sub
});
await this.inboundRepo.save(newInbound);
continue;
} else {
sni = config.sni === 'random' ? this.pickDomain(domains) : config.sni;
}
// === 2. Обработка Hysteria2 ===
if (type === 'hysteria2-udp') {
const link = this.inboundBuilder.buildHysteria2Link(serverAddress, sni, flagEmoji + '%20hysteria2-udp');
const newInbound = this.inboundRepo.create({
xuiId: 0,
port: 0, // Обычно Hysteria висит на 443, фактический порт вытаскивается в билдере
protocol: 'hysteria2',
remark: 'hysteria2-udp',
link: link,
subscription: sub
});
await this.inboundRepo.save(newInbound);
continue;
}
// === 3. Обработка стандартных инбаундов Xray (3x-ui) ===
// Определяем порт
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;
if (config.port === 'random' || !config.port) {
port = await this.getFreePort(0, usedPorts);
} else {
// Если передан конкретный порт строкой или числом
port = typeof config.port === 'string' ? parseInt(config.port, 10) : config.port;
}
usedPorts.add(port);
const xuiId = await this.xuiService.addInbound(config);
let xuiConfig: any;
switch (type) {
case 'vless-tcp-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({ port, uuid, sni, ...keys });
break;
case 'vless-xhttp-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({ port, uuid, sni, ...keys });
break;
case 'vless-grpc-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({ port, uuid, sni, ...keys });
break;
case 'vless-ws':
xuiConfig = this.inboundBuilder.buildVlessWs({ port, uuid, sni });
break;
case 'vmess-tcp':
xuiConfig = this.inboundBuilder.buildVmessTcp({ port, uuid });
break;
case 'shadowsocks-tcp':
xuiConfig = this.inboundBuilder.buildShadowsocksTcp({ port, uuid });
break;
case 'trojan-tcp-reality':
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({ port, uuid, sni, ...keys });
break;
default:
this.logger.warn(`Неизвестный тип инбаунда: ${type}`);
continue;
}
const xuiId = await this.xuiService.addInbound(xuiConfig);
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 idOrPass = xuiConfig.settings ? JSON.parse(xuiConfig.settings).clients?.[0]?.id || JSON.parse(xuiConfig.settings).clients?.[0]?.password : "";
const fullLink = this.inboundBuilder.buildInboundLink(xuiConfig, serverAddress, idOrPass, flagEmoji);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
port: port,
protocol: config.protocol,
remark: config.remark,
protocol: xuiConfig.protocol,
remark: xuiConfig.remark,
link: fullLink,
subscription: sub
});
@@ -150,6 +226,7 @@ export class RotationService {
}
}
}
private pickDomain(list: Domain[]): string {
return list[Math.floor(Math.random() * list.length)].name;
}
+17 -2
View File
@@ -2,14 +2,17 @@ import { Controller, Get, Post, Body } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
import * as net from 'net';
import * as dns from 'dns/promises';
import { COUNTRIES } from './countries';
import { XuiService } from 'src/xui/xui.service';
@Controller('settings')
export class SettingsController {
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private xuiService: XuiService
) {}
@Get()
@@ -18,14 +21,26 @@ export class SettingsController {
return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
}
@Post('check')
async checkConnection(@Body() body: { xui_url: string; xui_login: string; xui_password: string }) {
const success = await this.xuiService.checkConnection(body.xui_url, body.xui_login, body.xui_password);
return { success };
}
@Post()
async update(@Body() settings: Record<string, string>) {
if (settings.xui_url) {
try {
const parsed = new URL(settings.xui_url);
settings['xui_host'] = parsed.hostname;
const { address } = await dns.lookup(parsed.hostname);
let address = '';
if (net.isIP(parsed.hostname) === 0) {
const result = await dns.lookup(parsed.hostname);
address = result.address;
} else {
address = parsed.hostname;
}
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SettingsController } from './settings.controller';
import { Setting } from './entities/setting.entity';
import { XuiModule } from 'src/xui/xui.module';
@Module({
imports: [TypeOrmModule.forFeature([Setting])],
imports: [TypeOrmModule.forFeature([Setting]), XuiModule],
controllers: [SettingsController],
})
export class SettingsModule {}
@@ -0,0 +1,31 @@
import { IsString, IsArray, ValidateNested, IsOptional, Min, Max, ArrayMinSize, ArrayMaxSize } from 'class-validator';
import { Type } from 'class-transformer';
export class InboundConfigDto {
@IsString()
type: string;
@IsOptional()
port?: number | 'random';
@IsString()
@IsOptional()
sni?: string | 'random';
@IsString()
@IsOptional()
link?: string;
}
export class CreateSubscriptionDto {
@IsString()
name: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => InboundConfigDto)
@ArrayMinSize(1)
@ArrayMaxSize(20)
@IsOptional()
inboundsConfig?: InboundConfigDto[];
}
@@ -15,6 +15,9 @@ export class Subscription {
@Column({ default: true })
isEnabled: boolean;
@Column({ type: 'simple-json', nullable: true })
inboundsConfig: any[];
@OneToMany(() => Inbound, (inbound) => inbound.subscription)
inbounds: Inbound[];
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Param, Put } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
@Controller('subscriptions')
export class SubscriptionsController {
@@ -11,8 +12,13 @@ export class SubscriptionsController {
}
@Post()
create(@Body('name') name: string) {
return this.subscriptionsService.create(name);
create(@Body() createSubscriptionDto: CreateSubscriptionDto) {
return this.subscriptionsService.create(createSubscriptionDto);
}
@Put(':id')
update(@Param('id') id: string, @Body() updateSubscriptionDto: CreateSubscriptionDto) {
return this.subscriptionsService.update(id, updateSubscriptionDto);
}
@Delete(':id')
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription } from './entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { XuiService } from '../xui/xui.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
@@ -11,7 +11,6 @@ export class SubscriptionsService {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Inbound)
private xuiService: XuiService,
) {}
@@ -19,11 +18,32 @@ export class SubscriptionsService {
return this.subRepo.find({ relations: ['inbounds'], order: { createdAt: 'DESC' } });
}
async create(name: string) {
async create(dto: CreateSubscriptionDto) {
const sub = this.subRepo.create({
name,
name: dto.name,
uuid: uuidv4(),
inboundsConfig: dto.inboundsConfig || [],
});
return this.subRepo.save(sub);
}
async update(id: string, dto: CreateSubscriptionDto) {
const sub = await this.subRepo.findOne({
where: { id },
relations: ['inbounds']
});
if (!sub) {
throw new NotFoundException(`Subscription with ID ${id} not found`);
}
sub.name = dto.name;
if (dto.inboundsConfig) {
sub.inboundsConfig = dto.inboundsConfig;
}
return this.subRepo.save(sub);
}
@@ -31,7 +51,7 @@ export class SubscriptionsService {
const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'] });
if (!sub) return;
if (sub.inbounds) {
if (sub.inbounds && sub.inbounds.length > 0) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
}
+5 -2
View File
@@ -17,8 +17,11 @@ export class Tunnel {
@Column()
username: string;
@Column({ select: false })
password: string;
@Column({ select: false, nullable: true })
password?: string;
@Column({ type: 'text', select: false, nullable: true })
privateKey?: string;
@Column({ nullable: true })
domain: string;
+2 -1
View File
@@ -6,7 +6,7 @@ export class SshService {
private readonly logger = new Logger(SshService.name);
async executeCommand(
config: { host: string; port: number; username: string; password?: string },
config: { host: string; port: number; username: string; password?: string, privateKey?: string },
command: string
): Promise<string> {
return new Promise((resolve, reject) => {
@@ -42,6 +42,7 @@ export class SshService {
port: config.port,
username: config.username,
password: config.password,
privateKey: config.privateKey,
readyTimeout: 20000,
});
});
+4 -2
View File
@@ -31,12 +31,13 @@ export class TunnelsService {
async installScript(id: number) {
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
.addSelect('tunnel.password')
.addSelect('tunnel.privateKey')
.where('tunnel.id = :id', { id })
.getOne();
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_ip' } });
if (!hostSetting || !hostSetting.value) {
throw new HttpException(
@@ -55,7 +56,8 @@ export class TunnelsService {
host: tunnel.ip,
port: tunnel.sshPort,
username: tunnel.username,
password: tunnel.password
password: tunnel.password,
privateKey: tunnel.privateKey
}, command);
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
+23
View File
@@ -119,6 +119,29 @@ export class XuiService {
}
}
async checkConnection(url: string, username: string, pass: string): Promise<boolean> {
try {
const tempApi = axios.create({
baseURL: url,
timeout: 5000,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
withCredentials: true
});
const res = await tempApi.post('/login', {
username: username,
password: pass,
});
if (res.headers['set-cookie'] && res.data?.success) {
return true;
}
} catch (e) {
this.logger.warn(`Ошибка авторизации: ${e.message}`);
}
return false;
}
async getNewX25519Cert() {
try {
const res = await this.api.get('/panel/api/server/getNewX25519Cert');
+9 -48
View File
@@ -17,17 +17,10 @@ need_root() {
[[ $EUID -eq 0 ]] || die "Запускать только от root"
}
if ! command -v curl >/dev/null 2>&1; then
echo "❌ curl не установлен. Установите curl и повторите попытку"
echo " apt install -y curl"
exit 1
fi
#################################
# CONFIG
#################################
PROJECT_DIR="/opt/3dp-manager"
REPO_RAW="https://raw.githubusercontent.com/denpiligrim/3dp-manager/main"
#################################
# START
@@ -46,53 +39,21 @@ cd "$PROJECT_DIR"
command -v docker >/dev/null 2>&1 || die "Docker не установлен"
docker compose version >/dev/null 2>&1 || die "docker compose v2 недоступен"
#################################
# DOWNLOAD FILES
#################################
log "Загружаем обновлённые файлы из репозитория"
mkdir -p app
curl -fsSL "$REPO_RAW/app/Dockerfile" -o app/Dockerfile
curl -fsSL "$REPO_RAW/app/package.json" -o app/package.json
curl -fsSL "$REPO_RAW/app/index.js" -o app/index.js
curl -fsSL "$REPO_RAW/app/rotate.js" -o app/rotate.js
curl -fsSL "$REPO_RAW/app/builders/buildVlessRealityTcp.js" -o app/builders/buildVlessRealityTcp.js
curl -fsSL "$REPO_RAW/app/builders/buildVlessRealityXhttp.js" -o app/builders/buildVlessRealityXhttp.js
curl -fsSL "$REPO_RAW/app/builders/buildTrojanRealityTcp.js" -o app/builders/buildTrojanRealityTcp.js
curl -fsSL "$REPO_RAW/app/builders/buildShadowsocksTcp.js" -o app/builders/buildShadowsocksTcp.js
curl -fsSL "$REPO_RAW/app/builders/buildVmessTcp.js" -o app/builders/buildVmessTcp.js
curl -fsSL "$REPO_RAW/app/builders/buildVlessRealityGrpc.js" -o app/builders/buildVlessRealityGrpc.js
curl -fsSL "$REPO_RAW/app/builders/buildVlessWs.js" -o app/builders/buildVlessWs.js
curl -fsSL "$REPO_RAW/app/builders/buildInboundLink.js" -o app/builders/buildInboundLink.js
curl -fsSL "$REPO_RAW/whitelist.txt" -o whitelist.txt
log "Файлы обновлены"
#################################
# REBUILD BACKEND
#################################
log "Пересобираем backend"
docker compose build node
#################################
# RESTART CONTAINERS
#################################
log "Перезапускаем контейнеры"
docker compose up -d
if [ -f "app/my_whitelist.txt" ]; then
log "✔ Копируем my_whitelist.txt в контейнер..."
docker cp app/my_whitelist.txt node:/app/my_whitelist.txt
log "Скачивание последних версий Docker-образов..."
if docker compose pull; then
log "Образы успешно загружены."
else
error "Ошибка при скачивании образов. Проверьте подключение к интернету или доступность GitHub Container Registry."
fi
#################################
# HEALTH CHECK
#################################
sleep 2
log "Пересоздание контейнеров..."
docker compose up -d
docker compose ps | grep node >/dev/null || die "Backend не запущен"
docker compose ps | grep nginx >/dev/null || die "Nginx не запущен"
log "Очистка старых Docker-образов (освобождение места)..."
docker image prune -f
#################################
# DONE