update
This commit is contained in:
@@ -2,7 +2,7 @@ name: Docker Build & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [ "main", "dp-new-release" ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. Бот генерирует по заданному интервалу 10 подключений с разными параметрами:
|
||||
|
||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||
- порты: `443`, `8443` (фиксированные) и случайные из диапазона `10000-60000`;
|
||||
- транспорт: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI берутся из белого списка доменов (whitelist); можно использовать свой список.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${location.protocol}//${location.hostname}:3000/api`,
|
||||
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -18,6 +18,7 @@ interface Subscription {
|
||||
name: string;
|
||||
uuid: string;
|
||||
inbounds: any[];
|
||||
inboundsConfig?: any[];
|
||||
}
|
||||
|
||||
interface Tunnel {
|
||||
@@ -33,6 +34,7 @@ interface InboundConfigUI {
|
||||
type: string;
|
||||
port: string;
|
||||
sni: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
interface Domain { id: number; name: string; }
|
||||
@@ -46,6 +48,7 @@ const CONNECTION_OPTIONS = [
|
||||
'vmess-tcp',
|
||||
'shadowsocks-tcp',
|
||||
'trojan-tcp-reality',
|
||||
'custom',
|
||||
];
|
||||
|
||||
const patchLink = function (link: string, newHost: string): string {
|
||||
@@ -124,16 +127,16 @@ export default function SubscriptionsPage() {
|
||||
setEditingId(null);
|
||||
setName('');
|
||||
setInbounds([
|
||||
{ id: generateId(), type: 'hysteria2-udp', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vless-ws', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random' },
|
||||
{ id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random' },
|
||||
{ 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);
|
||||
@@ -143,79 +146,58 @@ export default function SubscriptionsPage() {
|
||||
setEditingId(sub.id);
|
||||
setName(sub.name);
|
||||
|
||||
// Пытаемся маппить существующие инбаунды, или даем дефолтные
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
setInbounds(sub.inbounds.map(i => ({
|
||||
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'
|
||||
sni: i.sni || 'random',
|
||||
link: i.link || ''
|
||||
})));
|
||||
} else {
|
||||
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' }]);
|
||||
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
|
||||
}
|
||||
|
||||
setPortErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
// Проверка порта
|
||||
const handlePortCheck = async (portValue: string, id: string) => {
|
||||
if (portValue === 'random' || portValue.trim() === '') {
|
||||
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
|
||||
return;
|
||||
}
|
||||
const portNum = parseInt(portValue);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||
setPortErrors(prev => ({ ...prev, [id]: 'Некорректный порт' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/check-port/${portNum}`);
|
||||
if (!data.isFree) {
|
||||
setPortErrors(prev => ({ ...prev, [id]: 'Порт занят' }));
|
||||
} else {
|
||||
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка проверки порта', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
|
||||
setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
|
||||
if (field === 'port') {
|
||||
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' }]);
|
||||
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'
|
||||
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;
|
||||
});
|
||||
}
|
||||
]);
|
||||
setPortErrors({});
|
||||
}
|
||||
};
|
||||
} else {
|
||||
setInbounds([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: 'vless-tcp-reality',
|
||||
port: 'random',
|
||||
sni: 'random',
|
||||
link: ''
|
||||
}
|
||||
]);
|
||||
setPortErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (Object.keys(portErrors).length > 0) {
|
||||
@@ -229,11 +211,16 @@ const removeInbound = (id?: string) => {
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
inboundsConfig: inbounds.map(i => ({
|
||||
type: i.type,
|
||||
port: i.port === 'random' ? 'random' : parseInt(i.port),
|
||||
sni: i.sni
|
||||
}))
|
||||
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 {
|
||||
@@ -421,31 +408,46 @@ const removeInbound = (id?: string) => {
|
||||
</Select>
|
||||
</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)}
|
||||
onBlur={(e) => handlePortCheck(e.target.value, inb.id)}
|
||||
error={!!portErrors[inb.id]}
|
||||
helperText={portErrors[inb.id] || ""}
|
||||
sx={{ width: 140 }}
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
TableHead, TableRow, IconButton, Dialog, DialogTitle,
|
||||
DialogContent, TextField, DialogActions, Chip, CircularProgress,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
useMediaQuery,
|
||||
FormControl,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio
|
||||
} from '@mui/material';
|
||||
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
@@ -24,9 +28,10 @@ export default function TunnelsPage() {
|
||||
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(); }, []);
|
||||
@@ -39,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();
|
||||
};
|
||||
|
||||
@@ -149,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>
|
||||
|
||||
+58
-1
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
#################################
|
||||
PROJECT_DIR="/opt/3dp-manager"
|
||||
DOCKER_USER="denpiligrim"
|
||||
DOCKER_TAG="main"
|
||||
DOCKER_TAG="dp-new-release"
|
||||
IMAGE_SERVER="ghcr.io/${DOCKER_USER}/3dp-manager-server:${DOCKER_TAG}"
|
||||
IMAGE_CLIENT="ghcr.io/${DOCKER_USER}/3dp-manager-client:${DOCKER_TAG}"
|
||||
|
||||
@@ -400,6 +400,63 @@ networks:
|
||||
EOF
|
||||
fi
|
||||
|
||||
#################################
|
||||
# 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
|
||||
|
||||
#################################
|
||||
# ЗАПУСК
|
||||
#################################
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -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,
|
||||
@@ -478,4 +479,43 @@ export class InboundBuilderService {
|
||||
`#${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(/auth:\s*['"]?([^'"\n]+)['"]?/);
|
||||
if (authMatch) auth = authMatch[1];
|
||||
|
||||
const obfsMatch = fileContent.match(/type:\s*['"]?(salamander)['"]?/);
|
||||
if (obfsMatch) obfs = obfsMatch[1];
|
||||
|
||||
const passMatch = fileContent.match(/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', sni);
|
||||
params.set('obfs', obfs);
|
||||
params.set('obfs-password', obfsPass);
|
||||
|
||||
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${this.flag}%20${encodeURIComponent(remark)}`;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const CONNECTION_TYPES = [
|
||||
'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,105 @@ 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();
|
||||
|
||||
// Определяем SNI
|
||||
let sni = 'unknown';
|
||||
if (type !== 'custom' && type !== 'vmess-tcp' && type !== 'shadowsocks-tcp') {
|
||||
sni = (!config.sni || config.sni === 'random') ? this.pickDomain(domains) : config.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;
|
||||
}
|
||||
|
||||
// === 2. Обработка Hysteria2 ===
|
||||
if (type === 'hysteria2-udp') {
|
||||
const link = this.inboundBuilder.buildHysteria2Link(serverAddress, sni, 'Hysteria2');
|
||||
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 +228,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}`);
|
||||
|
||||
@@ -11,6 +11,10 @@ export class InboundConfigDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sni?: string | 'random';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export class CreateSubscriptionDto {
|
||||
|
||||
@@ -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,6 +31,7 @@ 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();
|
||||
|
||||
@@ -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