import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateNodeDto, UpdateNodeDto } from './dto/node.dto'; import { Node, NodeAuthType, NodeProtocol } from './entities/node.entity'; import { XuiService } from '../xui/xui.service'; import { Subscription } from '../subscriptions/entities/subscription.entity'; import { Tunnel } from '../tunnels/entities/tunnel.entity'; import { Inbound } from '../inbounds/entities/inbound.entity'; import * as dns from 'dns/promises'; import * as net from 'net'; import { COUNTRIES } from '../settings/countries'; type GeoResult = { ip: string; country?: string; countryCode?: string; flag?: string; }; const getDomainFromHost = (host?: string) => host && net.isIP(host) === 0 ? host : undefined; @Injectable() export class NodesService { constructor( @InjectRepository(Node) private readonly nodesRepo: Repository, @InjectRepository(Subscription) private readonly subscriptionsRepo: Repository, @InjectRepository(Tunnel) private readonly tunnelsRepo: Repository, @InjectRepository(Inbound) private readonly inboundsRepo: Repository, private readonly xuiService: XuiService, ) {} findAll() { return this.nodesRepo.find({ order: { isMain: 'DESC', createdAt: 'DESC' } }); } async findOneWithSecrets(id: string) { const node = await this.nodesRepo .createQueryBuilder('node') .addSelect('node.password') .addSelect('node.token') .where('node.id = :id', { id }) .getOne(); if (!node) { throw new NotFoundException('Node not found'); } return node; } async getDefaultNode() { return this.nodesRepo .createQueryBuilder('node') .addSelect('node.password') .addSelect('node.token') .where('node.isMain = :isMain', { isMain: true }) .getOne(); } async create(dto: CreateNodeDto) { this.assertCredentials(dto); const resolved = await this.resolveNodeLocation(dto.url, dto.flag, dto.ip); const node = this.nodesRepo.create({ ...dto, url: this.normalizeUrl(dto.url), host: resolved.host, domain: dto.domain || resolved.domain, port: resolved.port, protocol: resolved.protocol, ip: resolved.ip, flag: resolved.flag, isMain: dto.isMain ?? false, }); if ((await this.nodesRepo.count()) === 0) { node.isMain = true; } if (node.isMain) { await this.clearMainNode(); } return this.nodesRepo.save(node); } async update(id: string, dto: UpdateNodeDto) { const node = await this.findOneWithSecrets(id); const nextAuthType = dto.authType ?? node.authType; if (nextAuthType === NodeAuthType.Password) { const login = dto.login ?? node.login; const password = dto.password ?? node.password; if (!login || !password) { throw new BadRequestException('Login and password are required'); } } if (nextAuthType === NodeAuthType.Token) { const token = dto.token ?? node.token; if (!token) { throw new BadRequestException('Token is required'); } } Object.assign(node, dto); if (dto.url) { node.url = this.normalizeUrl(dto.url); const resolved = await this.resolveNodeLocation( dto.url, dto.flag ?? node.flag, dto.ip, ); node.host = resolved.host; node.domain = dto.domain || resolved.domain; node.port = resolved.port; node.protocol = resolved.protocol; node.ip = resolved.ip; node.flag = resolved.flag; } else { if (dto.domain !== undefined) node.domain = dto.domain; if (dto.ip) node.ip = dto.ip; if (dto.flag) node.flag = dto.flag; } if (dto.isMain) { await this.clearMainNode(id); node.isMain = true; } return this.nodesRepo.save(node); } async remove(id: string) { const node = await this.findOneWithSecrets(id); const nodeCount = await this.nodesRepo.count(); await this.cleanupNodeDependencies(node, nodeCount === 1); await this.nodesRepo.remove(node); const main = await this.getDefaultNode(); if (!main) { const fallback = await this.nodesRepo.findOne({ where: {}, order: { createdAt: 'DESC' }, }); if (fallback) { fallback.isMain = true; await this.nodesRepo.save(fallback); } } return { success: true }; } private async cleanupNodeDependencies(node: Node, isLastNode: boolean) { await this.deleteNodeInbounds(node); const id = node.id; if (isLastNode) { await this.subscriptionsRepo.createQueryBuilder().delete().execute(); await this.tunnelsRepo.createQueryBuilder().delete().execute(); return; } await this.tunnelsRepo.delete({ nodeId: id }); await this.inboundsRepo.delete({ nodeId: id }); const subscriptions = await this.subscriptionsRepo.find({ where: [{ nodeId: id }], }); for (const sub of subscriptions) { sub.nodeId = undefined; sub.node = undefined; await this.subscriptionsRepo.save(sub); } const configuredSubscriptions = await this.subscriptionsRepo.find(); for (const sub of configuredSubscriptions) { const config = sub.inboundsConfig || []; const nextConfig = config.map((item) => { if (item.nodeId !== id) return item; const { nodeId: _nodeId, relayServerId: _relayServerId, ...rest } = item; return rest; }); if (JSON.stringify(nextConfig) !== JSON.stringify(config)) { sub.inboundsConfig = nextConfig; await this.subscriptionsRepo.save(sub); } } } private async deleteNodeInbounds(node: Node) { const inbounds = await this.inboundsRepo.find({ where: { nodeId: node.id }, }); for (const inbound of inbounds) { if (inbound.xuiId && inbound.xuiId > 0) { const isDeleted = await this.xuiService.deleteInbound( inbound.xuiId, node, ); if (!isDeleted) { throw new BadRequestException( `Failed to delete inbound ${inbound.xuiId} from 3x-ui`, ); } } } } async setMain(id: string) { const node = await this.findOneWithSecrets(id); await this.clearMainNode(id); node.isMain = true; return this.nodesRepo.save(node); } async checkConnection(id: string) { const node = await this.findOneWithSecrets(id); const status = await this.xuiService.checkNodeConnection(node); return { success: status.success, version: status.version }; } async syncFromMain() { const main = await this.getDefaultNode(); if (!main) { throw new BadRequestException('Main node is not configured'); } const discovered = await this.xuiService.getNodes(main); const synced: Node[] = []; for (const item of discovered) { if (!item.host || !item.port) { continue; } const url = `${item.protocol}://${item.host}:${item.port}`.replace( /\/+$/, '', ); const existing = await this.nodesRepo.findOne({ where: { url }, }); if (existing) { existing.name = item.name || existing.name; existing.version = item.version || existing.version; synced.push(await this.nodesRepo.save(existing)); continue; } synced.push( await this.nodesRepo.save( this.nodesRepo.create({ name: item.name || item.host, url, host: item.host, domain: getDomainFromHost(item.host), port: item.port, ip: await this.resolveIp(item.host), flag: ( await this.lookupGeo(await this.resolveIp(item.host)) )?.flag, protocol: item.protocol === NodeProtocol.Http ? NodeProtocol.Http : NodeProtocol.Https, authType: main.authType, login: main.login, password: main.password, token: main.token, version: item.version, isMain: false, }), ), ); } return { success: true, count: synced.length, nodes: synced }; } private assertCredentials(dto: CreateNodeDto) { if (dto.authType === NodeAuthType.Password && (!dto.login || !dto.password)) { throw new BadRequestException('Login and password are required'); } if (dto.authType === NodeAuthType.Token && !dto.token) { throw new BadRequestException('Token is required'); } } private async clearMainNode(exceptId?: string) { const qb = this.nodesRepo .createQueryBuilder() .update(Node) .set({ isMain: false }) .where('isMain = :isMain', { isMain: true }); if (exceptId) { qb.andWhere('id != :exceptId', { exceptId }); } await qb.execute(); } async checkPayload(dto: CreateNodeDto) { this.assertCredentials(dto); const node = this.nodesRepo.create({ ...dto, url: this.normalizeUrl(dto.url), }); const status = await this.xuiService.checkNodeConnection(node); return { success: status.success, version: status.version }; } async detectLocation(url: string) { const resolved = await this.resolveNodeLocation(url); return { ip: resolved.ip, host: resolved.host, domain: resolved.domain, port: resolved.port, protocol: resolved.protocol, flag: resolved.flag, country: resolved.country, countryCode: resolved.countryCode, }; } private async resolveNodeLocation( url: string, preferredFlag?: string, preferredIp?: string, ) { const normalized = this.normalizeUrl(url); const parsed = this.parseUrl(normalized); const ip = preferredIp || (await this.resolveIp(parsed.host)); const geo = ip ? await this.lookupGeo(ip) : undefined; return { ...parsed, domain: getDomainFromHost(parsed.host), ip, country: geo?.country, countryCode: geo?.countryCode, flag: preferredFlag || geo?.flag, }; } private parseUrl(url: string) { try { const parsed = new URL(url); return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined, protocol: parsed.protocol.replace(':', '') === NodeProtocol.Http ? NodeProtocol.Http : NodeProtocol.Https, }; } catch { return { host: url, port: undefined, protocol: NodeProtocol.Https }; } } private async resolveIp(host?: string) { if (!host || host === 'localhost') return undefined; if (net.isIP(host) !== 0) return host; try { const result = await dns.lookup(host); return result.address; } catch { return undefined; } } private async lookupGeo(ip?: string): Promise { if (!ip || ip === '127.0.0.1') return undefined; const fromCode = (countryCode?: string, country?: string) => { const countryInfo = COUNTRIES.find((c) => c.code === countryCode); return countryInfo ? { ip, country: countryInfo.name, countryCode, flag: countryInfo.emoji } : { ip, country, countryCode }; }; try { const res = await fetch(`https://ipwho.is/${ip}`); const data = (await res.json()) as { success?: boolean; country?: string; country_code?: string; }; if (data.success !== false) { return fromCode(data.country_code, data.country); } } catch { // Fallback below. } try { const res = await fetch(`http://ip-api.com/json/${ip}`); const data = (await res.json()) as { status?: string; country?: string; countryCode?: string; }; if (data.status === 'success') { return fromCode(data.countryCode, data.country); } } catch { return undefined; } } private normalizeUrl(url: string) { return url.trim().replace(/\/+$/, ''); } }