func optimization
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
Dockerfile
|
||||
@@ -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;"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, Container } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "==================================================="
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
Dockerfile
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user