add: rotation status

This commit is contained in:
Den Piligrim
2026-02-03 18:42:53 +03:00
parent 653896ae56
commit 98ff14d464
5 changed files with 324 additions and 101 deletions
+103 -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({
@@ -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 (
<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>
+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>
+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")
+26 -4
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,7 +74,7 @@ export class RotationService {
await this.settingRepo.save(s);
}
async performRotation() {
async performRotation() {
this.logger.log('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
+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