13 Commits

Author SHA1 Message Date
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
18 changed files with 519 additions and 225 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Docker Build & Publish
on:
push:
branches: [ "main" ]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Server image
uses: docker/build-push-action@v5
with:
context: ./server
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-server:${{ github.ref_name }}
- name: Build and push Client image
uses: docker/build-push-action@v5
with:
context: ./client
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-client:${{ github.ref_name }}
+5 -47
View File
@@ -2,11 +2,11 @@
<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.1-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 имеет графический интерфейс и простые пользовательские настройки.
**Поддержать проект**
@@ -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.1-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.1-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.1-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.1-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;
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Footer() {
<IconButton
component="a"
href="https://github.com/denpiligrim"
href="https://github.com/denpiligrim/3dp-manager"
target="_blank"
aria-label="GitHub"
color="inherit"
+165 -7
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 } 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,8 @@ 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);
useEffect(() => {
loadSettings();
@@ -37,6 +41,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 +104,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 +131,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>
<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 +258,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 +272,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: 2 }}
onClick={handleCheckConnection}
>
Проверить
</Button>
)}
</Paper>
</Grid>
@@ -162,6 +319,7 @@ export default function SettingsPage() {
</Button>
<Button
variant="outlined"
loading={loadingRotate}
color="warning"
onClick={handleForceRotate}
sx={{ mt: 2, ml: 2 }}
+8 -2
View File
@@ -191,12 +191,18 @@ export default function SubscriptionsPage() {
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<Dialog open={open} onClose={() => setOpen(false)} disableRestoreFocus>
<DialogTitle>Новая подписка</DialogTitle>
<DialogContent>
<TextField
autoFocus margin="dense" label="Имя пользователя" fullWidth
value={name} onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
}
}}
/>
</DialogContent>
<DialogActions>
@@ -215,7 +221,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>
+21 -61
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"
@@ -178,51 +180,6 @@ log "Сгенерированы секретные ключи для БД и JWT
#################################
# ГЕНЕРАЦИЯ ФАЙЛОВ 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
@@ -237,7 +194,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 +208,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 +223,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 +253,7 @@ services:
- app-network
backend:
build: ./server
image: ${IMAGE_SERVER}
container_name: 3dp-backend
restart: always
depends_on:
@@ -314,15 +271,16 @@ services:
- 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 +300,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 +314,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 +325,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 +352,7 @@ services:
- app-network
backend:
build: ./server
image: ${IMAGE_SERVER}
container_name: 3dp-backend
restart: always
depends_on:
@@ -412,14 +370,16 @@ services:
- 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
+178 -40
View File
@@ -35,7 +35,7 @@ export class InboundBuilderService {
},
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 })
};
}
@@ -47,7 +47,7 @@ export class InboundBuilderService {
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: []
@@ -66,9 +66,23 @@ export class InboundBuilderService {
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
xhttpSettings: { path: '/', mode: 'auto' }
xhttpSettings: {
host: domain,
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
})
};
}
@@ -77,17 +91,28 @@ export class InboundBuilderService {
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-grpc-reality`,
protocol: "vless",
remark: "vless-reality-grpc",
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,
@@ -99,9 +124,18 @@ export class InboundBuilderService {
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
grpcSettings: { serviceName: 'grpc', multiMode: false }
grpcSettings: {
serviceName: "myservice",
authority: domain,
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
})
};
}
@@ -113,18 +147,39 @@ export class InboundBuilderService {
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: domain,
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,29 +191,77 @@ 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
})
};
}
@@ -170,12 +273,24 @@ export class InboundBuilderService {
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,
@@ -184,11 +299,34 @@ export class InboundBuilderService {
dest: `${domain}:443`,
serverNames: [domain],
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
})
};
}
@@ -280,7 +418,7 @@ export class InboundBuilderService {
const vmessObj = {
add: domain,
aid: '',
aid: '0',
alpn: "",
fp: "",
host: "",
@@ -308,9 +446,9 @@ export class InboundBuilderService {
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")
+32 -7
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,6 +99,7 @@ export class RotationService {
}
this.logger.log('Ротация завершена.');
return { success: true, message: 'Ротация успешно выполнена' };
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
@@ -4,12 +4,14 @@ import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
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,6 +20,12 @@ 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) {
+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 {}
@@ -11,7 +11,6 @@ export class SubscriptionsService {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Inbound)
private xuiService: XuiService,
) {}
+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