Files
3dp-manager/server/src/xui/xui.service.ts
T
Den Piligrim 26f3bb2934 v2.2.0
2026-05-21 22:58:29 +03:00

351 lines
10 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios, { AxiosInstance, AxiosError } from 'axios';
import * as https from 'https';
import * as http from 'http';
import { Setting } from '../settings/entities/setting.entity';
import {
XuiResponse,
XuiCertResult,
XuiInboundRaw,
XuiDiscoveredNode,
} from './xui.types';
import { SessionService } from '../session/session.service';
import { Node, NodeAuthType } from '../nodes/entities/node.entity';
interface LoginResponse {
success: boolean;
}
@Injectable()
export class XuiService {
private readonly logger = new Logger(XuiService.name);
private api: AxiosInstance;
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private sessionService: SessionService,
) {
this.api = axios.create({
timeout: 15000,
proxy: false,
withCredentials: true,
});
this.api.interceptors.request.use((config) => {
const cookie = this.sessionService.getCookie();
if (cookie) {
config.headers['Cookie'] = cookie;
}
return config;
});
}
private getNodeBaseUrl(node: Node): string {
if (node.url) {
return node.url.replace(/\/+$/, '');
}
return `${node.protocol}://${node.host}:${node.port}`.replace(/\/+$/, '');
}
private async getSettings() {
const settings = await this.settingsRepo.find();
const config: Record<string, string> = {};
settings.forEach((s) => (config[s.key] = s.value));
return config;
}
private createApi(baseURL?: string): AxiosInstance {
return axios.create({
baseURL,
timeout: 15000,
proxy: false,
...this.getAgentConfig(baseURL),
withCredentials: true,
});
}
private getAgentConfig(baseURL?: string) {
if (!baseURL || baseURL.startsWith('https://')) {
return { httpsAgent: new https.Agent({ rejectUnauthorized: false }) };
}
return { httpAgent: new http.Agent() };
}
private async createAuthenticatedApi(node?: Node): Promise<AxiosInstance | null> {
if (!node) {
const success = await this.login();
return success ? this.api : null;
}
const api = this.createApi(this.getNodeBaseUrl(node));
if (node.authType === NodeAuthType.Token) {
if (!node.token) return null;
api.defaults.headers.common.Authorization = `Bearer ${node.token}`;
return api;
}
if (!node.login || !node.password) return null;
const res = await api.post<LoginResponse>('/login', {
username: node.login,
password: node.password,
});
if (!res.data?.success || !res.headers['set-cookie']) {
return null;
}
api.defaults.headers.common.Cookie = res.headers['set-cookie'].join('; ');
return api;
}
private parseVersion(headers: Record<string, unknown>, data: unknown): string | undefined {
const headerVersion = headers['x-ui-version'] || headers['x-3x-ui-version'];
if (typeof headerVersion === 'string') return headerVersion;
if (data && typeof data === 'object' && 'version' in data) {
const version = (data as { version?: unknown }).version;
return typeof version === 'string' ? version : undefined;
}
return undefined;
}
async login() {
try {
const config = await this.getSettings();
if (
!config['xui_url'] ||
!config['xui_login'] ||
!config['xui_password']
) {
this.logger.warn('Настройки 3x-ui не заполнены в БД');
return false;
}
this.logger.log(`Attempting login to 3x-ui: ${config['xui_url']}`);
this.api.defaults.baseURL = config['xui_url'];
const agentConfig = this.getAgentConfig(config['xui_url']);
this.api.defaults.httpAgent = agentConfig.httpAgent;
this.api.defaults.httpsAgent = agentConfig.httpsAgent;
const res = await this.api.post<LoginResponse>('/login', {
username: config['xui_login'],
password: config['xui_password'],
});
if (res.headers['set-cookie']) {
this.sessionService.setFromHeaders(res.headers['set-cookie']);
this.logger.log('3x-ui login successful');
return true;
} else {
this.logger.warn('3x-ui login failed: No cookie received');
}
} catch (e) {
const error = e as AxiosError;
this.logger.error(`3x-ui login error: ${error.message}`);
}
return false;
}
async addInbound(
inboundConfig: { port: number; [key: string]: unknown } | XuiInboundRaw,
node?: Node,
): Promise<number | null> {
let attempts = 0;
const maxAttempts = 3;
this.logger.log(`Adding inbound on port ${inboundConfig.port}`);
while (attempts < maxAttempts) {
attempts++;
try {
const api = await this.createAuthenticatedApi(node);
if (!api) {
this.logger.error('3x-ui authentication failed before addInbound');
return null;
}
const res = await api.post<XuiResponse<{ id: number }>>(
'/panel/api/inbounds/add',
inboundConfig,
);
if (res.data?.success) {
this.logger.log(
`Inbound created successfully with ID: ${res.data.obj.id}`,
);
return res.data.obj.id;
} else {
const msg = res.data?.msg || '';
if (
msg.toLowerCase().includes('port') &&
msg.toLowerCase().includes('exists')
) {
this.logger.warn(
`Попытка ${attempts}/${maxAttempts}: Порт ${inboundConfig.port} занят. Генерируем новый...`,
);
inboundConfig.port = Math.floor(
Math.random() * (60000 - 10000 + 1) + 10000,
);
} else {
this.logger.error(`3x-ui отклонил создание: ${msg}`);
return null;
}
}
} catch (e) {
const error = e as AxiosError;
if (error.response?.status === 401) {
this.logger.log('Сессия истекла, пробуем релогин...');
if (!node && (await this.login())) {
return this.addInbound(inboundConfig);
}
}
this.logger.error(
`Ошибка сети/валидации при добавлении инбаунда: ${error.message}`,
);
return null;
}
}
this.logger.error(
`Не удалось создать инбаунд после ${maxAttempts} попыток смены порта.`,
);
return null;
}
async deleteInbound(id: number, node?: Node): Promise<boolean> {
if (!id || id <= 0) {
this.logger.debug(`Skipping 3x-ui inbound deletion for non-remote id: ${id}`);
return true;
}
try {
const api = await this.createAuthenticatedApi(node);
if (!api) {
this.logger.error(`3x-ui authentication failed before deleting inbound ${id}`);
return false;
}
const res = await api.post<XuiResponse<unknown>>(
`/panel/api/inbounds/del/${id}`,
);
if (!res.data?.success) {
this.logger.error(
`3x-ui rejected inbound deletion ${id}: ${res.data?.msg || 'unknown error'}`,
);
return false;
}
this.logger.debug(`Inbound ${id} deleted`);
return true;
} catch (e) {
const error = e as AxiosError;
this.logger.error(`Ошибка удаления инбаунда ${id}: ${error.message}`);
}
return false;
}
async checkConnection(
url: string,
username: string,
pass: string,
): Promise<boolean> {
try {
this.logger.log(`Checking connection to 3x-ui: ${url}`);
const tempApi = axios.create({
baseURL: url,
timeout: 5000,
proxy: false,
...this.getAgentConfig(url),
withCredentials: true,
});
const res = await tempApi.post<LoginResponse>('/login', {
username: username,
password: pass,
});
if (res.headers['set-cookie'] && res.data?.success) {
this.logger.log(`Connection to 3x-ui successful: ${url}`);
return true;
} else {
this.logger.warn(
`Connection failed: Invalid credentials or no cookie received`,
);
}
} catch (error) {
const axiosError = error as AxiosError;
this.logger.error(
`Connection error: ${axiosError.message} (URL: ${url})`,
);
}
return false;
}
async checkNodeConnection(
node: Node,
): Promise<{ success: boolean; version?: string }> {
try {
const api = await this.createAuthenticatedApi(node);
if (!api) return { success: false };
const res = await api.get('/panel/api/inbounds/list');
return {
success: true,
version: this.parseVersion(res.headers as Record<string, unknown>, res.data),
};
} catch (error) {
const axiosError = error as AxiosError;
this.logger.error(
`Node connection error: ${axiosError.message} (${node.name})`,
);
return { success: false };
}
}
async getNewX25519Cert(node?: Node): Promise<XuiCertResult | null> {
try {
const api = await this.createAuthenticatedApi(node);
if (!api) return null;
const res = await api.get<XuiResponse<XuiCertResult>>(
'/panel/api/server/getNewX25519Cert',
);
if (res.data?.success && res.data.obj) return res.data.obj;
} catch {
this.logger.error('Ошибка получения ключей Reality');
}
return null;
}
async getNodes(node: Node): Promise<XuiDiscoveredNode[]> {
try {
const api = await this.createAuthenticatedApi(node);
if (!api) return [];
const res = await api.get<XuiResponse<XuiDiscoveredNode[]>>(
'/panel/api/nodes/list',
);
if (res.data?.success && Array.isArray(res.data.obj)) {
return res.data.obj;
}
} catch (error) {
const axiosError = error as AxiosError;
this.logger.warn(`Node sync is not available: ${axiosError.message}`);
}
return [];
}
}