Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 612f0468f7 | |||
| e7970fd8dd | |||
| bca723f8a0 | |||
| 21e4907aa2 | |||
| 29e776a0ec | |||
| 82811645bf | |||
| 63adfcb98f | |||
| 5b2870e04a | |||
| 21ae964012 | |||
| d171d0440c | |||
| 81dcbc28ae | |||
| 8e0bf98813 | |||
| a6515d73e6 | |||
| 2a7d351abb | |||
| 0decb0bc82 | |||
| 8e9e7b6c66 | |||
| 2147701d5a | |||
| fee9cd543c | |||
| f05ca55201 | |||
| f568de41de | |||
| f7495d691b | |||
| 5d8ca39f6c | |||
| 9bbbc8cd97 | |||
| 460e31260e | |||
| 1b2112e6d9 | |||
| e5f0410197 |
@@ -0,0 +1 @@
|
||||
patreon: DenPiligrim
|
||||
@@ -12,6 +12,11 @@ env:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: production
|
||||
url: https://github.com/denpiligrim/3dp-manager/pkgs/container/3dp-manager-server
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -39,4 +44,4 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-client:${{ github.ref_name }}
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-client:${{ github.ref_name }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
@@ -11,23 +11,23 @@
|
||||
**Поддержать проект**
|
||||
|
||||
- Банковским переводом:
|
||||
- Карта МИР: `2204320436318077`
|
||||
- Карта MasterCard: `5395452209474530`
|
||||
- :credit_card: Карта МИР: `2204320436318077`
|
||||
- :credit_card: Карта MasterCard: `5395452209474530`
|
||||
- На электронный кошелек:
|
||||
- ЮМоney: `4100116897060652`
|
||||
- PayPal: `vasiljevdenisx@gmail.com`
|
||||
- :moneybag: ЮМоney: `4100116897060652`
|
||||
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||
- Криптовалютой:
|
||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- Bybit ID: `165292278`
|
||||
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- :coin: Bybit ID: `165292278`
|
||||
|
||||
## Описание
|
||||
|
||||
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. Бот генерирует по заданному интервалу 10 подключений с разными параметрами:
|
||||
|
||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||
- порты: `443`, `8443` (фиксированные) и случайные из диапазона `10000-60000`;
|
||||
- транспорт: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI берутся из белого списка доменов (whitelist); можно использовать свой список.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Box, Container, Grid, IconButton, Link, Stack } from '@mui/material';
|
||||
import { GitHub, YouTube, Telegram } from '@mui/icons-material';
|
||||
|
||||
export default function Footer() {
|
||||
interface FooterProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({ isMobile }: FooterProps) {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
@@ -19,12 +23,12 @@ export default function Footer() {
|
||||
<Grid container spacing={4} justifyContent="space-between" alignItems="center">
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
|
||||
<Stack direction="row" alignItems="center" spacing={1} justifyContent={isMobile ? 'center' : 'start'}>
|
||||
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: isMobile ? 0 : 14 }} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: 'center' }}>
|
||||
<Link
|
||||
href="https://3dp-manager.com/docs/intro"
|
||||
target="_blank"
|
||||
@@ -38,7 +42,7 @@ export default function Footer() {
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'right' } }}>
|
||||
<Stack direction="row" spacing={1} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}>
|
||||
<Stack direction="row" spacing={1} justifyContent={{ xs: 'center', sm: 'flex-end' }}>
|
||||
|
||||
<IconButton
|
||||
component="a"
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useThemeContext } from '../ThemeContext';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Menu as MenuIcon } from '@mui/icons-material';
|
||||
|
||||
export default function Header() {
|
||||
interface HeaderProps {
|
||||
onMenuClick?: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
const { mode, toggleColorMode } = useThemeContext();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -48,12 +54,17 @@ export default function Header() {
|
||||
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" edge="start" onClick={onMenuClick} sx={{ mr: 1 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
|
||||
<Typography variant={isMobile ? 'body1' : 'h6'} noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
|
||||
3DP-MANAGER
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: isMobile ? 0.25 : 1 }}>
|
||||
|
||||
<Tooltip title="Справка о программе">
|
||||
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
|
||||
@@ -121,7 +132,7 @@ export default function Header() {
|
||||
</List>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Версия: 2.0.0<br />
|
||||
Версия: 2.0.2<br />
|
||||
Разработчик: DenPiligrim
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
Toolbar, Drawer, List, ListItem,
|
||||
ListItemButton, ListItemIcon, ListItemText, Box
|
||||
ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
|
||||
} from '@mui/material';
|
||||
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
@@ -13,6 +14,13 @@ const drawerWidth = 240;
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Подписки', icon: <People />, path: '/' },
|
||||
@@ -21,35 +29,44 @@ export default function Layout() {
|
||||
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
|
||||
];
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<Toolbar />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
if (isMobile) setMobileOpen(false);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
|
||||
|
||||
<Header />
|
||||
{/* Передаем функцию открытия в Header */}
|
||||
<Header onMenuClick={handleDrawerToggle} isMobile={isMobile} />
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
variant={isMobile ? "temporary" : "permanent"}
|
||||
open={isMobile ? mobileOpen : true}
|
||||
onClose={handleDrawerToggle}
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
@@ -59,16 +76,15 @@ export default function Layout() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
|
||||
<Box sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Box sx={{ flexGrow: 1, p: { xs: 2, md: 3 } }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
<Footer />
|
||||
<Footer isMobile={isMobile} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.animated-container {
|
||||
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientBG 15s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
input:autofill {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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, useTheme, useMediaQuery } from '@mui/material';
|
||||
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
|
||||
@@ -10,6 +10,8 @@ export default function DomainsPage() {
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
@@ -52,12 +54,10 @@ export default function DomainsPage() {
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
|
||||
if (confirm('Это действие необратимо. Точно удалить?')) {
|
||||
try {
|
||||
await api.delete('/domains/all');
|
||||
loadDomains();
|
||||
} catch (_e) { alert('Ошибка удаления'); }
|
||||
}
|
||||
try {
|
||||
await api.delete('/domains/all');
|
||||
loadDomains();
|
||||
} catch (_e) { alert('Ошибка удаления'); }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,21 +87,31 @@ export default function DomainsPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Белый список доменов (SNI)</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Добавить домен" size="small" fullWidth
|
||||
label="Доменное имя" size="small" fullWidth
|
||||
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
sx={{ width: '170px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Из файла
|
||||
</Button>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
|
||||
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
sx={{ width: isMobile ? 'auto' : '170px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isMobile ? '' : 'Из файла'}
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
@@ -109,7 +119,6 @@ export default function DomainsPage() {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
|
||||
</Paper>
|
||||
|
||||
{domains.length > 0 && (
|
||||
@@ -124,10 +133,9 @@ export default function DomainsPage() {
|
||||
Удалить все
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
)}
|
||||
|
||||
<Paper>
|
||||
<Paper sx={{ mt: domains.length > 0 ? 0 : 3 }}>
|
||||
<List>
|
||||
{domains.map((d) => (
|
||||
<ListItem key={d.id} secondaryAction={
|
||||
@@ -136,7 +144,7 @@ export default function DomainsPage() {
|
||||
<ListItemText primary={d.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Список пуст</Typography>}
|
||||
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
|
||||
</List>
|
||||
<TablePagination
|
||||
component="div"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Paper, TextField, Button, Typography, Alert } from '@mui/material';
|
||||
import { Box, Paper, TextField, Button, Typography, Alert, Chip } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
@@ -22,12 +22,21 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
<Box className="animated-container" sx={{
|
||||
height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: 'background.default'
|
||||
}}>
|
||||
<Paper sx={{ p: 4, width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="h5" gutterBottom align="center">Вход в 3DP-MANAGER</Typography>
|
||||
<Paper sx={{
|
||||
p: 4,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
animation: 'fadeIn 1.5s ease-out',
|
||||
boxShadow: '0 15px 25px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom align="center"><span style={{ verticalAlign: 'middle' }}>Вход в 3DP-MANAGER</span> <Chip label="v2.0.2" size="small" sx={{ verticalAlign: 'middle' }} /></Typography>
|
||||
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
@@ -40,7 +49,7 @@ export default function LoginPage() {
|
||||
fullWidth margin="normal" label="Пароль" type="password"
|
||||
value={creds.password} onChange={(e) => setCreds({ ...creds, password: e.target.value })}
|
||||
/>
|
||||
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3 }}>
|
||||
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3, transition: 'transform 0.3s ease', '&:hover': { transform: 'scale(1.05)' } }}>
|
||||
Войти
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton } from '@mui/material';
|
||||
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery } from '@mui/material';
|
||||
import api from '../api';
|
||||
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material';
|
||||
|
||||
@@ -27,6 +27,8 @@ export default function SettingsPage() {
|
||||
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
|
||||
const [intervalError, setIntervalError] = useState<string>('');
|
||||
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -190,7 +192,7 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Настройки утилиты</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
|
||||
@@ -276,7 +278,7 @@ export default function SettingsPage() {
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="info"
|
||||
sx={{ mt: 2, ml: 2 }}
|
||||
sx={{ mt: 2, ml: isMobile ? 1 : 2 }}
|
||||
onClick={handleCheckConnection}
|
||||
>
|
||||
Проверить
|
||||
@@ -322,7 +324,7 @@ export default function SettingsPage() {
|
||||
loading={loadingRotate}
|
||||
color="warning"
|
||||
onClick={handleForceRotate}
|
||||
sx={{ mt: 2, ml: 2 }}
|
||||
sx={{ mt: 2, ml: isMobile ? 0 : 2 }}
|
||||
>
|
||||
Сгенерировать сейчас
|
||||
</Button>
|
||||
|
||||
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody, TableCell,
|
||||
TableHead, TableRow, IconButton, Dialog, DialogTitle,
|
||||
DialogContent, TextField, DialogActions,
|
||||
FormControl,
|
||||
Select,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
type SelectChangeEvent
|
||||
DialogContent, TextField, DialogActions, FormControl, Select,
|
||||
InputAdornment, InputLabel, MenuItem,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
Menu,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import { Delete, Add, Link as LinkIcon, Refresh, OpenInNew, ContentCopy, Dns, Router } from '@mui/icons-material';
|
||||
import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
|
||||
interface Subscription {
|
||||
@@ -17,6 +18,7 @@ interface Subscription {
|
||||
name: string;
|
||||
uuid: string;
|
||||
inbounds: any[];
|
||||
inboundsConfig?: any[];
|
||||
}
|
||||
|
||||
interface Tunnel {
|
||||
@@ -27,15 +29,35 @@ interface Tunnel {
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
interface InboundConfigUI {
|
||||
id: string;
|
||||
type: string;
|
||||
port: string;
|
||||
sni: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
interface Domain { id: number; name: string; }
|
||||
|
||||
const CONNECTION_OPTIONS = [
|
||||
'vless-tcp-reality',
|
||||
'vless-xhttp-reality',
|
||||
'vless-grpc-reality',
|
||||
'vless-ws',
|
||||
'hysteria2-udp',
|
||||
'vmess-tcp',
|
||||
'shadowsocks-tcp',
|
||||
'trojan-tcp-reality',
|
||||
'custom',
|
||||
];
|
||||
|
||||
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}`;
|
||||
@@ -50,35 +72,168 @@ const patchLink = function (link: string, newHost: string): string {
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
};
|
||||
|
||||
const generateId = () => Math.random().toString(36).substring(7);
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const [subs, setSubs] = useState<Subscription[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<string | number>('main');
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [activeSub, setActiveSub] = useState<Subscription | null>(null);
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const openActionMenu = Boolean(menuAnchorEl);
|
||||
|
||||
// Состояния модального окна конструктора
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [inbounds, setInbounds] = useState<InboundConfigUI[]>([]);
|
||||
const [portErrors, setPortErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Состояния ссылок
|
||||
const [linksOpen, setLinksOpen] = useState(false);
|
||||
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
useEffect(() => { loadSubs(); }, []);
|
||||
|
||||
const loadSubs = async () => {
|
||||
const { data } = await api.get('/subscriptions');
|
||||
setSubs(data);
|
||||
|
||||
const tunnelsRes = await api.get('/tunnels');
|
||||
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
|
||||
|
||||
const allDomains = await api.get('/domains/all');
|
||||
setDomains(allDomains.data);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
await api.post('/subscriptions', { name });
|
||||
setOpen(false);
|
||||
const handleActionMenuClick = (event: React.MouseEvent<HTMLButtonElement>, sub: Subscription) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
setActiveSub(sub);
|
||||
};
|
||||
|
||||
const handleActionMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
setActiveSub(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingId(null);
|
||||
setName('');
|
||||
loadSubs();
|
||||
setInbounds([
|
||||
{ id: generateId(), type: 'hysteria2-udp', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vless-ws', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random', link: '' },
|
||||
{ id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random', link: '' },
|
||||
]);
|
||||
setPortErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (sub: Subscription) => {
|
||||
setEditingId(sub.id);
|
||||
setName(sub.name);
|
||||
|
||||
if (sub.inboundsConfig && sub.inboundsConfig.length > 0) {
|
||||
setInbounds(sub.inboundsConfig.map(i => ({
|
||||
id: generateId(),
|
||||
type: i.type || 'vless-tcp-reality',
|
||||
port: i.port ? i.port.toString() : 'random',
|
||||
sni: i.sni || 'random',
|
||||
link: i.link || ''
|
||||
})));
|
||||
} else {
|
||||
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
|
||||
}
|
||||
|
||||
setPortErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
|
||||
setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
|
||||
if (field === 'port' || (field === 'type' && value === 'custom')) {
|
||||
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
|
||||
}
|
||||
};
|
||||
|
||||
const addInbound = () => {
|
||||
if (inbounds.length < 20) {
|
||||
setInbounds([...inbounds, { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeInbound = (id?: string) => {
|
||||
if (id) {
|
||||
if (inbounds.length > 1) {
|
||||
setInbounds(inbounds.filter(inb => inb.id !== id));
|
||||
setPortErrors(prev => {
|
||||
const n = { ...prev };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setInbounds([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'vless-tcp-reality',
|
||||
port: 'random',
|
||||
sni: 'random',
|
||||
link: ''
|
||||
}
|
||||
]);
|
||||
setPortErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (Object.keys(portErrors).length > 0) {
|
||||
alert('Пожалуйста, исправьте ошибки с портами');
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
alert('Введите имя подписки');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
inboundsConfig: inbounds.map(i => {
|
||||
if (i.type === 'custom') {
|
||||
return { type: i.type, link: i.link };
|
||||
}
|
||||
return {
|
||||
type: i.type,
|
||||
port: i.port === 'random' ? 'random' : parseInt(i.port),
|
||||
sni: i.sni
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put(`/subscriptions/${editingId}`, payload);
|
||||
} else {
|
||||
await api.post('/subscriptions', payload);
|
||||
}
|
||||
setOpen(false);
|
||||
loadSubs();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.message || 'Произошла ошибка при сохранении');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -104,20 +259,15 @@ export default function SubscriptionsPage() {
|
||||
setLinksOpen(true);
|
||||
};
|
||||
|
||||
const handleServerChange = (event: SelectChangeEvent<any>) => {
|
||||
setSelectedServer(event.target.value as string);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Подписки</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'}>Подписки</Typography>
|
||||
{tunnels.length > 0 && (
|
||||
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
|
||||
<Select
|
||||
labelId="server-select-label"
|
||||
value={selectedServer}
|
||||
onChange={handleServerChange}
|
||||
onChange={(e) => setSelectedServer(e.target.value)}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
|
||||
@@ -125,29 +275,22 @@ export default function SubscriptionsPage() {
|
||||
}
|
||||
>
|
||||
<MenuItem value="main">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{tunnels.map((t) => (
|
||||
<MenuItem key={t.id} value={t.id.toString()}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box>
|
||||
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={handleOpenCreate}>Создать</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
<Paper>
|
||||
<Paper sx={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -164,53 +307,187 @@ export default function SubscriptionsPage() {
|
||||
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
|
||||
<TableCell>{sub.inbounds?.length || 0}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
|
||||
title="Копировать ссылку"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
|
||||
title="Открыть подписку"
|
||||
>
|
||||
<OpenInNew />
|
||||
</IconButton>
|
||||
<IconButton color="primary" onClick={() => showLinks(sub)} title="Показать конфиги">
|
||||
<LinkIcon />
|
||||
</IconButton>
|
||||
<IconButton color="primary" onClick={() => handleDelete(sub.id)} title="Удалить">
|
||||
<Delete />
|
||||
{!isMobile && (
|
||||
<>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
|
||||
title="Копировать ссылку"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
|
||||
title="Открыть подписку"
|
||||
>
|
||||
<OpenInNew />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Кнопка "Три точки" для вызова меню действий */}
|
||||
<IconButton onClick={(e) => handleActionMenuClick(e, sub)}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{subs.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет подписок</Typography>}
|
||||
</Paper>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} disableRestoreFocus>
|
||||
<DialogTitle>Новая подписка</DialogTitle>
|
||||
<DialogContent>
|
||||
<Menu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={openActionMenu}
|
||||
onClose={handleActionMenuClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{isMobile && activeSub && (
|
||||
<MenuItem onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`)}>
|
||||
<ListItemIcon><ContentCopy fontSize="small" color="primary" /></ListItemIcon>
|
||||
<ListItemText>Копировать ссылку</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMobile && activeSub && (
|
||||
<MenuItem onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`, '_blank')}>
|
||||
<ListItemIcon><OpenInNew fontSize="small" color="primary" /></ListItemIcon>
|
||||
<ListItemText>Открыть подписку</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{activeSub && (
|
||||
<MenuItem onClick={() => showLinks(activeSub)}>
|
||||
<ListItemIcon><LinkIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Показать конфиги</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{activeSub && (
|
||||
<MenuItem onClick={() => handleOpenEdit(activeSub)}>
|
||||
<ListItemIcon><Edit fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Редактировать</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{activeSub && (
|
||||
<MenuItem onClick={() => handleDelete(activeSub.id)}>
|
||||
<ListItemIcon><Delete fontSize="small" color="error" /></ListItemIcon>
|
||||
<ListItemText sx={{ color: 'error.main' }}>Удалить</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
{/* Модальное окно создания / редактирования */}
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth disableRestoreFocus>
|
||||
<DialogTitle variant='h5'>{editingId ? 'Редактировать подписку' : 'Новая подписка'}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TextField
|
||||
autoFocus margin="dense" label="Имя пользователя" fullWidth
|
||||
autoFocus margin="dense" label="Имя подписки" fullWidth
|
||||
value={name} onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
sx={{ mb: 4 }}
|
||||
/>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Инбаунды ({inbounds.length}/20)
|
||||
</Typography>
|
||||
|
||||
{inbounds.map((inb, index) => (
|
||||
<Box key={inb.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2, p: 2 }}>
|
||||
<Typography sx={{ mt: 1, minWidth: 30, fontWeight: 'bold' }}>#{index + 1}</Typography>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 185 }}>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={inb.type}
|
||||
label="Тип"
|
||||
sx={{ minWidth: '185px' }}
|
||||
onChange={(e) => handleInboundChange(inb.id, 'type', e.target.value)}
|
||||
>
|
||||
{CONNECTION_OPTIONS.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{inb.type === 'custom' ? (
|
||||
// Поле для кастомной ссылки
|
||||
<FormControl size="small" sx={{ flexGrow: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Ссылка на подключение"
|
||||
placeholder="vless://..."
|
||||
value={inb.link || ''}
|
||||
onChange={(e) => handleInboundChange(inb.id, 'link', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Порт"
|
||||
placeholder="random или порт"
|
||||
value={inb.port}
|
||||
onChange={(e) => handleInboundChange(inb.id, 'port', e.target.value)}
|
||||
error={!!portErrors[inb.id]}
|
||||
helperText={portErrors[inb.id] || ""}
|
||||
sx={{ width: 140 }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>SNI</InputLabel>
|
||||
<Select
|
||||
value={inb.sni}
|
||||
label="SNI"
|
||||
onChange={(e) => handleInboundChange(inb.id, 'sni', e.target.value)}
|
||||
>
|
||||
<MenuItem value="random">random</MenuItem>
|
||||
{domains.map(opt => <MenuItem key={opt.id} value={opt.name}>{opt.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => removeInbound(inb.id)}
|
||||
disabled={inbounds.length <= 1}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size='small'
|
||||
startIcon={<Add />}
|
||||
onClick={addInbound}
|
||||
disabled={inbounds.length >= 20}
|
||||
>
|
||||
Добавить инбаунд
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<Remove />}
|
||||
sx={{ ml: 0.5 }}
|
||||
onClick={() => removeInbound()}
|
||||
>
|
||||
Удалить все
|
||||
</Button>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button onClick={handleCreate}>Создать</Button>
|
||||
<Button onClick={handleSave} variant="contained" color="primary">Сохранить</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Модальное окно ссылок */}
|
||||
<Dialog open={linksOpen} onClose={() => setLinksOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Активные ссылки</DialogTitle>
|
||||
<DialogContent>
|
||||
|
||||
@@ -2,7 +2,13 @@ import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody, TableCell,
|
||||
TableHead, TableRow, IconButton, Dialog, DialogTitle,
|
||||
DialogContent, TextField, DialogActions, Chip, CircularProgress
|
||||
DialogContent, TextField, DialogActions, Chip, CircularProgress,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
FormControl,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio
|
||||
} from '@mui/material';
|
||||
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
@@ -20,9 +26,12 @@ export default function TunnelsPage() {
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loadingId, setLoadingId] = useState<number | null>(null);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
|
||||
name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: ''
|
||||
});
|
||||
|
||||
useEffect(() => { loadTunnels(); }, []);
|
||||
@@ -35,9 +44,16 @@ export default function TunnelsPage() {
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
await api.post('/tunnels', form);
|
||||
const payload = {
|
||||
...form,
|
||||
password: authMethod === 'password' ? form.password : null,
|
||||
privateKey: authMethod === 'key' ? form.privateKey : null,
|
||||
};
|
||||
|
||||
await api.post('/tunnels', payload);
|
||||
setOpen(false);
|
||||
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
|
||||
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' });
|
||||
setAuthMethod('password');
|
||||
loadTunnels();
|
||||
};
|
||||
|
||||
@@ -70,11 +86,11 @@ export default function TunnelsPage() {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Relay серверы</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
|
||||
</Box>
|
||||
|
||||
<Paper>
|
||||
<Paper sx={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -95,23 +111,35 @@ export default function TunnelsPage() {
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.isInstalled ?
|
||||
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" /> :
|
||||
<Chip icon={<Error />} label="Не настроен" color="warning" size="small" variant="outlined" />
|
||||
}
|
||||
{!isMobile && (t.isInstalled ?
|
||||
<Chip icon={<CheckCircle />} label={"Активен"} color="success" size="small" variant="outlined" /> :
|
||||
<Chip icon={<Error />} label={"Не настроен"} color="warning" size="small" variant="outlined" />
|
||||
)}
|
||||
{isMobile && (t.isInstalled ?
|
||||
<CheckCircle color='success' /> :
|
||||
<Error color='warning' />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{!t.isInstalled && (
|
||||
<Button
|
||||
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => handleInstall(t.id)}
|
||||
sx={{ mr: 1 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{loadingId === t.id ? 'Установка...' : 'Установить'}
|
||||
</Button>
|
||||
<>
|
||||
{isMobile ? (
|
||||
<IconButton disabled={loadingId !== null} color="primary" onClick={() => handleInstall(t.id)}>
|
||||
{loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => handleInstall(t.id)}
|
||||
sx={{ mr: 1 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{isMobile ? '' : (loadingId === t.id ? 'Установка...' : 'Установить')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
|
||||
<Delete />
|
||||
@@ -119,7 +147,7 @@ export default function TunnelsPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center">Список пуст</TableCell></TableRow>}
|
||||
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>Нет серверов</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
@@ -133,7 +161,28 @@ export default function TunnelsPage() {
|
||||
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
|
||||
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
|
||||
</Box>
|
||||
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
|
||||
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
|
||||
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
|
||||
<FormControlLabel value="password" control={<Radio />} label="По паролю" />
|
||||
<FormControlLabel value="key" control={<Radio />} label="По SSH ключу" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{authMethod === 'password' ? (
|
||||
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
|
||||
) : (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="SSH Private Key (RSA / Ed25519)"
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
value={form.privateKey}
|
||||
onChange={handleChange('privateKey')}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import type { PaletteMode } from '@mui/material';
|
||||
|
||||
const lightPalette = {
|
||||
primary: {
|
||||
main: '#2563eb',
|
||||
main: '#1395de',
|
||||
light: '#60a5fa',
|
||||
dark: '#1e40af',
|
||||
},
|
||||
@@ -21,7 +21,7 @@ const lightPalette = {
|
||||
|
||||
const darkPalette = {
|
||||
primary: {
|
||||
main: '#3b82f6',
|
||||
main: '#1395de',
|
||||
light: '#60a5fa',
|
||||
dark: '#1d4ed8',
|
||||
},
|
||||
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD:-admin}
|
||||
DB_NAME: ${POSTGRES_DB:-3dp_manager}
|
||||
JWT_SECRET: ${JWT_SECRET:-secretKey}
|
||||
ADMIN_LOGIN: ${ADMIN_LOGIN:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
|
||||
+82
-2
@@ -175,8 +175,67 @@ FINAL_PORT=$(get_random_port)
|
||||
# --- 4. Генерация паролей ---
|
||||
DB_PASS=$(openssl rand -base64 12)
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
ADMIN_USER=$(openssl rand -base64 8)
|
||||
ADMIN_PASS=$(openssl rand -base64 12)
|
||||
log "Сгенерированы секретные ключи для БД и JWT."
|
||||
|
||||
#################################
|
||||
# Hysteria 2
|
||||
#################################
|
||||
|
||||
# Проверка установки Hysteria 2 через наличие systemd сервиса
|
||||
if ! systemctl cat hysteria-server.service &> /dev/null; then
|
||||
echo "Сервис Hysteria 2 не найден. Начинаем установку..."
|
||||
|
||||
# Установка Hysteria 2 согласно документации
|
||||
bash <(curl -fsSL https://get.hy2.sh/)
|
||||
|
||||
RANDOM_FREE_PORT=$(get_random_port)
|
||||
|
||||
# Генерация надежных паролей
|
||||
GENERATED_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16)
|
||||
GENERATED_OBFS_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16)
|
||||
|
||||
# Запрос данных у пользователя
|
||||
echo "=== Настройка Hysteria 2 ==="
|
||||
read -p "Введите email для уведомлений Let's Encrypt: " HYSTERIA_EMAIL
|
||||
|
||||
# Создание конфигурационного файла
|
||||
cat > /etc/hysteria/config.yaml <<EOF
|
||||
listen: :$RANDOM_FREE_PORT
|
||||
|
||||
acme:
|
||||
domains:
|
||||
- $UI_HOST
|
||||
email: $HYSTERIA_EMAIL
|
||||
|
||||
auth:
|
||||
type: password
|
||||
password: $GENERATED_PASSWORD
|
||||
|
||||
obfs:
|
||||
type: salamander
|
||||
salamander:
|
||||
password: $GENERATED_OBFS_PASSWORD
|
||||
|
||||
masquerade:
|
||||
type: proxy
|
||||
proxy:
|
||||
url: https://ya.ru/
|
||||
rewriteHost: true
|
||||
EOF
|
||||
|
||||
# Перезапуск демона и включение сервиса для автозапуска
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now hysteria-server.service
|
||||
systemctl restart hysteria-server.service
|
||||
|
||||
echo "Hysteria 2 успешно установлена и запущена на порту $RANDOM_FREE_PORT"
|
||||
systemctl status hysteria-server.service --no-pager
|
||||
else
|
||||
echo "Hysteria 2 уже установлена, пропускаем установку."
|
||||
fi
|
||||
|
||||
#################################
|
||||
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
|
||||
#################################
|
||||
@@ -186,6 +245,8 @@ DB_PORT=5432
|
||||
DB_USERNAME=admin
|
||||
DB_PASSWORD=${DB_PASS}
|
||||
DB_NAME=3dp_manager
|
||||
ADMIN_LOGIN=${ADMIN_USER}
|
||||
ADMIN_PASSWORD=${ADMIN_PASS}
|
||||
EOF
|
||||
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
@@ -266,7 +327,11 @@ services:
|
||||
DB_PASSWORD: ${DB_PASS}
|
||||
DB_NAME: 3dp_manager
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_LOGIN: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||
PORT: 3000
|
||||
volumes:
|
||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -365,7 +430,11 @@ services:
|
||||
DB_PASSWORD: ${DB_PASS}
|
||||
DB_NAME: 3dp_manager
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_LOGIN: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||
PORT: 3000
|
||||
volumes:
|
||||
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -405,6 +474,17 @@ docker compose up --build -d
|
||||
log "Очистка кэша сборки..."
|
||||
docker image prune -f
|
||||
|
||||
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 10000:60000/tcp
|
||||
ufw allow 10000:60000/udp
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==================================================="
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
@@ -412,8 +492,8 @@ if [[ "$USE_SSL" == "true" ]]; then
|
||||
else
|
||||
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: http://${UI_HOST}:${FINAL_PORT}${NC}"
|
||||
fi
|
||||
echo "Логин: admin"
|
||||
echo "Пароль: admin"
|
||||
echo -e "${GREEN}Логин: ${ADMIN_USER}${NC}"
|
||||
echo -e "${GREEN}Пароль: ${ADMIN_PASS}${NC}"
|
||||
echo ""
|
||||
echo "Немедленно измените пароль в Настройках утилиты!"
|
||||
echo "==================================================="
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "3dp-manager",
|
||||
"version": "2.0.2",
|
||||
"description": "Inbound generator for 3x-ui",
|
||||
"private": false,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/denpiligrim/3dp-manager.git"
|
||||
},
|
||||
"author": "DenPiligrim",
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=admin
|
||||
DB_PASSWORD=
|
||||
DB_NAME=3dp_manager
|
||||
ADMIN_LOGIN=admin
|
||||
ADMIN_PASSWORD=
|
||||
Generated
+40
@@ -21,6 +21,8 @@
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.17.1",
|
||||
@@ -2975,6 +2977,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -4521,6 +4529,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-transformer": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
|
||||
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
"validator": "^13.15.22"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
@@ -7540,6 +7565,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.12.40",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.40.tgz",
|
||||
"integrity": "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -10716,6 +10747,15 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.26",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
|
||||
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.17.1",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Setting } from '../settings/entities/setting.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -13,6 +14,7 @@ export class AuthService {
|
||||
@InjectRepository(Setting)
|
||||
private settingsRepo: Repository<Setting>,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async validateUser(login: string, pass: string): Promise<any> {
|
||||
@@ -85,12 +87,14 @@ export class AuthService {
|
||||
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
||||
|
||||
if (!login) {
|
||||
this.logger.log('Инициализация администратора (admin / admin)...');
|
||||
this.logger.log('Инициализация администратора...');
|
||||
const envLogin = this.configService.get<string>('ADMIN_LOGIN') || 'admin';
|
||||
const envPass = this.configService.get<string>('ADMIN_PASSWORD') || 'admin';
|
||||
|
||||
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
|
||||
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: envLogin });
|
||||
await this.settingsRepo.save(loginSetting);
|
||||
|
||||
const hash = await bcrypt.hash('admin', 10);
|
||||
const hash = await bcrypt.hash(envPass, 10);
|
||||
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
|
||||
await this.settingsRepo.save(passSetting);
|
||||
|
||||
|
||||
@@ -155,11 +155,14 @@ export class ClientController {
|
||||
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
let links = sub.inbounds
|
||||
?.map(i => i.link)
|
||||
.filter(l => l && l.length > 0) || [];
|
||||
|
||||
links = links.map(link => this.patchLink(link, relayHost));
|
||||
const links = sub.inbounds
|
||||
?.filter(i => i.link && i.link.length > 0)
|
||||
.map(i => {
|
||||
if (i.protocol === 'custom') {
|
||||
return i.link;
|
||||
}
|
||||
return this.patchLink(i.link, relayHost);
|
||||
}) || [];
|
||||
|
||||
const plainTextList = links.join('\n');
|
||||
const base64Config = Buffer.from(plainTextList).toString('base64');
|
||||
@@ -267,7 +270,7 @@ export class ClientController {
|
||||
} catch (e) {
|
||||
return link;
|
||||
}
|
||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://') || link.startsWith('hy2://')) {
|
||||
return link.replace(/@.*?:/, `@${newHost}:`);
|
||||
} else if (link.startsWith('ss://')) {
|
||||
if (link.includes('@')) {
|
||||
|
||||
@@ -15,6 +15,11 @@ export class DomainsController {
|
||||
return this.domainsService.createMany(body.domains);
|
||||
}
|
||||
|
||||
@Get('all')
|
||||
findAllWithoutPagination() {
|
||||
return this.domainsService.findAllUnpaginated();
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page: number,
|
||||
|
||||
@@ -60,6 +60,10 @@ export class DomainsService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
async findAllUnpaginated(): Promise<Domain[]> {
|
||||
return this.repo.find({ order: { name: 'ASC' } });
|
||||
}
|
||||
|
||||
findOne(id: number) {
|
||||
return this.repo.findOneBy({ id });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class InboundBuilderService {
|
||||
private flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
|
||||
|
||||
buildVlessRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, domain, privateKey, publicKey } = params;
|
||||
buildVlessRealityTcp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, sni, privateKey, publicKey } = params;
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
@@ -26,9 +27,9 @@ export class InboundBuilderService {
|
||||
realitySettings: {
|
||||
show: false,
|
||||
xver: 0,
|
||||
target: `${domain}:443`,
|
||||
dest: `${domain}:443`,
|
||||
serverNames: [domain],
|
||||
target: `${sni}:443`,
|
||||
dest: `${sni}:443`,
|
||||
serverNames: [sni],
|
||||
privateKey: privateKey,
|
||||
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
|
||||
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
|
||||
@@ -39,8 +40,8 @@ export class InboundBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
buildVlessRealityXhttp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, domain, privateKey, publicKey } = params;
|
||||
buildVlessRealityXhttp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, sni, privateKey, publicKey } = params;
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
@@ -59,15 +60,15 @@ export class InboundBuilderService {
|
||||
realitySettings: {
|
||||
show: false,
|
||||
xver: 0,
|
||||
target: `${domain}:443`,
|
||||
dest: `${domain}:443`,
|
||||
serverNames: [domain],
|
||||
target: `${sni}:443`,
|
||||
dest: `${sni}:443`,
|
||||
serverNames: [sni],
|
||||
privateKey: privateKey,
|
||||
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
|
||||
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
|
||||
},
|
||||
xhttpSettings: {
|
||||
host: domain,
|
||||
host: sni,
|
||||
path: "/",
|
||||
mode: "auto",
|
||||
noSSEHeader: false,
|
||||
@@ -86,13 +87,13 @@ export class InboundBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
buildVlessRealityGrpc(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, domain, privateKey, publicKey } = params;
|
||||
buildVlessRealityGrpc(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, sni, privateKey, publicKey } = params;
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
protocol: "vless",
|
||||
remark: "vless-reality-grpc",
|
||||
remark: "vless-grpc-reality",
|
||||
settings: JSON.stringify({
|
||||
clients: [{
|
||||
id: uuid,
|
||||
@@ -117,16 +118,16 @@ export class InboundBuilderService {
|
||||
realitySettings: {
|
||||
show: false,
|
||||
xver: 0,
|
||||
target: `${domain}:443`,
|
||||
dest: `${domain}:443`,
|
||||
serverNames: [domain],
|
||||
target: `${sni}:443`,
|
||||
dest: `${sni}:443`,
|
||||
serverNames: [sni],
|
||||
privateKey: privateKey,
|
||||
shortIds: [crypto.randomBytes(4).toString('hex')],
|
||||
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
|
||||
},
|
||||
grpcSettings: {
|
||||
serviceName: "myservice",
|
||||
authority: domain,
|
||||
authority: sni,
|
||||
multiMode: false,
|
||||
}
|
||||
}),
|
||||
@@ -139,8 +140,8 @@ export class InboundBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
buildVlessWs(params: { port: number; uuid: string; domain: string }) {
|
||||
const { port, uuid, domain } = params;
|
||||
buildVlessWs(params: { port: number; uuid: string; sni: string }) {
|
||||
const { port, uuid, sni } = params;
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
@@ -168,7 +169,7 @@ export class InboundBuilderService {
|
||||
security: "none",
|
||||
externalProxy: [],
|
||||
wsSettings: {
|
||||
host: domain,
|
||||
host: sni,
|
||||
path: "/",
|
||||
acceptProxyProtocol: false,
|
||||
heartbeatPeriod: 0,
|
||||
@@ -265,8 +266,8 @@ export class InboundBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
buildTrojanRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, domain, privateKey, publicKey } = params;
|
||||
buildTrojanRealityTcp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
|
||||
const { port, uuid, sni, privateKey, publicKey } = params;
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
@@ -295,9 +296,9 @@ export class InboundBuilderService {
|
||||
realitySettings: {
|
||||
show: false,
|
||||
xver: 0,
|
||||
target: `${domain}:443`,
|
||||
dest: `${domain}:443`,
|
||||
serverNames: [domain],
|
||||
target: `${sni}:443`,
|
||||
dest: `${sni}:443`,
|
||||
serverNames: [sni],
|
||||
privateKey: privateKey,
|
||||
shortIds: [
|
||||
crypto.randomBytes(4).toString("hex"),
|
||||
@@ -334,29 +335,29 @@ export class InboundBuilderService {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
buildInboundLink(inbound: any, domain: string, idOrPass: string, flagEmoji: string): string {
|
||||
buildInboundLink(inbound: any, sni: string, idOrPass: string, flagEmoji: string): string {
|
||||
this.flag = flagEmoji;
|
||||
let link = "";
|
||||
|
||||
switch (inbound.protocol) {
|
||||
case "vless":
|
||||
link = this.buildVlessLink(inbound, domain, idOrPass);
|
||||
link = this.buildVlessLink(inbound, sni, idOrPass);
|
||||
break;
|
||||
case "vmess":
|
||||
link = this.buildVmessLink(inbound, domain, idOrPass);
|
||||
link = this.buildVmessLink(inbound, sni, idOrPass);
|
||||
break;
|
||||
case "shadowsocks":
|
||||
link = this.buildSsLink(inbound, domain, idOrPass);
|
||||
link = this.buildSsLink(inbound, sni, idOrPass);
|
||||
break;
|
||||
case "trojan":
|
||||
link = this.buildTrojanLink(inbound, domain, idOrPass);
|
||||
link = this.buildTrojanLink(inbound, sni, idOrPass);
|
||||
break;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
private buildVlessLink(inbound: any, domain: string, uuid: string) {
|
||||
private buildVlessLink(inbound: any, sni: string, uuid: string) {
|
||||
const stream = JSON.parse(inbound.streamSettings);
|
||||
const settings = JSON.parse(inbound.settings);
|
||||
|
||||
@@ -407,17 +408,17 @@ export class InboundBuilderService {
|
||||
}
|
||||
|
||||
return (
|
||||
`vless://${uuid}@${domain}:${inbound.port}` +
|
||||
`vless://${uuid}@${sni}:${inbound.port}` +
|
||||
`?${params.toString()}` +
|
||||
`#${this.flag}%20${encodeURIComponent(inbound.remark)}`
|
||||
);
|
||||
}
|
||||
|
||||
private buildVmessLink(inbound: any, domain: string, uuid: string) {
|
||||
private buildVmessLink(inbound: any, sni: string, uuid: string) {
|
||||
const stream = JSON.parse(inbound.streamSettings);
|
||||
|
||||
const vmessObj = {
|
||||
add: domain,
|
||||
add: sni,
|
||||
aid: '0',
|
||||
alpn: "",
|
||||
fp: "",
|
||||
@@ -441,7 +442,7 @@ export class InboundBuilderService {
|
||||
return `vmess://${base64}`;
|
||||
}
|
||||
|
||||
private buildSsLink(inbound: any, domain: string, idOrPass: string) {
|
||||
private buildSsLink(inbound: any, sni: string, idOrPass: string) {
|
||||
const settings = JSON.parse(inbound.settings);
|
||||
|
||||
const method = settings.method;
|
||||
@@ -454,28 +455,67 @@ export class InboundBuilderService {
|
||||
.from(userInfo, "utf8")
|
||||
.toString("base64");
|
||||
|
||||
return `ss://${base64}@${domain}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
|
||||
return `ss://${base64}@${sni}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
|
||||
}
|
||||
|
||||
private buildTrojanLink(inbound: any, domain: string, password: string) {
|
||||
private buildTrojanLink(inbound: any, sni: string, password: string) {
|
||||
const stream = JSON.parse(inbound.streamSettings);
|
||||
const reality = stream.realitySettings;
|
||||
|
||||
const pbk = reality.settings.publicKey;
|
||||
const sni = reality.serverNames?.[0] || domain;
|
||||
const SNI = reality.serverNames?.[0] || sni;
|
||||
const sid = reality.shortIds?.[0] || "";
|
||||
const spx = '%2F';
|
||||
|
||||
return (
|
||||
`trojan://${password}@${domain}:${inbound.port}` +
|
||||
`trojan://${password}@${SNI}:${inbound.port}` +
|
||||
`?type=tcp` +
|
||||
`&security=reality` +
|
||||
`&pbk=${pbk}` +
|
||||
`&fp=random` +
|
||||
`&sni=${sni}` +
|
||||
`&sni=${SNI}` +
|
||||
`&sid=${sid}` +
|
||||
`&spx=${spx}` +
|
||||
`#${this.flag}%20${inbound.remark}`
|
||||
);
|
||||
}
|
||||
|
||||
buildHysteria2Link(serverAddress: string, sni: string, remark: string): string {
|
||||
let auth = 'YOUR_AUTH';
|
||||
let obfs = 'salamander';
|
||||
let obfsPass = 'YOUR_PASS';
|
||||
let port = 443;
|
||||
|
||||
try {
|
||||
const configPath = '/etc/hysteria/config.yaml';
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const fileContent = fs.readFileSync(configPath, 'utf8');
|
||||
|
||||
const authMatch = fileContent.match(/password:\s*['"]?([^'"\n]+)['"]?/);
|
||||
if (authMatch) auth = authMatch[1];
|
||||
|
||||
const obfsMatch = fileContent.match(/type:\s*['"]?(salamander)['"]?/);
|
||||
if (obfsMatch) obfs = obfsMatch[1];
|
||||
|
||||
const passMatch = fileContent.match(/salamander:[\s\S]*?password:\s*['"]?([^'"\n]+)['"]?/);
|
||||
if (passMatch) obfsPass = passMatch[1];
|
||||
|
||||
const listenMatch = fileContent.match(/listen:\s*['"]?:(\d+)['"]?/);
|
||||
if (listenMatch) port = parseInt(listenMatch[1], 10);
|
||||
} else {
|
||||
console.warn(`Конфиг Hysteria2 не найден по пути: ${configPath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка чтения конфига Hysteria2', e);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('insecure', '0');
|
||||
params.set('sni', serverAddress);
|
||||
params.set('obfs', obfs);
|
||||
params.set('obfs-password', obfsPass);
|
||||
|
||||
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CONNECTION_TYPES = [
|
||||
'vless-tcp-reality',
|
||||
'vless-xhttp-reality',
|
||||
'vless-grpc-reality',
|
||||
'vless-ws',
|
||||
'vmess-tcp',
|
||||
'shadowsocks-tcp',
|
||||
'trojan-tcp-reality',
|
||||
'hysteria2-udp',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export type ConnectionType = typeof CONNECTION_TYPES[number];
|
||||
@@ -102,12 +102,15 @@ export class RotationService implements OnModuleInit {
|
||||
return { success: true, message: 'Ротация успешно выполнена' };
|
||||
}
|
||||
|
||||
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
|
||||
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
|
||||
this.logger.log(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
|
||||
|
||||
// Удаляем старые инбаунды
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
for (const inbound of sub.inbounds) {
|
||||
await this.xuiService.deleteInbound(inbound.xuiId);
|
||||
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||
await this.xuiService.deleteInbound(inbound.xuiId);
|
||||
}
|
||||
await this.inboundRepo.delete(inbound.id);
|
||||
}
|
||||
}
|
||||
@@ -119,55 +122,103 @@ export class RotationService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const usedPorts = new Set<number>();
|
||||
|
||||
const tasks = [
|
||||
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
() => this.inboundBuilder.buildVlessRealityXhttp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
() => this.inboundBuilder.buildVlessRealityGrpc({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
() => this.inboundBuilder.buildVlessWs({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains) }),
|
||||
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
() => this.inboundBuilder.buildVmessTcp({ port: 0, uuid: uuidv4() }),
|
||||
() => this.inboundBuilder.buildShadowsocksTcp({ port: 0, uuid: uuidv4() }),
|
||||
() => this.inboundBuilder.buildTrojanRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
|
||||
];
|
||||
|
||||
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
||||
const serverAddress = host?.value || 'localhost';
|
||||
const flag = await this.settingRepo.findOne({ where: { key: 'xui_geo_flag' } });
|
||||
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
let config = task();
|
||||
// Получаем конфиг или пустой массив
|
||||
const inboundsConfig = sub.inboundsConfig || [];
|
||||
|
||||
for (const config of inboundsConfig) {
|
||||
const type = config.type;
|
||||
const uuid = uuidv4();
|
||||
|
||||
let sni = '';
|
||||
|
||||
// === 1. Обработка Custom ===
|
||||
if (type === 'custom') {
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: 0, // Не привязано к 3x-ui
|
||||
port: 0,
|
||||
protocol: 'custom',
|
||||
remark: 'custom-link',
|
||||
link: config.link || '',
|
||||
subscription: sub
|
||||
});
|
||||
await this.inboundRepo.save(newInbound);
|
||||
continue;
|
||||
} else {
|
||||
sni = config.sni === 'random' ? this.pickDomain(domains) : config.sni;
|
||||
}
|
||||
|
||||
// === 2. Обработка Hysteria2 ===
|
||||
if (type === 'hysteria2-udp') {
|
||||
const link = this.inboundBuilder.buildHysteria2Link(serverAddress, sni, flagEmoji + '%20hysteria2-udp');
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: 0,
|
||||
port: 0, // Обычно Hysteria висит на 443, фактический порт вытаскивается в билдере
|
||||
protocol: 'hysteria2',
|
||||
remark: 'hysteria2-udp',
|
||||
link: link,
|
||||
subscription: sub
|
||||
});
|
||||
await this.inboundRepo.save(newInbound);
|
||||
continue;
|
||||
}
|
||||
|
||||
// === 3. Обработка стандартных инбаундов Xray (3x-ui) ===
|
||||
|
||||
// Определяем порт
|
||||
let port = 0;
|
||||
if (index === 0) port = await this.getFreePort(8443, usedPorts);
|
||||
else if (index === 1) port = await this.getFreePort(443, usedPorts);
|
||||
else port = await this.getFreePort(0, usedPorts);
|
||||
|
||||
config.port = port;
|
||||
if (config.port === 'random' || !config.port) {
|
||||
port = await this.getFreePort(0, usedPorts);
|
||||
} else {
|
||||
// Если передан конкретный порт строкой или числом
|
||||
port = typeof config.port === 'string' ? parseInt(config.port, 10) : config.port;
|
||||
}
|
||||
usedPorts.add(port);
|
||||
|
||||
const xuiId = await this.xuiService.addInbound(config);
|
||||
let xuiConfig: any;
|
||||
|
||||
switch (type) {
|
||||
case 'vless-tcp-reality':
|
||||
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({ port, uuid, sni, ...keys });
|
||||
break;
|
||||
case 'vless-xhttp-reality':
|
||||
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({ port, uuid, sni, ...keys });
|
||||
break;
|
||||
case 'vless-grpc-reality':
|
||||
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({ port, uuid, sni, ...keys });
|
||||
break;
|
||||
case 'vless-ws':
|
||||
xuiConfig = this.inboundBuilder.buildVlessWs({ port, uuid, sni });
|
||||
break;
|
||||
case 'vmess-tcp':
|
||||
xuiConfig = this.inboundBuilder.buildVmessTcp({ port, uuid });
|
||||
break;
|
||||
case 'shadowsocks-tcp':
|
||||
xuiConfig = this.inboundBuilder.buildShadowsocksTcp({ port, uuid });
|
||||
break;
|
||||
case 'trojan-tcp-reality':
|
||||
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({ port, uuid, sni, ...keys });
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`Неизвестный тип инбаунда: ${type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const xuiId = await this.xuiService.addInbound(xuiConfig);
|
||||
|
||||
if (xuiId) {
|
||||
const remarkParts = config.remark.split('-');
|
||||
let domainForLink = 'unknown';
|
||||
try {
|
||||
const ss = JSON.parse(config.streamSettings || '{}');
|
||||
if (ss.realitySettings?.serverNames?.[0]) domainForLink = ss.realitySettings.serverNames[0];
|
||||
else if (ss.wsSettings?.headers?.Host) domainForLink = ss.wsSettings.headers.Host;
|
||||
else if (ss.tcpSettings?.header?.request?.headers?.Host?.[0]) domainForLink = ss.tcpSettings.header.request.headers.Host[0];
|
||||
} catch (e) { }
|
||||
const idOrPass = config.settings ? JSON.parse(config.settings).clients?.[0]?.id || JSON.parse(config.settings).clients?.[0]?.password : "";
|
||||
const fullLink = this.inboundBuilder.buildInboundLink(config, serverAddress, idOrPass, flagEmoji);
|
||||
const idOrPass = xuiConfig.settings ? JSON.parse(xuiConfig.settings).clients?.[0]?.id || JSON.parse(xuiConfig.settings).clients?.[0]?.password : "";
|
||||
const fullLink = this.inboundBuilder.buildInboundLink(xuiConfig, serverAddress, idOrPass, flagEmoji);
|
||||
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: xuiId,
|
||||
port: port,
|
||||
protocol: config.protocol,
|
||||
remark: config.remark,
|
||||
protocol: xuiConfig.protocol,
|
||||
remark: xuiConfig.remark,
|
||||
link: fullLink,
|
||||
subscription: sub
|
||||
});
|
||||
@@ -175,6 +226,7 @@ export class RotationService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pickDomain(list: Domain[]): string {
|
||||
return list[Math.floor(Math.random() * list.length)].name;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Body } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Setting } from './entities/setting.entity';
|
||||
import * as net from 'net';
|
||||
import * as dns from 'dns/promises';
|
||||
import { COUNTRIES } from './countries';
|
||||
import { XuiService } from 'src/xui/xui.service';
|
||||
@@ -32,8 +33,14 @@ export class SettingsController {
|
||||
try {
|
||||
const parsed = new URL(settings.xui_url);
|
||||
settings['xui_host'] = parsed.hostname;
|
||||
|
||||
const { address } = await dns.lookup(parsed.hostname);
|
||||
|
||||
let address = '';
|
||||
if (net.isIP(parsed.hostname) === 0) {
|
||||
const result = await dns.lookup(parsed.hostname);
|
||||
address = result.address;
|
||||
} else {
|
||||
address = parsed.hostname;
|
||||
}
|
||||
|
||||
settings['xui_ip'] = address;
|
||||
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, Min, Max, ArrayMinSize, ArrayMaxSize } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class InboundConfigDto {
|
||||
@IsString()
|
||||
type: string;
|
||||
|
||||
@IsOptional()
|
||||
port?: number | 'random';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sni?: string | 'random';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export class CreateSubscriptionDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InboundConfigDto)
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(20)
|
||||
@IsOptional()
|
||||
inboundsConfig?: InboundConfigDto[];
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export class Subscription {
|
||||
@Column({ default: true })
|
||||
isEnabled: boolean;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
inboundsConfig: any[];
|
||||
|
||||
@OneToMany(() => Inbound, (inbound) => inbound.subscription)
|
||||
inbounds: Inbound[];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, Param, Put } from '@nestjs/common';
|
||||
import { SubscriptionsService } from './subscriptions.service';
|
||||
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
|
||||
|
||||
@Controller('subscriptions')
|
||||
export class SubscriptionsController {
|
||||
@@ -11,8 +12,13 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body('name') name: string) {
|
||||
return this.subscriptionsService.create(name);
|
||||
create(@Body() createSubscriptionDto: CreateSubscriptionDto) {
|
||||
return this.subscriptionsService.create(createSubscriptionDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() updateSubscriptionDto: CreateSubscriptionDto) {
|
||||
return this.subscriptionsService.update(id, updateSubscriptionDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Subscription } from './entities/subscription.entity';
|
||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||
import { XuiService } from '../xui/xui.service';
|
||||
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,11 +18,32 @@ export class SubscriptionsService {
|
||||
return this.subRepo.find({ relations: ['inbounds'], order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async create(name: string) {
|
||||
async create(dto: CreateSubscriptionDto) {
|
||||
const sub = this.subRepo.create({
|
||||
name,
|
||||
name: dto.name,
|
||||
uuid: uuidv4(),
|
||||
inboundsConfig: dto.inboundsConfig || [],
|
||||
});
|
||||
|
||||
return this.subRepo.save(sub);
|
||||
}
|
||||
|
||||
async update(id: string, dto: CreateSubscriptionDto) {
|
||||
const sub = await this.subRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['inbounds']
|
||||
});
|
||||
|
||||
if (!sub) {
|
||||
throw new NotFoundException(`Subscription with ID ${id} not found`);
|
||||
}
|
||||
|
||||
sub.name = dto.name;
|
||||
|
||||
if (dto.inboundsConfig) {
|
||||
sub.inboundsConfig = dto.inboundsConfig;
|
||||
}
|
||||
|
||||
return this.subRepo.save(sub);
|
||||
}
|
||||
|
||||
@@ -30,7 +51,7 @@ export class SubscriptionsService {
|
||||
const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'] });
|
||||
if (!sub) return;
|
||||
|
||||
if (sub.inbounds) {
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
for (const inbound of sub.inbounds) {
|
||||
await this.xuiService.deleteInbound(inbound.xuiId);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ export class Tunnel {
|
||||
@Column()
|
||||
username: string;
|
||||
|
||||
@Column({ select: false })
|
||||
password: string;
|
||||
@Column({ select: false, nullable: true })
|
||||
password?: string;
|
||||
|
||||
@Column({ type: 'text', select: false, nullable: true })
|
||||
privateKey?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
domain: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ export class SshService {
|
||||
private readonly logger = new Logger(SshService.name);
|
||||
|
||||
async executeCommand(
|
||||
config: { host: string; port: number; username: string; password?: string },
|
||||
config: { host: string; port: number; username: string; password?: string, privateKey?: string },
|
||||
command: string
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -42,6 +42,7 @@ export class SshService {
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.privateKey,
|
||||
readyTimeout: 20000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,12 +31,13 @@ export class TunnelsService {
|
||||
async installScript(id: number) {
|
||||
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
|
||||
.addSelect('tunnel.password')
|
||||
.addSelect('tunnel.privateKey')
|
||||
.where('tunnel.id = :id', { id })
|
||||
.getOne();
|
||||
|
||||
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
|
||||
|
||||
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
||||
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_ip' } });
|
||||
|
||||
if (!hostSetting || !hostSetting.value) {
|
||||
throw new HttpException(
|
||||
@@ -55,7 +56,8 @@ export class TunnelsService {
|
||||
host: tunnel.ip,
|
||||
port: tunnel.sshPort,
|
||||
username: tunnel.username,
|
||||
password: tunnel.password
|
||||
password: tunnel.password,
|
||||
privateKey: tunnel.privateKey
|
||||
}, command);
|
||||
|
||||
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
|
||||
|
||||
Reference in New Issue
Block a user