diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 0db5d2b..adf70bf 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -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({ @@ -125,20 +128,20 @@ export default function SettingsPage() { } }; -const handleForceRotate = async () => { + const handleForceRotate = async () => { if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) { try { setLoadingRotate(true); const res = await api.post('/rotation/rotate-all'); - + setLoadingRotate(false); if (res.data && res.data.success) { setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' }); } else { - setMsg({ - open: true, - type: 'error', - text: res.data?.message || 'Ошибка выполнения ротации' + setMsg({ + open: true, + type: 'error', + text: res.data?.message || 'Ошибка выполнения ротации' }); } } catch (e) { @@ -148,12 +151,105 @@ const handleForceRotate = async () => { } }; + 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 ( Настройки утилиты + + + + + Статус сервиса + + {isPaused ? + } label="Остановлен" color="warning" size="small" variant="outlined" /> : + } label="Активен" color="success" size="small" variant="outlined" /> + } + + + + {isPaused ? : } + + + + {/* Последняя генерация */} + + + + + Последняя генерация + + + {formatDate(settings.last_rotation_timestamp)} + + + + + + {/* Следующая генерация */} + + + + + Следующая генерация + + + {getNextRotationDate()} + + + + + + + Панель 3x-ui diff --git a/client/src/pages/SubscriptionsPage.tsx b/client/src/pages/SubscriptionsPage.tsx index 864209c..455ff81 100644 --- a/client/src/pages/SubscriptionsPage.tsx +++ b/client/src/pages/SubscriptionsPage.tsx @@ -191,12 +191,18 @@ export default function SubscriptionsPage() { - setOpen(false)}> + setOpen(false)} disableRestoreFocus> Новая подписка setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCreate(); + } + }} /> @@ -215,7 +221,7 @@ export default function SubscriptionsPage() { /> - + diff --git a/server/src/inbounds/inbound-builder.service.ts b/server/src/inbounds/inbound-builder.service.ts index 06eb580..7883583 100644 --- a/server/src/inbounds/inbound-builder.service.ts +++ b/server/src/inbounds/inbound-builder.service.ts @@ -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") diff --git a/server/src/rotation/rotation.service.ts b/server/src/rotation/rotation.service.ts index 8636089..cefaa1c 100644 --- a/server/src/rotation/rotation.service.ts +++ b/server/src/rotation/rotation.service.ts @@ -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,7 +74,7 @@ export class RotationService { await this.settingRepo.save(s); } - async performRotation() { + async performRotation() { this.logger.log('Запуск плановой ротации...'); const isLoginSuccess = await this.xuiService.login(); diff --git a/update.sh b/update.sh index 458a457..a35c343 100644 --- a/update.sh +++ b/update.sh @@ -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