436 lines
12 KiB
TypeScript
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(/\/+$/, '');
|
|
}
|
|
}
|