func optimization

This commit is contained in:
Den Piligrim
2026-01-31 18:44:09 +03:00
parent 6db895bcc7
commit 27310c0bc0
39 changed files with 1989 additions and 539 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
build
npm-debug.log
.git
.env
Dockerfile
+21
View File
@@ -0,0 +1,21 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV VITE_API_URL=/api
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
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;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+2 -2
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
@@ -23,7 +23,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import SubscriptionsPage from './pages/SubscriptionsPage';
-4
View File
@@ -2,7 +2,6 @@ import React, { createContext, useState, useMemo, useContext, useEffect } from '
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import useMediaQuery from '@mui/material/useMediaQuery';
// Импортируем нашу настройку
import { getDesignTokens } from './theme';
type ColorMode = 'light' | 'dark' | 'system';
@@ -17,7 +16,6 @@ const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
export const useThemeContext = () => useContext(ThemeContext);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Читаем из localStorage или ставим 'system'
const [mode, setMode] = useState<ColorMode>(() => {
return (localStorage.getItem('themeMode') as ColorMode) || 'system';
});
@@ -36,7 +34,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
});
};
// Вычисляем реальную тему (light/dark) на основе настроек и системы
const theme = useMemo(() => {
let activeMode: ColorMode;
@@ -46,7 +43,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
activeMode = mode;
}
// ВАЖНО: Используем нашу функцию getDesignTokens
const themeOptions = getDesignTokens(activeMode);
return createTheme(themeOptions);
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000/api',
baseURL: `${location.protocol}//${location.hostname}:3000/api`,
});
export default api;
+32 -12
View File
@@ -3,34 +3,54 @@ import api from '../api';
interface AuthContextType {
token: string | null;
isAuthenticated: boolean;
login: (token: string) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType>(null!);
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [token, setToken] = useState<string | null>(() => {
const savedToken = localStorage.getItem('token');
if (savedToken) {
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`;
}
return savedToken;
});
const login = (newToken: string) => {
localStorage.setItem('token', newToken);
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
setToken(newToken);
};
const logout = () => {
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
setToken(null);
};
useEffect(() => {
if (token) {
localStorage.setItem('token', token);
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
}
}, [token]);
const login = (newToken: string) => setToken(newToken);
const logout = () => setToken(null);
return (
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
};
+1 -7
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import {
AppBar, Toolbar, Typography, IconButton, Tooltip, Box,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, List, ListItem, ListItemText
@@ -16,10 +16,8 @@ export default function Header() {
const { logout } = useAuth();
const navigate = useNavigate();
// Состояние для модального окна справки
const [helpOpen, setHelpOpen] = useState(false);
// Логика выхода
const handleLogout = () => {
if (confirm('Вы действительно хотите выйти?')) {
logout();
@@ -57,21 +55,18 @@ export default function Header() {
<Box sx={{ display: 'flex', gap: 1 }}>
{/* Кнопка Справки */}
<Tooltip title="Справка о программе">
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
<HelpOutline />
</IconButton>
</Tooltip>
{/* Кнопка Темы */}
<Tooltip title={`Режим: ${getThemeLabel()}`}>
<IconButton color="inherit" onClick={toggleColorMode}>
{getThemeIcon()}
</IconButton>
</Tooltip>
{/* Кнопка Выхода */}
<Tooltip title="Выйти из системы">
<IconButton color="inherit" onClick={handleLogout}>
<Logout />
@@ -82,7 +77,6 @@ export default function Header() {
</Toolbar>
</AppBar>
{/* Модальное окно справки */}
<Dialog
open={helpOpen}
onClose={() => setHelpOpen(false)}
+1 -7
View File
@@ -1,4 +1,3 @@
import React from 'react';
import {
Toolbar, Drawer, List, ListItem,
ListItemButton, ListItemIcon, ListItemText, Box
@@ -6,7 +5,7 @@ import {
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import Header from './Header'; // <--- Новый компонент
import Header from './Header';
import Footer from './Footer';
const drawerWidth = 240;
@@ -25,10 +24,8 @@ export default function Layout() {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
{/* Шапка */}
<Header />
{/* Боковое меню */}
<Drawer
variant="permanent"
sx={{
@@ -55,7 +52,6 @@ export default function Layout() {
</Box>
</Drawer>
{/* Основной контейнер контента */}
<Box
component="main"
sx={{
@@ -68,12 +64,10 @@ export default function Layout() {
>
<Toolbar />
{/* Контент страницы */}
<Box sx={{ flexGrow: 1, p: 3 }}>
<Outlet />
</Box>
{/* Футер */}
<Footer />
</Box>
</Box>
+12 -22
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
import { Delete, Add, DeleteSweep, UploadFile, Remove } from '@mui/icons-material';
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
import api from '../api';
interface Domain { id: number; name: string; }
@@ -9,22 +9,15 @@ export default function DomainsPage() {
const [domains, setDomains] = useState<Domain[]>([]);
const [newDomain, setNewDomain] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [totalCount, setTotalCount] = useState(0); // Общее кол-во записей в БД
const [totalCount, setTotalCount] = useState(0);
// Состояние пагинации
const [page, setPage] = useState(0); // MUI использует индекс с 0
const [rowsPerPage, setRowsPerPage] = useState(10); // По умолчанию 10
useEffect(() => {
loadDomains();
}, [page, rowsPerPage]);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const loadDomains = async () => {
try {
// Backend ждет page начиная с 1, а MUI дает с 0. Поэтому page + 1
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
// Сервер теперь возвращает { data: [], total: 123 }
setDomains(data.data);
setTotalCount(data.total);
} catch (e) {
@@ -32,15 +25,17 @@ export default function DomainsPage() {
}
};
// Обработчик смены страницы
const handleChangePage = (event: unknown, newPage: number) => {
useEffect(() => {
loadDomains();
}, [page, rowsPerPage]);
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
// Обработчик смены кол-ва строк на странице
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0); // Сбрасываем на первую страницу
setPage(0);
};
const handleAdd = async () => {
@@ -61,12 +56,11 @@ export default function DomainsPage() {
try {
await api.delete('/domains/all');
loadDomains();
} catch (e) { alert('Ошибка удаления'); }
} catch (_e) { alert('Ошибка удаления'); }
}
}
};
// --- ЛОГИКА ЗАГРУЗКИ ФАЙЛА ---
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -76,18 +70,15 @@ export default function DomainsPage() {
const text = e.target?.result as string;
if (!text) return;
// Разбиваем текст на строки по переносу
const lines = text.split(/\r?\n/);
// Отправляем на сервер
try {
const { data } = await api.post('/domains/upload', { domains: lines });
alert(`Успешно добавлено доменов: ${data.count}`);
loadDomains();
} catch (err) {
} catch (_err) {
alert('Ошибка при загрузке списка');
} finally {
// Сбрасываем инпут, чтобы можно было загрузить тот же файл повторно
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
@@ -111,7 +102,6 @@ export default function DomainsPage() {
>
Из файла
</Button>
{/* Скрытый инпут */}
<input
type="file"
accept=".txt"
-1
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Box, Typography, Button, Container } from '@mui/material';
import { useNavigate } from 'react-router-dom';
+70 -31
View File
@@ -1,38 +1,47 @@
import React, { useEffect, useState } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment } from '@mui/material';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip } from '@mui/material';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
const ROTATION_PRESETS = [
{ label: 'Сутки', value: 1440 },
{ label: '3 дня', value: 4320 },
{ label: 'Неделя', value: 10080 },
];
export default function SettingsPage() {
// Настройки 3x-ui и ротации
const [settings, setSettings] = useState({
xui_url: '',
xui_login: '',
xui_password: '',
rotation_interval: '30', // Значение по умолчанию
rotation_interval: '30',
});
// Настройки админа (локальное состояние формы)
const [adminProfile, setAdminProfile] = useState({
login: '',
password: '',
});
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success'|'error', text: '' });
const { logout } = useAuth(); // Чтобы разлогинить, если сменили свои данные
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [intervalError, setIntervalError] = useState('');
useEffect(() => {
loadSettings();
}, []);
useEffect(() => {
const val = parseInt(settings.rotation_interval, 10);
if (isNaN(val) || val < 10) {
setIntervalError('Минимальный интервал — 10 минут');
} else {
setIntervalError('');
}
}, [settings.rotation_interval]);
const loadSettings = async () => {
try {
const { data } = await api.get('/settings');
// Заполняем основные настройки
setSettings((prev) => ({ ...prev, ...data }));
// Логин админа тоже приходит в settings (если мы разрешили его чтение),
// но пароль (хеш) показывать нельзя.
if (data.admin_login) {
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
}
@@ -41,14 +50,21 @@ export default function SettingsPage() {
}
};
// --- Handlers для настроек 3x-ui и ротации ---
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setSettings({ ...settings, [prop]: event.target.value });
};
const handlePresetClick = (minutes: number) => {
setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() }));
};
const handleSaveSettings = async () => {
if (intervalError) {
setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' });
return;
}
try {
// Отправляем всё, что в settings
await api.post('/settings', settings);
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
} catch (e) {
@@ -56,7 +72,6 @@ export default function SettingsPage() {
}
};
// --- Handlers для профиля админа ---
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
};
@@ -65,27 +80,34 @@ export default function SettingsPage() {
try {
await api.post('/auth/update-profile', adminProfile);
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
setAdminProfile(prev => ({ ...prev, password: '' })); // Очищаем поле пароля
// Опционально: можно сделать логаут, чтобы заставить войти с новыми данными
// logout();
setAdminProfile(prev => ({ ...prev, password: '' }));
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
}
};
const handleForceRotate = async () => {
if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) {
try {
await api.post('/rotation/rotate-all');
setMsg({ open: true, type: 'success', text: 'Ротация успешно выполнена!' });
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка при запуске ротации' });
}
}
};
return (
<Box>
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
<Grid container spacing={3}>
{/* БЛОК 1: Подключение к 3x-ui */}
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="URL панели"
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
@@ -99,22 +121,20 @@ export default function SettingsPage() {
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
/>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Сохранить подключение
</Button>
</Paper>
</Grid>
{/* БЛОК 2: Ротация и Админка */}
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Настройки Ротации */}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Интервал генерации"
type="number"
@@ -125,24 +145,43 @@ export default function SettingsPage() {
}}
helperText="Как часто менять инбаунды (минимум 10 мин)"
/>
<Stack direction="row" spacing={1} sx={{ mt: 1, mb: 2 }}>
{ROTATION_PRESETS.map((preset) => (
<Chip
key={preset.value}
label={preset.label}
onClick={() => handlePresetClick(preset.value)}
color={settings.rotation_interval === preset.value.toString() ? "primary" : "default"}
variant={settings.rotation_interval === preset.value.toString() ? "filled" : "outlined"}
clickable
/>
))}
</Stack>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Применить интервал
</Button>
<Button
variant="outlined"
color="warning"
onClick={handleForceRotate}
sx={{ mt: 2, ml: 2 }}
>
Сгенерировать сейчас
</Button>
</Paper>
{/* Настройки Администратора */}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Логин администратора"
value={adminProfile.login}
value={adminProfile.login}
onChange={handleAdminChange('login')}
/>
<TextField
fullWidth margin="normal" label="Новый пароль" type="password"
value={adminProfile.password}
value={adminProfile.password}
onChange={handleAdminChange('password')}
helperText="Оставьте пустым, если не хотите менять"
/>
@@ -155,7 +194,7 @@ export default function SettingsPage() {
</Grid>
</Grid>
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({...msg, open: false})}>
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({ ...msg, open: false })}>
<Alert severity={msg.type}>{msg.text}</Alert>
</Snackbar>
</Box>
+68 -33
View File
@@ -23,6 +23,35 @@ interface Tunnel {
id: number;
name: string;
ip: string;
domain: string;
isInstalled: boolean;
}
const patchLink = function (link: string, newHost: string): string {
if (link.startsWith('vmess://')) {
try {
const base64Part = link.substring(8);
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
const config = JSON.parse(jsonStr);
config.add = newHost;
const newJsonStr = JSON.stringify(config);
const newBase64 = Buffer.from(newJsonStr).toString('base64');
return `vmess://${newBase64}`;
} catch (e) {
return link;
}
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
return link.replace(/@.*?:/, `@${newHost}:`);
} else if (link.startsWith('ss://')) {
if (link.includes('@')) {
return link.replace(/@.*?:/, `@${newHost}:`);
}
return link;
}
return link;
}
export default function SubscriptionsPage() {
@@ -30,8 +59,8 @@ export default function SubscriptionsPage() {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [selectedServer, setSelectedServer] = useState<string>('main');
const [selectedServer, setSelectedServer] = useState<string | number>('main');
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
@@ -42,7 +71,7 @@ export default function SubscriptionsPage() {
const { data } = await api.get('/subscriptions');
setSubs(data);
const tunnelsRes = await api.get('/tunnels');
setTunnels(tunnelsRes.data);
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
};
const handleCreate = async () => {
@@ -60,7 +89,13 @@ export default function SubscriptionsPage() {
};
const showLinks = (sub: Subscription) => {
const links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
let links = [];
if (selectedServer === 'main') {
links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
} else {
const host = tunnels[+selectedServer - 1].domain.length > 0 ? tunnels[+selectedServer - 1].domain : tunnels[+selectedServer - 1].ip;
links = sub.inbounds?.map(i => patchLink(i.link, host)).filter(Boolean) || [];
}
if (links.length === 0) {
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
} else {
@@ -69,7 +104,7 @@ export default function SubscriptionsPage() {
setLinksOpen(true);
};
const handleServerChange = (event: SelectChangeEvent) => {
const handleServerChange = (event: SelectChangeEvent<any>) => {
setSelectedServer(event.target.value as string);
};
@@ -77,34 +112,34 @@ export default function SubscriptionsPage() {
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Подписки</Typography>
{tunnels.length > 0 && (
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
<Select
labelId="server-select-label"
value={selectedServer}
onChange={handleServerChange}
startAdornment={
<InputAdornment position="start">
{selectedServer === 'main' ? <Dns fontSize="small"/> : <Router fontSize="small"/>}
</InputAdornment>
}
>
<MenuItem value="main">
{tunnels.length > 0 && (
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
<Select
labelId="server-select-label"
value={selectedServer}
onChange={handleServerChange}
startAdornment={
<InputAdornment position="start">
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
</InputAdornment>
}
>
<MenuItem value="main">
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</Box>
</MenuItem>
{tunnels.map((t) => (
<MenuItem key={t.id} value={t.id.toString()}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
</Box>
</MenuItem>
{tunnels.map((t) => (
<MenuItem key={t.id} value={t.id.toString()}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
)}
))}
</Select>
</FormControl>
)}
<Box>
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
@@ -125,20 +160,20 @@ export default function SubscriptionsPage() {
<TableBody>
{subs.map((sub) => (
<TableRow key={sub.id}>
<TableCell>{sub.name}</TableCell>
<TableCell sx={{ fontWeight: 700 }}>{sub.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
<TableCell>{sub.inbounds?.length || 0}</TableCell>
<TableCell align="right">
<IconButton
color="primary"
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `http://localhost:3000/bus/${sub.uuid}` : `http://localhost:3000/bus/${sub.uuid}/${selectedServer}`)}
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' ? `http://localhost:3000/bus/${sub.uuid}` : `http://localhost:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
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 />
+2 -2
View File
@@ -19,8 +19,8 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noUnusedParameters": false,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
+2 -2
View File
@@ -16,8 +16,8 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
+1 -4
View File
@@ -2,8 +2,5 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
open: true
}
plugins: [react()]
})
+36 -1
View File
@@ -11,6 +11,41 @@ services:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- app-network
backend:
build: ./server
container_name: 3dp-backend
restart: always
depends_on:
- postgres
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: ${POSTGRES_USER:-admin}
DB_PASSWORD: ${POSTGRES_PASSWORD:-admin}
DB_NAME: ${POSTGRES_DB:-3dp_manager}
JWT_SECRET: ${JWT_SECRET:-secretKey}
ports:
- "3000:3000"
networks:
- app-network
frontend:
build: ./client
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "80:80"
networks:
- app-network
volumes:
pg_data:
pg_data:
networks:
app-network:
driver: bridge
+262 -339
View File
@@ -2,46 +2,20 @@
set -euo pipefail
#################################
# DEBUG TRAP
# КОНФИГУРАЦИЯ И ПЕРЕМЕННЫЕ
#################################
trap 'echo -e "\033[1;31m[ERROR]\033[0m Ошибка в строке $LINENO"; exit 1' ERR
REPO_URL="https://github.com/denpiligrim/3dp-manager/archive/refs/heads/dp-gui.tar.gz"
PROJECT_DIR="/opt/3dp-manager"
#################################
# HELPER FUNCTIONS
#################################
log() { echo -e "\033[1;32m[INFO]\033[0m $1"; }
die() { echo -e "\033[1;31m[ERROR]\033[0m $1"; exit 1; }
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
need_root() {
[[ $EUID -eq 0 ]] || die "Запускать только от root"
}
validate_url() {
[[ "$1" =~ ^https?://[^/:]+:[0-9]+(/[^/]+)*$ ]]
}
#################################
# CHECKS
#################################
need_root
# Check OS and set release variable
. /etc/os-release
if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
die "Этот скрипт поддерживает только Ubuntu или Debian: $ID"
fi
REMOTE_PANEL=${REMOTE_PANEL:-false}
if [[ "$REMOTE_PANEL" != "true" ]]; then
if ! x-ui status >/dev/null 2>&1; then
echo "❌ Панель 3x-ui не найдена или не работает."
echo " Чтобы установить, выполните:"
echo " bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)"
exit 1
fi
echo "✔ Панель 3x-ui найдена и работает"
fi
log() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
#################################
# ASCII-баннер
@@ -59,148 +33,25 @@ echo "==================================================="
echo ""
#################################
# INPUT / USER DATA
# ПРОВЕРКИ И УСТАНОВКА ЗАВИСИМОСТЕЙ
#################################
# Function to get panel URL from 3x-ui
get_xui_url() {
if [[ "$REMOTE_PANEL" == "true" ]]; then
echo ""
return
fi
local output=$(x-ui settings 2>/dev/null || true)
echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "Access URL:" | grep -oE 'https?://[^[:space:]]+' | head -n1 || true
}
echo "Определяем URL панели 3x-ui..."
UI_URL=$(get_xui_url)
if [[ -z "$UI_URL" ]]; then
echo "Не удалось автоматически получить URL"
read -rp "Введите URL панели 3x-ui вручную: " UI_URL
if [[ $EUID -ne 0 ]]; then
error "Этот скрипт должен быть запущен от имени root"
fi
UI_URL=$(echo "$UI_URL" | sed -E 's/[[:space:]]*$//; s|/*$||')
# Validate URL correctness
validate_url "$UI_URL" || die "Некорректный URL панели 3x-ui: $UI_URL"
echo "URL панели 3x-ui: $UI_URL"
read -rp "Логин 3x-ui: " UI_LOGIN
read -rsp "Пароль 3x-ui: " UI_PASSWORD
echo
UI_LOGIN=$(echo "$UI_LOGIN" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
UI_PASSWORD=$(echo "$UI_PASSWORD" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[[ -z "$UI_LOGIN" || -z "$UI_PASSWORD" ]] && die "Логин/пароль обязательны"
# Check login
if ! command -v curl >/dev/null 2>&1; then
echo "❌ curl не установлен. Установите curl и повторите попытку"
echo " apt install -y curl"
exit 1
. /etc/os-release
if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
die "Этот скрипт поддерживает только Ubuntu или Debian: $ID"
fi
LOGIN_RESPONSE=$(curl -s -k --connect-timeout 10 -X POST "$UI_URL/login" -H "Content-Type: application/json" -d "{\"username\":\"$UI_LOGIN\",\"password\":\"$UI_PASSWORD\"}" || true)
log "Проверка зависимостей..."
if ! command -v curl &> /dev/null; then apt-get update && apt-get install -y curl; fi
if ! command -v jq &> /dev/null; then apt-get install -y jq; fi
if ! command -v openssl &> /dev/null; then apt-get install -y openssl; fi
if ! command -v tar &> /dev/null; then apt-get install -y tar; fi
if ! command -v hostname &> /dev/null; then apt-get install -y net-tools || apt-get install -y hostname; fi
if ! echo "$LOGIN_RESPONSE" | grep -q '"success":true'; then
echo "Не удалось залогиниться в 3x-ui. Проверьте URL, логин и пароль."
exit 1
fi
echo "✔ Успешный логин в 3x-ui"
# Parse UI_URL
UI_HOST=$(echo "$UI_URL" | awk -F[/:] '{print $4}') # domain or IP
UI_PROTO=$(echo "$UI_URL" | awk -F: '{print $1}') # http or https
if [[ "$UI_PROTO" == "https" ]]; then
log "HTTPS панель обнаружена, проверяем SSL сертификаты"
DEFAULT_CERT="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
DEFAULT_KEY="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
if [[ -f "$DEFAULT_CERT" && -f "$DEFAULT_KEY" ]]; then
CERT_PATH="$DEFAULT_CERT"
KEY_PATH="$DEFAULT_KEY"
log "Найдены сертификаты Let's Encrypt для $UI_HOST"
else
log "⚠ Сертификаты Let's Encrypt для $UI_HOST не найдены"
read -rp "Введите полный путь к сертификату (публичный ключ): " CERT_PATH
read -rp "Введите полный путь к ключу (приватный ключ): " KEY_PATH
[[ -f "$CERT_PATH" ]] || die "Файл сертификата не найден: $CERT_PATH"
[[ -f "$KEY_PATH" ]] || die "Файл ключа не найден: $KEY_PATH"
fi
fi
# Input rotation interval
read -rp "Интервал генерации инбаундов в минутах (от 10, по умолчанию 30): " ROTATE_INTERVAL
ROTATE_INTERVAL="${ROTATE_INTERVAL:-30}"
# Check that it's a number and ≥10
if ! [[ "$ROTATE_INTERVAL" =~ ^[0-9]+$ ]] || [ "$ROTATE_INTERVAL" -lt 10 ]; then
echo "Неверное значение. Используется значение по умолчанию 30 минут."
ROTATE_INTERVAL=30
fi
echo "Интервал ротации установлен: $ROTATE_INTERVAL минут"
PROJECT_DIR="/opt/3dp-manager"
log "Используется директория проекта: $PROJECT_DIR"
mkdir -p "$PROJECT_DIR"
cd "$PROJECT_DIR"
#################################
# Get country flag
#################################
REPO_BASE="https://raw.githubusercontent.com/denpiligrim/3dp-manager/main"
COUNTRY_FLAG=""
# Get countryCode
IP_JSON=$(curl -s --fail http://ip-api.com/json/ || true)
[ -z "$IP_JSON" ] && exit 0
if ! command -v jq >/dev/null 2>&1; then
echo "❌ Не найден jq. Для работы скрипта необходимо установить jq."
echo " apt install -y jq"
exit 1
fi
COUNTRY_CODE=$(echo "$IP_JSON" | jq -r '.countryCode // empty' 2>/dev/null || true)
[ -z "$COUNTRY_CODE" ] && exit 0
# Flags JSON URL
FLAGS_JSON_URL="$REPO_BASE/app/assets/flags.json"
FLAGS_JSON=$(curl -s "$FLAGS_JSON_URL" || true)
[ -z "$FLAGS_JSON" ] && exit 0
# emoji
COUNTRY_FLAG=$(echo "$FLAGS_JSON" | jq -r --arg code "$COUNTRY_CODE" '.[] | select(.code == $code) | .emoji // empty' 2>/dev/null | head -n1)
#################################
# Whitelist
#################################
if curl -fsSL "$REPO_BASE/whitelist.txt" -o whitelist.txt; then
log "whitelist.txt скопирован"
else
log "⚠ Не удалось скачать whitelist.txt"
fi
#################################
# TOKEN GENERATION
#################################
SUB_TOKEN=$(date +%s%N | sha256sum | cut -c1-16)
log "Сгенерирован токен подписки"
#################################
# DOCKER
#################################
# Установка Docker
log "Проверка Docker"
if command -v docker >/dev/null 2>&1; then
@@ -236,192 +87,264 @@ EOF
fi
#################################
# STRUCTURE
# ЗАГРУЗКА ПРОЕКТА
#################################
mkdir -p app app/builders subscriptions
log "Подготовка директории $PROJECT_DIR..."
mkdir -p "$PROJECT_DIR"
log "Скачивание последней версии проекта..."
curl -L "$REPO_URL" | tar xz -C "$PROJECT_DIR" --strip-components=1
cd "$PROJECT_DIR"
#################################
# СБОР ДАННЫХ
#################################
read -rp "Введите домен сервера (если пропустить, будет использоваться IP без HTTPS): " INPUT_HOST
USE_SSL=false
CERT_PATH=""
KEY_PATH=""
SKIP_SSL_SETUP=false
if [ -z "$INPUT_HOST" ]; then
UI_HOST=$(hostname -I | awk '{print $1}')
log "Домен не указан. Используется локальный IP: $UI_HOST"
log "Режим HTTPS принудительно отключен для IP-адреса."
USE_SSL=false
SKIP_SSL_SETUP=true
else
UI_HOST=$INPUT_HOST
SKIP_SSL_SETUP=false
fi
# --- 2. Настройка SSL (Только если введен домен) ---
if [[ "$SKIP_SSL_SETUP" == "false" ]]; then
# Пытаемся автоматически найти сертификаты Let's Encrypt
LE_CERT="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
LE_KEY="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
if [[ -f "$LE_CERT" && -f "$LE_KEY" ]]; then
log "Найдены сертификаты Let's Encrypt."
USE_SSL=true
CERT_PATH="$LE_CERT"
KEY_PATH="$LE_KEY"
else
# Спрашиваем пользователя, если авто-поиск не дал результата
read -rp "Использовать SSL (свои сертификаты)? (y/n): " ssl_ans
if [[ "$ssl_ans" =~ ^[Yy]$ ]]; then
read -rp "Путь к fullchain.pem: " user_cert
read -rp "Путь к privkey.pem: " user_key
if [[ -f "$user_cert" && -f "$user_key" ]]; then
USE_SSL=true
CERT_PATH="$user_cert"
KEY_PATH="$user_key"
else
warn "Файлы сертификатов не найдены. Будет использоваться HTTP."
fi
fi
fi
fi
# Generate a random free port for subscription/Nginx
get_random_port() {
while :; do
PORT=$((RANDOM % 50000 + 10000)) # range 10000-60000
PORT=$((RANDOM % 4000 + 3000))
if ! ss -ltn | awk '{print $4}' | grep -q ":$PORT\$"; then
echo "$PORT"
return
fi
done
}
NGINX_PORT=$(get_random_port)
FINAL_PORT=$(get_random_port)
# Generate subscription URL
SUB_URL="$UI_PROTO://$UI_HOST:$NGINX_PORT/bus/$SUB_TOKEN"
# --- 4. Генерация паролей ---
DB_PASS=$(openssl rand -base64 12)
JWT_SECRET=$(openssl rand -base64 32)
log "Сгенерированы секретные ключи для БД и JWT."
#################################
# ENV
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
#################################
cat > .env <<EOF
SUB_TOKEN=$SUB_TOKEN
UI_URL=$UI_URL
UI_LOGIN=$UI_LOGIN
UI_PASSWORD=$UI_PASSWORD
COUNTRY_FLAG=$COUNTRY_FLAG
NGINX_PORT=$NGINX_PORT
UI_HOST=$UI_HOST
UI_PROTO=$UI_PROTO
ROTATE_INTERVAL=$ROTATE_INTERVAL
SUB_URL=$SUB_URL
# --- 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.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
#################################
# Dockerfile
#################################
curl -fsSL "$REPO_BASE/app/Dockerfile" -o app/Dockerfile
#################################
# package.json
#################################
curl -fsSL "$REPO_BASE/app/package.json" -o app/package.json
#################################
# JS Files
#################################
curl -fsSL "$REPO_BASE/app/index.js" -o app/index.js
curl -fsSL "$REPO_BASE/app/rotate.js" -o app/rotate.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessRealityTcp.js" -o app/builders/buildVlessRealityTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessRealityXhttp.js" -o app/builders/buildVlessRealityXhttp.js
curl -fsSL "$REPO_BASE/app/builders/buildTrojanRealityTcp.js" -o app/builders/buildTrojanRealityTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildShadowsocksTcp.js" -o app/builders/buildShadowsocksTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildVmessTcp.js" -o app/builders/buildVmessTcp.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessRealityGrpc.js" -o app/builders/buildVlessRealityGrpc.js
curl -fsSL "$REPO_BASE/app/builders/buildVlessWs.js" -o app/builders/buildVlessWs.js
curl -fsSL "$REPO_BASE/app/builders/buildInboundLink.js" -o app/builders/buildInboundLink.js
#################################
# NGINX & DOCKER COMPOSE
#################################
# Generate nginx.conf
if [[ "$UI_PROTO" == "https" ]]; then
cat > docker-compose.yml <<EOF
services:
node:
build: ./app
env_file: .env
container_name: node
volumes:
- ./subscriptions:/subscriptions
- ./whitelist.txt:/app/whitelist.txt
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on: [node]
ports:
- "$NGINX_PORT:$NGINX_PORT"
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./subscriptions:/subscriptions
- $CERT_PATH:$CERT_PATH:ro
- $KEY_PATH:$KEY_PATH:ro
EOF
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT ssl;
server_name $UI_HOST;
ssl_certificate $CERT_PATH;
ssl_certificate_key $KEY_PATH;
location = /bus/$SUB_TOKEN {
alias /subscriptions/list.txt;
default_type text/plain;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
# --- 2. Nginx конфиг (Базовый HTTP) ---
# Этот конфиг будет использоваться внутри контейнера
cat > client/nginx-client.conf <<EOF
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Frontend Routing (SPA)
location / {
return 404;
try_files \$uri \$uri/ /index.html;
}
# Proxy API requests to Backend Container
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$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;
}
}
}
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
# --- 4. docker-compose.yml ---
log "Генерация docker-compose.yml..."
cat > docker-compose.yml <<EOF
services:
# --- Database ---
postgres:
image: postgres:18-alpine
container_name: 3dp-postgres
restart: always
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: 3dp_manager
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
interval: 5s
timeout: 5s
retries: 5
# --- Backend (NestJS) ---
backend:
build: ./server
container_name: 3dp-backend
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USER: admin
DATABASE_PASSWORD: ${DB_PASS}
DATABASE_NAME: 3dp_manager
JWT_SECRET: ${JWT_SECRET}
PORT: 3000
# --- Frontend (Nginx + React) ---
frontend:
build: ./client
container_name: 3dp-frontend
restart: always
depends_on:
- backend
ports:
- "${FINAL_PORT}:${FINAL_PORT}"
EOF
# Добавляем SSL конфигурацию (ЕСЛИ ВКЛЮЧЕНО)
if [[ "$USE_SSL" == "true" ]]; then
# 1. Перезаписываем nginx конфиг для SSL
cat > client/nginx-client.conf <<EOF
server {
listen $FINAL_PORT ssl;
server_name $UI_HOST;
root /usr/share/nginx/html;
index index.html;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
}
EOF
# 2. Добавляем volumes с сертификатами в docker-compose
# Используем sed для вставки volumes после ports frontend-сервиса
sed -i "/services:/a \ \ \ \ volumes:\n - $CERT_PATH:/etc/nginx/certs/fullchain.pem:ro\n - $KEY_PATH:/etc/nginx/certs/privkey.pem:ro" docker-compose.yml
fi
# Добавляем volume для БД в конец файла
cat >> docker-compose.yml <<EOF
volumes:
pg_data:
EOF
#################################
# ЗАПУСК
#################################
log "Сборка и запуск контейнеров..."
# Останавливаем старые, если были
docker compose down --remove-orphans || true
# Запускаем сборку и старт
docker compose up --build -d
log "Очистка кэша сборки..."
docker image prune -f
echo ""
echo "==================================================="
if [[ "$USE_SSL" == "true" ]]; then
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: https://${UI_HOST}:${FINAL_PORT}${NC}"
else
cat > docker-compose.yml <<EOF
services:
node:
build: ./app
env_file: .env
container_name: node
volumes:
- ./subscriptions:/subscriptions
- ./whitelist.txt:/app/whitelist.txt
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on: [node]
ports:
- "$NGINX_PORT:$NGINX_PORT"
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./subscriptions:/subscriptions
EOF
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT;
server_name $UI_HOST;
location = /bus/$SUB_TOKEN {
alias /subscriptions/list.txt;
default_type text/plain;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
location / {
return 404;
}
}
}
EOF
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: http://${UI_HOST}:${FINAL_PORT}${NC}"
fi
# ufw
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 "$NGINX_PORT"/tcp
ufw allow 10000:60000/tcp
ufw allow 10000:60000/udp
fi
#################################
# RUN
#################################
log "Сборка контейнеров"
docker compose build
log "Запуск контейнеров"
docker compose up -d
docker compose ps | grep node >/dev/null || die "Backend не запущен"
docker compose ps | grep nginx >/dev/null || die "Nginx не запущен"
#################################
# RESULT
#################################
log "✔ Установка завершена"
echo
echo "URL подписки:"
echo "$SUB_URL"
echo "==================================================="
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.git
.env
Dockerfile
+27
View File
@@ -0,0 +1,27 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/main"]
+1 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Post, Body, Request, UseGuards, Get } from '@nestjs/common';
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
-8
View File
@@ -33,7 +33,6 @@ export class AuthService {
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
// Сравниваем пароль
const isMatch = await bcrypt.compare(pass, dbPass.value);
if (isMatch) {
@@ -63,9 +62,6 @@ export class AuthService {
this.logger.log('Пароль администратора изменен.');
}
// ... imports
// (Оставьте существующие методы без изменений, добавьте/обновите этот)
async updateAdminProfile(login: string, password?: string) {
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
@@ -85,19 +81,15 @@ export class AuthService {
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
}
// Обновленный метод инициализации
async seedAdmin() {
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
// Если пользователя нет ИЛИ если нужно принудительно сбросить (для отладки)
if (!login) {
this.logger.log('Инициализация администратора (admin / admin)...');
// 1. Сохраняем логин
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
await this.settingsRepo.save(loginSetting);
// 2. Сохраняем пароль
const hash = await bcrypt.hash('admin', 10);
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
await this.settingsRepo.save(passSetting);
+1 -1
View File
@@ -1,4 +1,4 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
+1 -6
View File
@@ -20,13 +20,12 @@ export class ClientController {
) { }
@Public()
@Get('bus/:uuid') // Тот самый путь /bus/UUID
@Get('bus/:uuid')
async getSubscription(
@Param('uuid') uuid: string,
@Req() req: Request,
@Res() res: Response
) {
// 1. Ищем подписку
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
@@ -36,12 +35,10 @@ export class ClientController {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
// 2. Генерируем список ссылок (Config)
const links = sub.inbounds
?.map(i => i.link)
.filter(l => l && l.length > 0) || [];
// Формируем Base64 строку (это и есть подписка для клиента)
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
@@ -67,7 +64,6 @@ export class ClientController {
console.log(`Взяли QR из кэша для ${uuid}`);
}
// HTML шаблон
const html = `
<!DOCTYPE html>
<html lang="ru">
@@ -190,7 +186,6 @@ export class ClientController {
console.log(`Взяли QR из кэша для ${uuid}`);
}
// HTML шаблон
const html = `
<!DOCTYPE html>
<html lang="ru">
-3
View File
@@ -10,7 +10,6 @@ export class DomainsController {
return this.domainsService.create(body);
}
// Загрузка списка (массива строк)
@Post('upload')
uploadMany(@Body() body: { domains: string[] }) {
return this.domainsService.createMany(body.domains);
@@ -21,7 +20,6 @@ export class DomainsController {
@Query('page') page: number,
@Query('limit') limit: number
) {
// Если параметры не передали, ставим дефолтные: стр 1, лимит 10
const pageNum = page ? +page : 1;
const limitNum = limit ? +limit : 10;
@@ -33,7 +31,6 @@ export class DomainsController {
return this.domainsService.findOne(+id);
}
// ВАЖНО: @Delete('all') должен идти ПЕРЕД @Delete(':id')
@Delete('all')
removeAll() {
return this.domainsService.removeAll();
+6 -17
View File
@@ -10,9 +10,7 @@ export class DomainsService {
private repo: Repository<Domain>,
) { }
// Создать один домен
async create(createDomainDto: { name: string }) {
// Простейшая проверка на дубликат (можно и через try-catch)
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
if (exists) return exists;
@@ -24,9 +22,9 @@ export class DomainsService {
const skip = (page - 1) * limit;
const [result, total] = await this.repo.findAndCount({
take: limit, // Сколько взять (10)
skip: skip, // Сколько пропустить
order: { id: 'DESC' }, // Сортируем: новые сверху
take: limit,
skip: skip,
order: { id: 'DESC' },
});
return {
@@ -39,39 +37,30 @@ export class DomainsService {
return this.repo.findOneBy({ id });
}
// Удалить один
remove(id: number) {
return this.repo.delete(id);
}
// === СПЕЦИАЛЬНЫЕ МЕТОДЫ ===
// 1. Удалить вообще всё (для кнопки "Удалить все")
async removeAll() {
await this.repo.clear(); // TRUNCATE table
await this.repo.clear();
return { success: true };
}
// 2. Массовая загрузка из файла
async createMany(names: string[]) {
if (!names || names.length === 0) return { count: 0 };
// Убираем пробелы и пустые строки
const cleanNames = names
.map(n => n.trim())
.filter(n => n.length > 0);
// Получаем текущие домены, чтобы не вставлять дубли
const existing = await this.repo.find();
const existingSet = new Set(existing.map(d => d.name));
// Оставляем только новые
const uniqueNewNames = [...new Set(cleanNames)] // убираем дубли внутри самого файла
.filter(name => !existingSet.has(name)); // убираем те, что уже есть в БД
const uniqueNewNames = [...new Set(cleanNames)]
.filter(name => !existingSet.has(name));
if (uniqueNewNames.length === 0) return { count: 0 };
// Создаем и сохраняем
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
await this.repo.save(entities);
@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class InboundBuilderService {
private readonly flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
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;
@@ -196,7 +196,8 @@ export class InboundBuilderService {
return uuidv4();
}
buildInboundLink(inbound: any, domain: string, idOrPass: string): string {
buildInboundLink(inbound: any, domain: string, idOrPass: string, flagEmoji: string): string {
this.flag = flagEmoji;
let link = "";
switch (inbound.protocol) {
@@ -286,7 +287,7 @@ export class InboundBuilderService {
id: uuid,
net: stream.network || "tcp",
path: "/",
port: inbound.port,
port: inbound.port.toString(),
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
scy: "",
sni: "",
@@ -0,0 +1,12 @@
import { Controller, Post } from '@nestjs/common';
import { RotationService } from './rotation.service';
@Controller('rotation')
export class RotationController {
constructor(private readonly rotationService: RotationService) {}
@Post('rotate-all')
async rotateAll() {
return this.rotationService.performRotation();
}
}
+2
View File
@@ -10,6 +10,7 @@ import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
import { RotationController } from './rotation.controller';
@Module({
imports: [
@@ -19,5 +20,6 @@ import { Setting } from '../settings/entities/setting.entity';
InboundsModule,
],
providers: [RotationService],
controllers: [RotationController],
})
export class RotationModule {}
+7 -5
View File
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Repository } from 'typeorm';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
@@ -52,7 +52,7 @@ export class RotationService {
await this.settingRepo.save(s);
}
private async performRotation() {
async performRotation() {
this.logger.log('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
@@ -96,8 +96,8 @@ export class RotationService {
const usedPorts = new Set<number>();
const tasks = [
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 8443 pref
() => this.inboundBuilder.buildVlessRealityXhttp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 443 pref
() => 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 }),
@@ -110,6 +110,8 @@ export class RotationService {
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();
@@ -134,7 +136,7 @@ export class RotationService {
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);
const fullLink = this.inboundBuilder.buildInboundLink(config, serverAddress, idOrPass, flagEmoji);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
import * as dns from 'dns/promises';
import { COUNTRIES } from './countries';
@Controller('settings')
export class SettingsController {
@@ -28,6 +29,37 @@ export class SettingsController {
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
if (address && address !== '127.0.0.1' && address !== 'localhost') {
try {
console.log(`Определяем страну для IP: ${address}...`);
const geoRes = await fetch(`http://ip-api.com/json/${address}`);
const geoData: any = await geoRes.json();
if (geoData.status === 'success') {
const countryCode = geoData.countryCode;
const countryInfo = COUNTRIES.find(c => c.code === countryCode);
if (countryInfo) {
const flagEmoji = countryInfo.emoji;
settings['xui_geo_country'] = countryInfo.name;
settings['xui_geo_flag'] = flagEmoji;
console.log(`GeoIP Success: ${countryInfo.name} ${flagEmoji}`);
} else {
console.warn(`Страна с кодом ${countryCode} не найдена в countries.ts`);
settings['xui_geo_country'] = geoData.country;
settings['xui_geo_flag'] = '';
}
} else {
console.warn(`GeoIP Error: ${geoData.message}`);
}
} catch (geoError) {
console.error(`Ошибка запроса к ip-api.com: ${geoError.message}`);
}
}
} catch (e) {
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
}
@@ -12,7 +12,6 @@ export class SubscriptionsService {
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Inbound)
private inboundRepo: Repository<Inbound>,
private xuiService: XuiService,
) {}
+1 -1
View File
@@ -7,7 +7,7 @@ import { Setting } from '../settings/entities/setting.entity';
import { SshService } from './ssh.service';
@Module({
imports: [TypeOrmModule.forFeature([Tunnel, Setting])], // Setting нужен для xui_host
imports: [TypeOrmModule.forFeature([Tunnel, Setting])],
controllers: [TunnelsController],
providers: [TunnelsService, SshService],
})
-7
View File
@@ -28,9 +28,7 @@ export class TunnelsService {
return this.tunnelRepo.delete(id);
}
// === УСТАНОВКА СКРИПТА ===
async installScript(id: number) {
// 1. Ищем туннель с паролем
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
.addSelect('tunnel.password')
.where('tunnel.id = :id', { id })
@@ -38,7 +36,6 @@ export class TunnelsService {
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
// 2. Ищем IP основного сервера (куда пересылать трафик)
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
if (!hostSetting || !hostSetting.value) {
@@ -51,12 +48,9 @@ export class TunnelsService {
this.logger.log(`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`);
// 3. Формируем команду
// export ORIGIN_IP="1.2.3.4" && bash <(curl ...)
const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/dp-gui/forwarding_install.sh)`;
try {
// 4. Выполняем через SSH
const output = await this.sshService.executeCommand({
host: tunnel.ip,
port: tunnel.sshPort,
@@ -66,7 +60,6 @@ export class TunnelsService {
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
// Помечаем как установленный
tunnel.isInstalled = true;
await this.tunnelRepo.save(tunnel);
+41 -14
View File
@@ -63,23 +63,50 @@ export class XuiService {
}
async addInbound(inboundConfig: any) {
try {
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
if (res.data?.success) {
this.logger.log(res.data?.msg);
return res.data.obj.id;
} else {
this.logger.error(res.data?.msg);
}
} catch (e) {
this.logger.error(`Ошибка добавления инбаунда: ${e.message}`);
if (e.response?.status === 401) {
this.logger.log('Сессия истекла, пробуем релогин...');
if (await this.login()) {
return this.addInbound(inboundConfig);
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
if (res.data?.success) {
return res.data.obj.id;
}
else {
const msg = res.data?.msg || '';
if (
msg.toLowerCase().includes('port') &&
msg.toLowerCase().includes('exists')
) {
this.logger.warn(`Попытка ${attempts}/${maxAttempts}: Порт ${inboundConfig.port} занят. Генерируем новый...`);
inboundConfig.port = Math.floor(Math.random() * (60000 - 10000 + 1) + 10000);
} else {
this.logger.error(`3x-ui отклонил создание: ${msg}`);
return null;
}
}
} catch (e) {
if (e.response?.status === 401) {
this.logger.log('Сессия истекла, пробуем релогин...');
if (await this.login()) {
return this.addInbound(inboundConfig);
}
}
this.logger.error(`Ошибка сети/валидации при добавлении инбаунда: ${e.message}`);
return null;
}
}
this.logger.error(`Не удалось создать инбаунд после ${maxAttempts} попыток смены порта.`);
return null;
}
+5 -2
View File
@@ -16,10 +16,13 @@
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictNullChecks": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"resolveJsonModule": true
}
}