v2.2.0
This commit is contained in:
@@ -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(/\/+$/, '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user