351 lines
10 KiB
TypeScript
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 [];
|
|
}
|
|
}
|