This commit is contained in:
Den Piligrim
2026-05-21 22:58:29 +03:00
parent 962544820f
commit 26f3bb2934
48 changed files with 2877 additions and 1170 deletions
+216 -4
View File
@@ -8,12 +8,34 @@ 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,
) {}
@@ -47,10 +69,17 @@ export class NodesService {
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,
});
@@ -87,6 +116,21 @@ export class NodesService {
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) {
@@ -99,14 +143,17 @@ export class NodesService {
async remove(id: string) {
const node = await this.findOneWithSecrets(id);
if (node.isMain && (await this.nodesRepo.count()) > 1) {
throw new BadRequestException('Select another main node before deleting');
}
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: {} });
const fallback = await this.nodesRepo.findOne({
where: {},
order: { createdAt: 'DESC' },
});
if (fallback) {
fallback.isMain = true;
await this.nodesRepo.save(fallback);
@@ -116,6 +163,65 @@ export class NodesService {
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);
@@ -164,7 +270,12 @@ export class NodesService {
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
@@ -217,6 +328,107 @@ export class NodesService {
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(/\/+$/, '');
}