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

436 lines
12 KiB
TypeScript

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<Node>,
@InjectRepository(Subscription)
private readonly subscriptionsRepo: Repository<Subscription>,
@InjectRepository(Tunnel)
private readonly tunnelsRepo: Repository<Tunnel>,
@InjectRepository(Inbound)
private readonly inboundsRepo: Repository<Inbound>,
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<GeoResult | undefined> {
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(/\/+$/, '');
}
}