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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.9",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.9",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import SubscriptionsPage from './pages/SubscriptionsPage';
|
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 { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
// Импортируем нашу настройку
|
|
||||||
import { getDesignTokens } from './theme';
|
import { getDesignTokens } from './theme';
|
||||||
|
|
||||||
type ColorMode = 'light' | 'dark' | 'system';
|
type ColorMode = 'light' | 'dark' | 'system';
|
||||||
@@ -17,7 +16,6 @@ const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
|
|||||||
export const useThemeContext = () => useContext(ThemeContext);
|
export const useThemeContext = () => useContext(ThemeContext);
|
||||||
|
|
||||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
// Читаем из localStorage или ставим 'system'
|
|
||||||
const [mode, setMode] = useState<ColorMode>(() => {
|
const [mode, setMode] = useState<ColorMode>(() => {
|
||||||
return (localStorage.getItem('themeMode') as ColorMode) || 'system';
|
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(() => {
|
const theme = useMemo(() => {
|
||||||
let activeMode: ColorMode;
|
let activeMode: ColorMode;
|
||||||
|
|
||||||
@@ -46,7 +43,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
activeMode = mode;
|
activeMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ВАЖНО: Используем нашу функцию getDesignTokens
|
|
||||||
const themeOptions = getDesignTokens(activeMode);
|
const themeOptions = getDesignTokens(activeMode);
|
||||||
|
|
||||||
return createTheme(themeOptions);
|
return createTheme(themeOptions);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: 'http://localhost:3000/api',
|
baseURL: `${location.protocol}//${location.hostname}:3000/api`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
@@ -3,34 +3,54 @@ import api from '../api';
|
|||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
login: (token: string) => void;
|
login: (token: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
isAuthenticated: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>(null!);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
export const useAuth = () => {
|
||||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem('token', token);
|
|
||||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('token');
|
|
||||||
delete api.defaults.headers.common['Authorization'];
|
delete api.defaults.headers.common['Authorization'];
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const login = (newToken: string) => setToken(newToken);
|
|
||||||
const logout = () => setToken(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
|
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuth = () => useContext(AuthContext);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AppBar, Toolbar, Typography, IconButton, Tooltip, Box,
|
AppBar, Toolbar, Typography, IconButton, Tooltip, Box,
|
||||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, List, ListItem, ListItemText
|
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, List, ListItem, ListItemText
|
||||||
@@ -16,10 +16,8 @@ export default function Header() {
|
|||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Состояние для модального окна справки
|
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
// Логика выхода
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (confirm('Вы действительно хотите выйти?')) {
|
if (confirm('Вы действительно хотите выйти?')) {
|
||||||
logout();
|
logout();
|
||||||
@@ -57,21 +55,18 @@ export default function Header() {
|
|||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
|
||||||
{/* Кнопка Справки */}
|
|
||||||
<Tooltip title="Справка о программе">
|
<Tooltip title="Справка о программе">
|
||||||
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
|
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
|
||||||
<HelpOutline />
|
<HelpOutline />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Кнопка Темы */}
|
|
||||||
<Tooltip title={`Режим: ${getThemeLabel()}`}>
|
<Tooltip title={`Режим: ${getThemeLabel()}`}>
|
||||||
<IconButton color="inherit" onClick={toggleColorMode}>
|
<IconButton color="inherit" onClick={toggleColorMode}>
|
||||||
{getThemeIcon()}
|
{getThemeIcon()}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Кнопка Выхода */}
|
|
||||||
<Tooltip title="Выйти из системы">
|
<Tooltip title="Выйти из системы">
|
||||||
<IconButton color="inherit" onClick={handleLogout}>
|
<IconButton color="inherit" onClick={handleLogout}>
|
||||||
<Logout />
|
<Logout />
|
||||||
@@ -82,7 +77,6 @@ export default function Header() {
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
{/* Модальное окно справки */}
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={helpOpen}
|
open={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
onClose={() => setHelpOpen(false)}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
import {
|
||||||
Toolbar, Drawer, List, ListItem,
|
Toolbar, Drawer, List, ListItem,
|
||||||
ListItemButton, ListItemIcon, ListItemText, Box
|
ListItemButton, ListItemIcon, ListItemText, Box
|
||||||
@@ -6,7 +5,7 @@ import {
|
|||||||
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
|
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import Header from './Header'; // <--- Новый компонент
|
import Header from './Header';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
@@ -25,10 +24,8 @@ export default function Layout() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
|
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
|
||||||
|
|
||||||
{/* Шапка */}
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{/* Боковое меню */}
|
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -55,7 +52,6 @@ export default function Layout() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Основной контейнер контента */}
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -68,12 +64,10 @@ export default function Layout() {
|
|||||||
>
|
>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
|
||||||
{/* Контент страницы */}
|
|
||||||
<Box sx={{ flexGrow: 1, p: 3 }}>
|
<Box sx={{ flexGrow: 1, p: 3 }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Футер */}
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
|
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';
|
import api from '../api';
|
||||||
|
|
||||||
interface Domain { id: number; name: string; }
|
interface Domain { id: number; name: string; }
|
||||||
@@ -9,22 +9,15 @@ export default function DomainsPage() {
|
|||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [newDomain, setNewDomain] = useState('');
|
const [newDomain, setNewDomain] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [totalCount, setTotalCount] = useState(0); // Общее кол-во записей в БД
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Состояние пагинации
|
const [page, setPage] = useState(0);
|
||||||
const [page, setPage] = useState(0); // MUI использует индекс с 0
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10); // По умолчанию 10
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDomains();
|
|
||||||
}, [page, rowsPerPage]);
|
|
||||||
|
|
||||||
const loadDomains = async () => {
|
const loadDomains = async () => {
|
||||||
try {
|
try {
|
||||||
// Backend ждет page начиная с 1, а MUI дает с 0. Поэтому page + 1
|
|
||||||
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
|
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
|
||||||
|
|
||||||
// Сервер теперь возвращает { data: [], total: 123 }
|
|
||||||
setDomains(data.data);
|
setDomains(data.data);
|
||||||
setTotalCount(data.total);
|
setTotalCount(data.total);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -32,15 +25,17 @@ export default function DomainsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик смены страницы
|
useEffect(() => {
|
||||||
const handleChangePage = (event: unknown, newPage: number) => {
|
loadDomains();
|
||||||
|
}, [page, rowsPerPage]);
|
||||||
|
|
||||||
|
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик смены кол-ва строк на странице
|
|
||||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRowsPerPage(parseInt(event.target.value, 10));
|
setRowsPerPage(parseInt(event.target.value, 10));
|
||||||
setPage(0); // Сбрасываем на первую страницу
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
@@ -61,12 +56,11 @@ export default function DomainsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.delete('/domains/all');
|
await api.delete('/domains/all');
|
||||||
loadDomains();
|
loadDomains();
|
||||||
} catch (e) { alert('Ошибка удаления'); }
|
} catch (_e) { alert('Ошибка удаления'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- ЛОГИКА ЗАГРУЗКИ ФАЙЛА ---
|
|
||||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -76,18 +70,15 @@ export default function DomainsPage() {
|
|||||||
const text = e.target?.result as string;
|
const text = e.target?.result as string;
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
// Разбиваем текст на строки по переносу
|
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split(/\r?\n/);
|
||||||
|
|
||||||
// Отправляем на сервер
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/domains/upload', { domains: lines });
|
const { data } = await api.post('/domains/upload', { domains: lines });
|
||||||
alert(`Успешно добавлено доменов: ${data.count}`);
|
alert(`Успешно добавлено доменов: ${data.count}`);
|
||||||
loadDomains();
|
loadDomains();
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
alert('Ошибка при загрузке списка');
|
alert('Ошибка при загрузке списка');
|
||||||
} finally {
|
} finally {
|
||||||
// Сбрасываем инпут, чтобы можно было загрузить тот же файл повторно
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -111,7 +102,6 @@ export default function DomainsPage() {
|
|||||||
>
|
>
|
||||||
Из файла
|
Из файла
|
||||||
</Button>
|
</Button>
|
||||||
{/* Скрытый инпут */}
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".txt"
|
accept=".txt"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Typography, Button, Container } from '@mui/material';
|
import { Box, Typography, Button, Container } from '@mui/material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,47 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 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() {
|
export default function SettingsPage() {
|
||||||
// Настройки 3x-ui и ротации
|
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
xui_url: '',
|
xui_url: '',
|
||||||
xui_login: '',
|
xui_login: '',
|
||||||
xui_password: '',
|
xui_password: '',
|
||||||
rotation_interval: '30', // Значение по умолчанию
|
rotation_interval: '30',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Настройки админа (локальное состояние формы)
|
|
||||||
const [adminProfile, setAdminProfile] = useState({
|
const [adminProfile, setAdminProfile] = useState({
|
||||||
login: '',
|
login: '',
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success'|'error', text: '' });
|
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
|
||||||
const { logout } = useAuth(); // Чтобы разлогинить, если сменили свои данные
|
const [intervalError, setIntervalError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const val = parseInt(settings.rotation_interval, 10);
|
||||||
|
if (isNaN(val) || val < 10) {
|
||||||
|
setIntervalError('Минимальный интервал — 10 минут');
|
||||||
|
} else {
|
||||||
|
setIntervalError('');
|
||||||
|
}
|
||||||
|
}, [settings.rotation_interval]);
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/settings');
|
const { data } = await api.get('/settings');
|
||||||
// Заполняем основные настройки
|
|
||||||
setSettings((prev) => ({ ...prev, ...data }));
|
setSettings((prev) => ({ ...prev, ...data }));
|
||||||
|
|
||||||
// Логин админа тоже приходит в settings (если мы разрешили его чтение),
|
|
||||||
// но пароль (хеш) показывать нельзя.
|
|
||||||
if (data.admin_login) {
|
if (data.admin_login) {
|
||||||
setAdminProfile((prev) => ({ ...prev, login: 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>) => {
|
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSettings({ ...settings, [prop]: event.target.value });
|
setSettings({ ...settings, [prop]: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePresetClick = (minutes: number) => {
|
||||||
|
setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveSettings = async () => {
|
const handleSaveSettings = async () => {
|
||||||
|
if (intervalError) {
|
||||||
|
setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем всё, что в settings
|
|
||||||
await api.post('/settings', settings);
|
await api.post('/settings', settings);
|
||||||
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
|
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -56,7 +72,6 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Handlers для профиля админа ---
|
|
||||||
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
|
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
|
||||||
};
|
};
|
||||||
@@ -65,27 +80,34 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/auth/update-profile', adminProfile);
|
await api.post('/auth/update-profile', adminProfile);
|
||||||
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
|
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
|
||||||
setAdminProfile(prev => ({ ...prev, password: '' })); // Очищаем поле пароля
|
setAdminProfile(prev => ({ ...prev, password: '' }));
|
||||||
|
|
||||||
// Опционально: можно сделать логаут, чтобы заставить войти с новыми данными
|
|
||||||
// logout();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
|
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
|
|
||||||
{/* БЛОК 1: Подключение к 3x-ui */}
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Paper sx={{ p: 3, height: '100%' }}>
|
<Paper sx={{ p: 3, height: '100%' }}>
|
||||||
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
|
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth margin="normal" label="URL панели"
|
fullWidth margin="normal" label="URL панели"
|
||||||
value={settings.xui_url} onChange={handleSettingChange('xui_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"
|
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
|
||||||
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
|
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
||||||
Сохранить подключение
|
Сохранить подключение
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* БЛОК 2: Ротация и Админка */}
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
|
||||||
{/* Настройки Ротации */}
|
|
||||||
<Paper sx={{ p: 3 }}>
|
<Paper sx={{ p: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
|
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth margin="normal" label="Интервал генерации"
|
fullWidth margin="normal" label="Интервал генерации"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -125,24 +145,43 @@ export default function SettingsPage() {
|
|||||||
}}
|
}}
|
||||||
helperText="Как часто менять инбаунды (минимум 10 мин)"
|
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 variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
||||||
Применить интервал
|
Применить интервал
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
onClick={handleForceRotate}
|
||||||
|
sx={{ mt: 2, ml: 2 }}
|
||||||
|
>
|
||||||
|
Сгенерировать сейчас
|
||||||
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Настройки Администратора */}
|
|
||||||
<Paper sx={{ p: 3 }}>
|
<Paper sx={{ p: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
|
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth margin="normal" label="Логин администратора"
|
fullWidth margin="normal" label="Логин администратора"
|
||||||
value={adminProfile.login}
|
value={adminProfile.login}
|
||||||
onChange={handleAdminChange('login')}
|
onChange={handleAdminChange('login')}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth margin="normal" label="Новый пароль" type="password"
|
fullWidth margin="normal" label="Новый пароль" type="password"
|
||||||
value={adminProfile.password}
|
value={adminProfile.password}
|
||||||
onChange={handleAdminChange('password')}
|
onChange={handleAdminChange('password')}
|
||||||
helperText="Оставьте пустым, если не хотите менять"
|
helperText="Оставьте пустым, если не хотите менять"
|
||||||
/>
|
/>
|
||||||
@@ -155,7 +194,7 @@ export default function SettingsPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</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>
|
<Alert severity={msg.type}>{msg.text}</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -23,6 +23,35 @@ interface Tunnel {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: 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() {
|
export default function SubscriptionsPage() {
|
||||||
@@ -30,8 +59,8 @@ export default function SubscriptionsPage() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
|
|
||||||
const [selectedServer, setSelectedServer] = useState<string>('main');
|
const [selectedServer, setSelectedServer] = useState<string | number>('main');
|
||||||
|
|
||||||
const [linksOpen, setLinksOpen] = useState(false);
|
const [linksOpen, setLinksOpen] = useState(false);
|
||||||
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
|
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
|
||||||
@@ -42,7 +71,7 @@ export default function SubscriptionsPage() {
|
|||||||
const { data } = await api.get('/subscriptions');
|
const { data } = await api.get('/subscriptions');
|
||||||
setSubs(data);
|
setSubs(data);
|
||||||
const tunnelsRes = await api.get('/tunnels');
|
const tunnelsRes = await api.get('/tunnels');
|
||||||
setTunnels(tunnelsRes.data);
|
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -60,7 +89,13 @@ export default function SubscriptionsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showLinks = (sub: Subscription) => {
|
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) {
|
if (links.length === 0) {
|
||||||
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
|
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
|
||||||
} else {
|
} else {
|
||||||
@@ -69,7 +104,7 @@ export default function SubscriptionsPage() {
|
|||||||
setLinksOpen(true);
|
setLinksOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleServerChange = (event: SelectChangeEvent) => {
|
const handleServerChange = (event: SelectChangeEvent<any>) => {
|
||||||
setSelectedServer(event.target.value as string);
|
setSelectedServer(event.target.value as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,34 +112,34 @@ export default function SubscriptionsPage() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||||
<Typography variant="h4">Подписки</Typography>
|
<Typography variant="h4">Подписки</Typography>
|
||||||
{tunnels.length > 0 && (
|
{tunnels.length > 0 && (
|
||||||
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
|
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
|
||||||
<Select
|
<Select
|
||||||
labelId="server-select-label"
|
labelId="server-select-label"
|
||||||
value={selectedServer}
|
value={selectedServer}
|
||||||
onChange={handleServerChange}
|
onChange={handleServerChange}
|
||||||
startAdornment={
|
startAdornment={
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
{selectedServer === 'main' ? <Dns fontSize="small"/> : <Router fontSize="small"/>}
|
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value="main">
|
<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' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
))}
|
||||||
{tunnels.map((t) => (
|
</Select>
|
||||||
<MenuItem key={t.id} value={t.id.toString()}>
|
</FormControl>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
)}
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
|
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
|
||||||
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
|
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
|
||||||
@@ -125,20 +160,20 @@ export default function SubscriptionsPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{subs.map((sub) => (
|
{subs.map((sub) => (
|
||||||
<TableRow key={sub.id}>
|
<TableRow key={sub.id}>
|
||||||
<TableCell>{sub.name}</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>{sub.name}</TableCell>
|
||||||
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
|
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
|
||||||
<TableCell>{sub.inbounds?.length || 0}</TableCell>
|
<TableCell>{sub.inbounds?.length || 0}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
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="Копировать ссылку"
|
title="Копировать ссылку"
|
||||||
>
|
>
|
||||||
<ContentCopy />
|
<ContentCopy />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
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="Открыть подписку"
|
title="Открыть подписку"
|
||||||
>
|
>
|
||||||
<OpenInNew />
|
<OpenInNew />
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
|
|||||||
@@ -2,8 +2,5 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()]
|
||||||
server: {
|
|
||||||
open: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
+36
-1
@@ -11,6 +11,41 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
pg_data:
|
pg_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
+262
-339
@@ -2,46 +2,20 @@
|
|||||||
set -euo pipefail
|
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
|
RED='\033[0;31m'
|
||||||
#################################
|
GREEN='\033[0;32m'
|
||||||
log() { echo -e "\033[1;32m[INFO]\033[0m $1"; }
|
YELLOW='\033[1;33m'
|
||||||
die() { echo -e "\033[1;31m[ERROR]\033[0m $1"; exit 1; }
|
NC='\033[0m'
|
||||||
|
|
||||||
need_root() {
|
log() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
}
|
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# ASCII-баннер
|
# ASCII-баннер
|
||||||
@@ -59,148 +33,25 @@ echo "==================================================="
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# INPUT / USER DATA
|
# ПРОВЕРКИ И УСТАНОВКА ЗАВИСИМОСТЕЙ
|
||||||
#################################
|
#################################
|
||||||
# Function to get panel URL from 3x-ui
|
if [[ $EUID -ne 0 ]]; then
|
||||||
get_xui_url() {
|
error "Этот скрипт должен быть запущен от имени root"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
UI_URL=$(echo "$UI_URL" | sed -E 's/[[:space:]]*$//; s|/*$||')
|
. /etc/os-release
|
||||||
# Validate URL correctness
|
if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
|
||||||
validate_url "$UI_URL" || die "Некорректный URL панели 3x-ui: $UI_URL"
|
die "Этот скрипт поддерживает только Ubuntu или Debian: $ID"
|
||||||
|
|
||||||
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
|
|
||||||
fi
|
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
|
# Установка Docker
|
||||||
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
|
|
||||||
#################################
|
|
||||||
log "Проверка Docker"
|
log "Проверка Docker"
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if command -v docker >/dev/null 2>&1; then
|
||||||
@@ -236,192 +87,264 @@ EOF
|
|||||||
fi
|
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() {
|
get_random_port() {
|
||||||
while :; do
|
while :; do
|
||||||
PORT=$((RANDOM % 50000 + 10000)) # range 10000-60000
|
PORT=$((RANDOM % 4000 + 3000))
|
||||||
if ! ss -ltn | awk '{print $4}' | grep -q ":$PORT\$"; then
|
if ! ss -ltn | awk '{print $4}' | grep -q ":$PORT\$"; then
|
||||||
echo "$PORT"
|
echo "$PORT"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
NGINX_PORT=$(get_random_port)
|
FINAL_PORT=$(get_random_port)
|
||||||
|
|
||||||
# Generate subscription URL
|
# --- 4. Генерация паролей ---
|
||||||
SUB_URL="$UI_PROTO://$UI_HOST:$NGINX_PORT/bus/$SUB_TOKEN"
|
DB_PASS=$(openssl rand -base64 12)
|
||||||
|
JWT_SECRET=$(openssl rand -base64 32)
|
||||||
|
log "Сгенерированы секретные ключи для БД и JWT."
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# ENV
|
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
|
||||||
#################################
|
#################################
|
||||||
cat > .env <<EOF
|
|
||||||
SUB_TOKEN=$SUB_TOKEN
|
# --- 1. Dockerfile для Client ---
|
||||||
UI_URL=$UI_URL
|
cat > client/Dockerfile <<EOF
|
||||||
UI_LOGIN=$UI_LOGIN
|
FROM node:24-alpine AS builder
|
||||||
UI_PASSWORD=$UI_PASSWORD
|
|
||||||
COUNTRY_FLAG=$COUNTRY_FLAG
|
WORKDIR /app
|
||||||
NGINX_PORT=$NGINX_PORT
|
|
||||||
UI_HOST=$UI_HOST
|
COPY package*.json ./
|
||||||
UI_PROTO=$UI_PROTO
|
RUN npm ci
|
||||||
ROTATE_INTERVAL=$ROTATE_INTERVAL
|
|
||||||
SUB_URL=$SUB_URL
|
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
|
EOF
|
||||||
|
|
||||||
#################################
|
# --- 2. Nginx конфиг (Базовый HTTP) ---
|
||||||
# Dockerfile
|
# Этот конфиг будет использоваться внутри контейнера
|
||||||
#################################
|
cat > client/nginx-client.conf <<EOF
|
||||||
curl -fsSL "$REPO_BASE/app/Dockerfile" -o app/Dockerfile
|
server {
|
||||||
|
listen 80;
|
||||||
#################################
|
server_name localhost;
|
||||||
# package.json
|
root /usr/share/nginx/html;
|
||||||
#################################
|
index index.html;
|
||||||
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 *;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Frontend Routing (SPA)
|
||||||
location / {
|
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
|
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
|
else
|
||||||
cat > docker-compose.yml <<EOF
|
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: http://${UI_HOST}:${FINAL_PORT}${NC}"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
echo "==================================================="
|
||||||
# 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"
|
|
||||||
@@ -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 { AuthService } from './auth.service';
|
||||||
import { Public } from './public.decorator';
|
import { Public } from './public.decorator';
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export class AuthService {
|
|||||||
|
|
||||||
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
|
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
|
||||||
|
|
||||||
// Сравниваем пароль
|
|
||||||
const isMatch = await bcrypt.compare(pass, dbPass.value);
|
const isMatch = await bcrypt.compare(pass, dbPass.value);
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
@@ -63,9 +62,6 @@ export class AuthService {
|
|||||||
this.logger.log('Пароль администратора изменен.');
|
this.logger.log('Пароль администратора изменен.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... imports
|
|
||||||
// (Оставьте существующие методы без изменений, добавьте/обновите этот)
|
|
||||||
|
|
||||||
async updateAdminProfile(login: string, password?: string) {
|
async updateAdminProfile(login: string, password?: string) {
|
||||||
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
||||||
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
|
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
|
||||||
@@ -85,19 +81,15 @@ export class AuthService {
|
|||||||
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
|
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновленный метод инициализации
|
|
||||||
async seedAdmin() {
|
async seedAdmin() {
|
||||||
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
||||||
|
|
||||||
// Если пользователя нет ИЛИ если нужно принудительно сбросить (для отладки)
|
|
||||||
if (!login) {
|
if (!login) {
|
||||||
this.logger.log('Инициализация администратора (admin / admin)...');
|
this.logger.log('Инициализация администратора (admin / admin)...');
|
||||||
|
|
||||||
// 1. Сохраняем логин
|
|
||||||
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
|
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
|
||||||
await this.settingsRepo.save(loginSetting);
|
await this.settingsRepo.save(loginSetting);
|
||||||
|
|
||||||
// 2. Сохраняем пароль
|
|
||||||
const hash = await bcrypt.hash('admin', 10);
|
const hash = await bcrypt.hash('admin', 10);
|
||||||
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
|
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
|
||||||
await this.settingsRepo.save(passSetting);
|
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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,12 @@ export class ClientController {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('bus/:uuid') // Тот самый путь /bus/UUID
|
@Get('bus/:uuid')
|
||||||
async getSubscription(
|
async getSubscription(
|
||||||
@Param('uuid') uuid: string,
|
@Param('uuid') uuid: string,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
) {
|
) {
|
||||||
// 1. Ищем подписку
|
|
||||||
const sub = await this.subRepo.findOne({
|
const sub = await this.subRepo.findOne({
|
||||||
where: { uuid },
|
where: { uuid },
|
||||||
relations: ['inbounds']
|
relations: ['inbounds']
|
||||||
@@ -36,12 +35,10 @@ export class ClientController {
|
|||||||
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
|
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Генерируем список ссылок (Config)
|
|
||||||
const links = sub.inbounds
|
const links = sub.inbounds
|
||||||
?.map(i => i.link)
|
?.map(i => i.link)
|
||||||
.filter(l => l && l.length > 0) || [];
|
.filter(l => l && l.length > 0) || [];
|
||||||
|
|
||||||
// Формируем Base64 строку (это и есть подписка для клиента)
|
|
||||||
const plainTextList = links.join('\n');
|
const plainTextList = links.join('\n');
|
||||||
const base64Config = Buffer.from(plainTextList).toString('base64');
|
const base64Config = Buffer.from(plainTextList).toString('base64');
|
||||||
|
|
||||||
@@ -67,7 +64,6 @@ export class ClientController {
|
|||||||
console.log(`Взяли QR из кэша для ${uuid}`);
|
console.log(`Взяли QR из кэша для ${uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML шаблон
|
|
||||||
const html = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@@ -190,7 +186,6 @@ export class ClientController {
|
|||||||
console.log(`Взяли QR из кэша для ${uuid}`);
|
console.log(`Взяли QR из кэша для ${uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML шаблон
|
|
||||||
const html = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export class DomainsController {
|
|||||||
return this.domainsService.create(body);
|
return this.domainsService.create(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка списка (массива строк)
|
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
uploadMany(@Body() body: { domains: string[] }) {
|
uploadMany(@Body() body: { domains: string[] }) {
|
||||||
return this.domainsService.createMany(body.domains);
|
return this.domainsService.createMany(body.domains);
|
||||||
@@ -21,7 +20,6 @@ export class DomainsController {
|
|||||||
@Query('page') page: number,
|
@Query('page') page: number,
|
||||||
@Query('limit') limit: number
|
@Query('limit') limit: number
|
||||||
) {
|
) {
|
||||||
// Если параметры не передали, ставим дефолтные: стр 1, лимит 10
|
|
||||||
const pageNum = page ? +page : 1;
|
const pageNum = page ? +page : 1;
|
||||||
const limitNum = limit ? +limit : 10;
|
const limitNum = limit ? +limit : 10;
|
||||||
|
|
||||||
@@ -33,7 +31,6 @@ export class DomainsController {
|
|||||||
return this.domainsService.findOne(+id);
|
return this.domainsService.findOne(+id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ВАЖНО: @Delete('all') должен идти ПЕРЕД @Delete(':id')
|
|
||||||
@Delete('all')
|
@Delete('all')
|
||||||
removeAll() {
|
removeAll() {
|
||||||
return this.domainsService.removeAll();
|
return this.domainsService.removeAll();
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ export class DomainsService {
|
|||||||
private repo: Repository<Domain>,
|
private repo: Repository<Domain>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Создать один домен
|
|
||||||
async create(createDomainDto: { name: string }) {
|
async create(createDomainDto: { name: string }) {
|
||||||
// Простейшая проверка на дубликат (можно и через try-catch)
|
|
||||||
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
|
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
|
||||||
if (exists) return exists;
|
if (exists) return exists;
|
||||||
|
|
||||||
@@ -24,9 +22,9 @@ export class DomainsService {
|
|||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const [result, total] = await this.repo.findAndCount({
|
const [result, total] = await this.repo.findAndCount({
|
||||||
take: limit, // Сколько взять (10)
|
take: limit,
|
||||||
skip: skip, // Сколько пропустить
|
skip: skip,
|
||||||
order: { id: 'DESC' }, // Сортируем: новые сверху
|
order: { id: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -39,39 +37,30 @@ export class DomainsService {
|
|||||||
return this.repo.findOneBy({ id });
|
return this.repo.findOneBy({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удалить один
|
|
||||||
remove(id: number) {
|
remove(id: number) {
|
||||||
return this.repo.delete(id);
|
return this.repo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === СПЕЦИАЛЬНЫЕ МЕТОДЫ ===
|
|
||||||
|
|
||||||
// 1. Удалить вообще всё (для кнопки "Удалить все")
|
|
||||||
async removeAll() {
|
async removeAll() {
|
||||||
await this.repo.clear(); // TRUNCATE table
|
await this.repo.clear();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Массовая загрузка из файла
|
|
||||||
async createMany(names: string[]) {
|
async createMany(names: string[]) {
|
||||||
if (!names || names.length === 0) return { count: 0 };
|
if (!names || names.length === 0) return { count: 0 };
|
||||||
|
|
||||||
// Убираем пробелы и пустые строки
|
|
||||||
const cleanNames = names
|
const cleanNames = names
|
||||||
.map(n => n.trim())
|
.map(n => n.trim())
|
||||||
.filter(n => n.length > 0);
|
.filter(n => n.length > 0);
|
||||||
|
|
||||||
// Получаем текущие домены, чтобы не вставлять дубли
|
|
||||||
const existing = await this.repo.find();
|
const existing = await this.repo.find();
|
||||||
const existingSet = new Set(existing.map(d => d.name));
|
const existingSet = new Set(existing.map(d => d.name));
|
||||||
|
|
||||||
// Оставляем только новые
|
const uniqueNewNames = [...new Set(cleanNames)]
|
||||||
const uniqueNewNames = [...new Set(cleanNames)] // убираем дубли внутри самого файла
|
.filter(name => !existingSet.has(name));
|
||||||
.filter(name => !existingSet.has(name)); // убираем те, что уже есть в БД
|
|
||||||
|
|
||||||
if (uniqueNewNames.length === 0) return { count: 0 };
|
if (uniqueNewNames.length === 0) return { count: 0 };
|
||||||
|
|
||||||
// Создаем и сохраняем
|
|
||||||
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
|
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
|
||||||
await this.repo.save(entities);
|
await this.repo.save(entities);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InboundBuilderService {
|
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 }) {
|
buildVlessRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
|
||||||
const { port, uuid, domain, privateKey, publicKey } = params;
|
const { port, uuid, domain, privateKey, publicKey } = params;
|
||||||
@@ -196,7 +196,8 @@ export class InboundBuilderService {
|
|||||||
return uuidv4();
|
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 = "";
|
let link = "";
|
||||||
|
|
||||||
switch (inbound.protocol) {
|
switch (inbound.protocol) {
|
||||||
@@ -286,7 +287,7 @@ export class InboundBuilderService {
|
|||||||
id: uuid,
|
id: uuid,
|
||||||
net: stream.network || "tcp",
|
net: stream.network || "tcp",
|
||||||
path: "/",
|
path: "/",
|
||||||
port: inbound.port,
|
port: inbound.port.toString(),
|
||||||
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
|
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
|
||||||
scy: "",
|
scy: "",
|
||||||
sni: "",
|
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 { Inbound } from '../inbounds/entities/inbound.entity';
|
||||||
import { Domain } from '../domains/entities/domain.entity';
|
import { Domain } from '../domains/entities/domain.entity';
|
||||||
import { Setting } from '../settings/entities/setting.entity';
|
import { Setting } from '../settings/entities/setting.entity';
|
||||||
|
import { RotationController } from './rotation.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,5 +20,6 @@ import { Setting } from '../settings/entities/setting.entity';
|
|||||||
InboundsModule,
|
InboundsModule,
|
||||||
],
|
],
|
||||||
providers: [RotationService],
|
providers: [RotationService],
|
||||||
|
controllers: [RotationController],
|
||||||
})
|
})
|
||||||
export class RotationModule {}
|
export class RotationModule {}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||||
@@ -52,7 +52,7 @@ export class RotationService {
|
|||||||
await this.settingRepo.save(s);
|
await this.settingRepo.save(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async performRotation() {
|
async performRotation() {
|
||||||
this.logger.log('Запуск плановой ротации...');
|
this.logger.log('Запуск плановой ротации...');
|
||||||
|
|
||||||
const isLoginSuccess = await this.xuiService.login();
|
const isLoginSuccess = await this.xuiService.login();
|
||||||
@@ -96,8 +96,8 @@ export class RotationService {
|
|||||||
const usedPorts = new Set<number>();
|
const usedPorts = new Set<number>();
|
||||||
|
|
||||||
const tasks = [
|
const tasks = [
|
||||||
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 8443 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 }), // Port 443 pref
|
() => 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.buildVlessRealityGrpc({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||||
() => this.inboundBuilder.buildVlessWs({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains) }),
|
() => this.inboundBuilder.buildVlessWs({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains) }),
|
||||||
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
() => 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 host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
||||||
const serverAddress = host?.value || 'localhost';
|
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()) {
|
for (const [index, task] of tasks.entries()) {
|
||||||
let config = task();
|
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];
|
else if (ss.tcpSettings?.header?.request?.headers?.Host?.[0]) domainForLink = ss.tcpSettings.header.request.headers.Host[0];
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
const idOrPass = config.settings ? JSON.parse(config.settings).clients?.[0]?.id || JSON.parse(config.settings).clients?.[0]?.password : "";
|
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({
|
const newInbound = this.inboundRepo.create({
|
||||||
xuiId: xuiId,
|
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 { Repository } from 'typeorm';
|
||||||
import { Setting } from './entities/setting.entity';
|
import { Setting } from './entities/setting.entity';
|
||||||
import * as dns from 'dns/promises';
|
import * as dns from 'dns/promises';
|
||||||
|
import { COUNTRIES } from './countries';
|
||||||
|
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
export class SettingsController {
|
export class SettingsController {
|
||||||
@@ -28,6 +29,37 @@ export class SettingsController {
|
|||||||
|
|
||||||
settings['xui_ip'] = address;
|
settings['xui_ip'] = address;
|
||||||
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
|
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) {
|
} catch (e) {
|
||||||
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
|
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export class SubscriptionsService {
|
|||||||
@InjectRepository(Subscription)
|
@InjectRepository(Subscription)
|
||||||
private subRepo: Repository<Subscription>,
|
private subRepo: Repository<Subscription>,
|
||||||
@InjectRepository(Inbound)
|
@InjectRepository(Inbound)
|
||||||
private inboundRepo: Repository<Inbound>,
|
|
||||||
private xuiService: XuiService,
|
private xuiService: XuiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Setting } from '../settings/entities/setting.entity';
|
|||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Tunnel, Setting])], // Setting нужен для xui_host
|
imports: [TypeOrmModule.forFeature([Tunnel, Setting])],
|
||||||
controllers: [TunnelsController],
|
controllers: [TunnelsController],
|
||||||
providers: [TunnelsService, SshService],
|
providers: [TunnelsService, SshService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ export class TunnelsService {
|
|||||||
return this.tunnelRepo.delete(id);
|
return this.tunnelRepo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === УСТАНОВКА СКРИПТА ===
|
|
||||||
async installScript(id: number) {
|
async installScript(id: number) {
|
||||||
// 1. Ищем туннель с паролем
|
|
||||||
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
|
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
|
||||||
.addSelect('tunnel.password')
|
.addSelect('tunnel.password')
|
||||||
.where('tunnel.id = :id', { id })
|
.where('tunnel.id = :id', { id })
|
||||||
@@ -38,7 +36,6 @@ export class TunnelsService {
|
|||||||
|
|
||||||
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
|
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
|
||||||
|
|
||||||
// 2. Ищем IP основного сервера (куда пересылать трафик)
|
|
||||||
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
||||||
|
|
||||||
if (!hostSetting || !hostSetting.value) {
|
if (!hostSetting || !hostSetting.value) {
|
||||||
@@ -51,12 +48,9 @@ export class TunnelsService {
|
|||||||
|
|
||||||
this.logger.log(`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`);
|
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)`;
|
const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/dp-gui/forwarding_install.sh)`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 4. Выполняем через SSH
|
|
||||||
const output = await this.sshService.executeCommand({
|
const output = await this.sshService.executeCommand({
|
||||||
host: tunnel.ip,
|
host: tunnel.ip,
|
||||||
port: tunnel.sshPort,
|
port: tunnel.sshPort,
|
||||||
@@ -66,7 +60,6 @@ export class TunnelsService {
|
|||||||
|
|
||||||
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
|
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
|
||||||
|
|
||||||
// Помечаем как установленный
|
|
||||||
tunnel.isInstalled = true;
|
tunnel.isInstalled = true;
|
||||||
await this.tunnelRepo.save(tunnel);
|
await this.tunnelRepo.save(tunnel);
|
||||||
|
|
||||||
|
|||||||
@@ -63,23 +63,50 @@ export class XuiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addInbound(inboundConfig: any) {
|
async addInbound(inboundConfig: any) {
|
||||||
try {
|
let attempts = 0;
|
||||||
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
|
const maxAttempts = 3;
|
||||||
if (res.data?.success) {
|
|
||||||
this.logger.log(res.data?.msg);
|
while (attempts < maxAttempts) {
|
||||||
return res.data.obj.id;
|
attempts++;
|
||||||
} else {
|
|
||||||
this.logger.error(res.data?.msg);
|
try {
|
||||||
}
|
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
|
||||||
} catch (e) {
|
|
||||||
this.logger.error(`Ошибка добавления инбаунда: ${e.message}`);
|
if (res.data?.success) {
|
||||||
if (e.response?.status === 401) {
|
return res.data.obj.id;
|
||||||
this.logger.log('Сессия истекла, пробуем релогин...');
|
}
|
||||||
if (await this.login()) {
|
|
||||||
return this.addInbound(inboundConfig);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@
|
|||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": false,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"resolveJsonModule": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user