v2.2.0
This commit is contained in:
@@ -422,38 +422,87 @@ export class InboundBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
buildHysteria2Inbound(params: { port: number; uuid: string; sni: string }) {
|
||||
buildHysteria2Inbound(params: {
|
||||
port: number;
|
||||
uuid: string;
|
||||
sni: string;
|
||||
certificateFile?: string;
|
||||
keyFile?: string;
|
||||
}) {
|
||||
const { port, uuid, sni } = params;
|
||||
const certificateFile =
|
||||
params.certificateFile || `/root/cert/${sni}/fullchain.pem`;
|
||||
const keyFile =
|
||||
params.keyFile || `/root/cert/${sni}/privkey.pem`;
|
||||
const obfsPassword = crypto.randomBytes(8).toString('hex');
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
protocol: 'hysteria2',
|
||||
protocol: 'hysteria',
|
||||
remark: 'hysteria2-udp',
|
||||
settings: JSON.stringify({
|
||||
clients: [
|
||||
{
|
||||
password: uuid,
|
||||
auth: uuid,
|
||||
email: uuid,
|
||||
enable: true,
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
reset: 0,
|
||||
},
|
||||
],
|
||||
masquerade: `${sni}:443`,
|
||||
version: 2,
|
||||
}),
|
||||
streamSettings: JSON.stringify({
|
||||
network: 'udp',
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
finalmask: {
|
||||
udp: [
|
||||
{
|
||||
settings: {
|
||||
password: obfsPassword,
|
||||
},
|
||||
type: 'salamander',
|
||||
},
|
||||
],
|
||||
},
|
||||
hysteriaSettings: {
|
||||
auth: uuid,
|
||||
masquerade: {
|
||||
content: '',
|
||||
dir: '',
|
||||
headers: {},
|
||||
insecure: true,
|
||||
rewriteHost: false,
|
||||
statusCode: 0,
|
||||
type: 'proxy',
|
||||
url: 'https://google.com',
|
||||
},
|
||||
udpIdleTimeout: 60,
|
||||
version: 2,
|
||||
},
|
||||
tlsSettings: {
|
||||
serverName: sni,
|
||||
alpn: ['h3'],
|
||||
certificates: [
|
||||
{
|
||||
buildChain: false,
|
||||
certificateFile,
|
||||
keyFile,
|
||||
oneTimeLoading: false,
|
||||
usage: 'encipherment',
|
||||
},
|
||||
],
|
||||
cipherSuites: '',
|
||||
disableSystemRoot: false,
|
||||
echForceQuery: 'none',
|
||||
echServerKeys: '',
|
||||
enableSessionResumption: false,
|
||||
maxVersion: '1.3',
|
||||
minVersion: '1.2',
|
||||
rejectUnknownSni: false,
|
||||
},
|
||||
}),
|
||||
sniffing: JSON.stringify({
|
||||
enabled: false,
|
||||
destOverride: ['http', 'tls', 'quic'],
|
||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
}),
|
||||
@@ -487,6 +536,7 @@ export class InboundBuilderService {
|
||||
case 'trojan':
|
||||
link = this.buildTrojanLink(inbound, sni, idOrPass);
|
||||
break;
|
||||
case 'hysteria':
|
||||
case 'hysteria2':
|
||||
link = this.buildHysteria2PanelLink(inbound, sni, idOrPass, flagEmoji);
|
||||
break;
|
||||
@@ -645,13 +695,38 @@ export class InboundBuilderService {
|
||||
) {
|
||||
const stream = JSON.parse(inbound.streamSettings) as {
|
||||
tlsSettings?: { serverName?: string };
|
||||
finalmask?: { udp?: Array<{ type?: string; settings?: { password?: string } }> };
|
||||
};
|
||||
const settings = JSON.parse(inbound.settings) as {
|
||||
clients?: Array<{ auth?: string; password?: string }>;
|
||||
};
|
||||
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
|
||||
const finalmask = stream.finalmask?.udp?.[0];
|
||||
const params = new URLSearchParams();
|
||||
params.set('insecure', '0');
|
||||
params.set('insecure', '1');
|
||||
params.set('security', 'tls');
|
||||
params.set('fp', 'chrome');
|
||||
params.set('alpn', 'h3');
|
||||
|
||||
const fmConfig = {
|
||||
udp: [
|
||||
{
|
||||
type: finalmask.type,
|
||||
settings: {
|
||||
password: finalmask.settings.password,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
params.set('fm', JSON.stringify(fmConfig));
|
||||
params.set('sni', stream.tlsSettings?.serverName || serverAddress);
|
||||
if (finalmask?.type) params.set('obfs', finalmask.type);
|
||||
if (finalmask?.settings?.password) {
|
||||
params.set('obfs-password', finalmask.settings.password);
|
||||
}
|
||||
|
||||
return (
|
||||
`hy2://${password}@${serverAddress}:${inbound.port}/?${params.toString()}` +
|
||||
`hy2://${auth}@${serverAddress}:${inbound.port}/?${params.toString()}` +
|
||||
`#${flagEmoji}%20${encodeURIComponent(inbound.remark || '')}`
|
||||
);
|
||||
}
|
||||
@@ -694,11 +769,28 @@ export class InboundBuilderService {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('insecure', '0');
|
||||
params.set('insecure', '1');
|
||||
params.set('security', 'tls');
|
||||
params.set('fp', 'chrome');
|
||||
params.set('alpn', 'h3');
|
||||
|
||||
params.set('sni', serverAddress);
|
||||
|
||||
params.set('obfs', obfs);
|
||||
params.set('obfs-password', obfsPass);
|
||||
|
||||
const fmConfig = {
|
||||
udp: [
|
||||
{
|
||||
type: obfs,
|
||||
settings: {
|
||||
password: obfsPass,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
params.set('fm', JSON.stringify(fmConfig));
|
||||
|
||||
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddNodeIpFlagAndInboundLabels1770000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddNodeIpFlagAndInboundLabels1770000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "node"
|
||||
ADD COLUMN IF NOT EXISTS "ip" character varying,
|
||||
ADD COLUMN IF NOT EXISTS "flag" character varying
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "node" DROP COLUMN IF EXISTS "flag"`);
|
||||
await queryRunner.query(`ALTER TABLE "node" DROP COLUMN IF EXISTS "ip"`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIP,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
@@ -17,6 +18,18 @@ export class CreateNodeDto {
|
||||
@IsString()
|
||||
url: string;
|
||||
|
||||
@IsIP()
|
||||
@IsOptional()
|
||||
ip?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
domain?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
flag?: string;
|
||||
|
||||
@IsEnum(NodeAuthType)
|
||||
authType: NodeAuthType;
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ export class Node {
|
||||
@Column({ nullable: true })
|
||||
host?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
domain?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ip?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
flag?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
port?: number;
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ export class NodesController {
|
||||
return this.nodesService.checkPayload(dto);
|
||||
}
|
||||
|
||||
@Post('detect-location')
|
||||
detectLocation(@Body() body: { url: string }) {
|
||||
return this.nodesService.detectLocation(body.url);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateNodeDto) {
|
||||
return this.nodesService.update(id, dto);
|
||||
|
||||
@@ -4,9 +4,15 @@ import { Node } from './entities/node.entity';
|
||||
import { NodesController } from './nodes.controller';
|
||||
import { NodesService } from './nodes.service';
|
||||
import { XuiModule } from '../xui/xui.module';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Node]), XuiModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Node, Subscription, Tunnel, Inbound]),
|
||||
XuiModule,
|
||||
],
|
||||
controllers: [NodesController],
|
||||
providers: [NodesService],
|
||||
exports: [NodesService, TypeOrmModule],
|
||||
|
||||
@@ -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(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export class RotationService implements OnModuleInit {
|
||||
isEnabled: true,
|
||||
isAutoRotationEnabled: true,
|
||||
},
|
||||
relations: ['inbounds', 'node', 'relayServer'],
|
||||
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||
});
|
||||
if (subscriptions.length === 0) {
|
||||
return { success: false, message: 'Нет активных подписок для ротации' };
|
||||
@@ -156,7 +156,13 @@ export class RotationService implements OnModuleInit {
|
||||
}
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
await this.rotateSubscription(sub, domains, defaultNode);
|
||||
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||
if (!rotated) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete old inbounds',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Ротация завершена.');
|
||||
@@ -174,7 +180,16 @@ export class RotationService implements OnModuleInit {
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
for (const inbound of sub.inbounds) {
|
||||
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||
await this.xuiService.deleteInbound(inbound.xuiId, inbound.node);
|
||||
const isDeleted = await this.xuiService.deleteInbound(
|
||||
inbound.xuiId,
|
||||
await this.resolveInboundNode(inbound),
|
||||
);
|
||||
if (!isDeleted) {
|
||||
this.logger.error(
|
||||
`Failed to delete old inbound ${inbound.xuiId}; rotation for subscription ${sub.id} is aborted`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await this.inboundRepo.delete(inbound.id);
|
||||
}
|
||||
@@ -186,7 +201,7 @@ export class RotationService implements OnModuleInit {
|
||||
this.logger.error(
|
||||
'Не удалось получить Reality ключи, пропускаем подписку',
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const usedPorts = new Set<number>();
|
||||
@@ -196,7 +211,7 @@ export class RotationService implements OnModuleInit {
|
||||
const flag = await this.settingRepo.findOne({
|
||||
where: { key: 'xui_geo_flag' },
|
||||
});
|
||||
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||
const defaultFlagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||
|
||||
// Получаем конфиг или пустой массив
|
||||
const inboundsConfig = sub.inboundsConfig || [];
|
||||
@@ -209,15 +224,20 @@ export class RotationService implements OnModuleInit {
|
||||
sub.node,
|
||||
defaultNode,
|
||||
);
|
||||
const relayServer = await this.resolveRelay(
|
||||
const resolvedRelay = await this.resolveRelay(
|
||||
config.relayServerId,
|
||||
sub.relayServer,
|
||||
);
|
||||
const relayServer =
|
||||
resolvedRelay && this.isRelayAvailableForNode(resolvedRelay, targetNode)
|
||||
? resolvedRelay
|
||||
: undefined;
|
||||
const targetAddress =
|
||||
relayServer?.domain ||
|
||||
relayServer?.ip ||
|
||||
this.getNodeAddress(targetNode) ||
|
||||
serverAddress;
|
||||
const flagEmoji = config.flag || targetNode?.flag || defaultFlagEmoji;
|
||||
|
||||
let sni = '';
|
||||
|
||||
@@ -250,33 +270,39 @@ export class RotationService implements OnModuleInit {
|
||||
}
|
||||
usedPorts.add(port);
|
||||
|
||||
const hysteriaSni = this.getNodeAddress(targetNode) || serverAddress;
|
||||
const hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
|
||||
port,
|
||||
uuid,
|
||||
sni,
|
||||
sni: hysteriaSni,
|
||||
certificateFile: config.certificateFile,
|
||||
keyFile: config.keyFile,
|
||||
});
|
||||
if (config.name?.trim()) {
|
||||
hysteriaConfig.remark = config.name.trim();
|
||||
}
|
||||
const xuiId = await this.xuiService.addInbound(
|
||||
hysteriaConfig,
|
||||
targetNode,
|
||||
);
|
||||
const link =
|
||||
xuiId && hysteriaConfig
|
||||
? this.inboundBuilder.buildInboundLink(
|
||||
hysteriaConfig,
|
||||
targetAddress,
|
||||
uuid,
|
||||
flagEmoji,
|
||||
)
|
||||
: this.inboundBuilder.buildHysteria2Link(
|
||||
targetAddress,
|
||||
sni,
|
||||
flagEmoji + '%20hysteria2-udp',
|
||||
);
|
||||
if (!xuiId) {
|
||||
this.logger.warn(
|
||||
'Hysteria2 inbound was not created by 3x-ui; skipping subscription link for this inbound',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const link = this.inboundBuilder.buildInboundLink(
|
||||
hysteriaConfig,
|
||||
targetAddress,
|
||||
uuid,
|
||||
flagEmoji,
|
||||
);
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: xuiId || 0,
|
||||
xuiId,
|
||||
port,
|
||||
protocol: 'hysteria2',
|
||||
remark: 'hysteria2-udp',
|
||||
remark: hysteriaConfig.remark,
|
||||
link: link,
|
||||
subscription: sub,
|
||||
node: targetNode,
|
||||
@@ -350,6 +376,10 @@ export class RotationService implements OnModuleInit {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.name?.trim()) {
|
||||
xuiConfig.remark = config.name.trim();
|
||||
}
|
||||
|
||||
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
|
||||
|
||||
if (xuiId && xuiConfig) {
|
||||
@@ -379,6 +409,8 @@ export class RotationService implements OnModuleInit {
|
||||
await this.inboundRepo.save(newInbound);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private pickDomain(list: Domain[]): string {
|
||||
@@ -413,7 +445,7 @@ export class RotationService implements OnModuleInit {
|
||||
|
||||
const sub = await this.subRepo.findOne({
|
||||
where: { id: subscriptionId },
|
||||
relations: ['inbounds', 'node', 'relayServer'],
|
||||
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||
});
|
||||
|
||||
if (!sub) {
|
||||
@@ -437,7 +469,13 @@ export class RotationService implements OnModuleInit {
|
||||
return { success: false, message: 'Список доменов пуст!' };
|
||||
}
|
||||
|
||||
await this.rotateSubscription(sub, domains, defaultNode);
|
||||
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||
if (!rotated) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete old inbounds',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
|
||||
return { success: true, message: 'Ротация успешно выполнена' };
|
||||
@@ -472,18 +510,33 @@ export class RotationService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
|
||||
if (!relayServerId) return subscriptionRelay ?? undefined;
|
||||
private async resolveInboundNode(inbound: Inbound) {
|
||||
if (!inbound.nodeId) return inbound.node;
|
||||
|
||||
return (
|
||||
(await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ??
|
||||
subscriptionRelay ??
|
||||
undefined
|
||||
(await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||
.getOne()) ?? inbound.node
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
|
||||
if (!relayServerId) return subscriptionRelay ?? undefined;
|
||||
return (await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ?? undefined;
|
||||
}
|
||||
|
||||
private isRelayAvailableForNode(relay: Tunnel, node?: Node) {
|
||||
if (!relay.nodeId) return true;
|
||||
return Boolean(node?.id && relay.nodeId === node.id);
|
||||
}
|
||||
|
||||
private getNodeAddress(node?: Node) {
|
||||
if (!node) return undefined;
|
||||
if (node.domain) return node.domain;
|
||||
if (node.ip) return node.ip;
|
||||
if (node.host) return node.host;
|
||||
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,11 @@ export class SettingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
countries() {
|
||||
return COUNTRIES;
|
||||
}
|
||||
|
||||
@Post('check')
|
||||
async checkConnection(
|
||||
@Body() body: { xui_url: string; xui_login: string; xui_password: string },
|
||||
|
||||
@@ -9,14 +9,45 @@ import {
|
||||
ValidateIf,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
ValidateBy,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const PORT_OR_RANDOM = 'portOrRandom';
|
||||
|
||||
function IsPortOrRandom(validationOptions?: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: PORT_OR_RANDOM,
|
||||
validator: {
|
||||
validate: (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'random') return true;
|
||||
const port =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && /^\d+$/.test(value)
|
||||
? Number(value)
|
||||
: NaN;
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
},
|
||||
defaultMessage: () =>
|
||||
'port must be "random" or an integer from 1 to 65535',
|
||||
},
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export class InboundConfigDto {
|
||||
@IsString()
|
||||
type: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsPortOrRandom()
|
||||
port?: number | string;
|
||||
|
||||
@IsString()
|
||||
@@ -35,6 +66,22 @@ export class InboundConfigDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
relayServerId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
flag?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
certificateFile?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keyFile?: string;
|
||||
}
|
||||
|
||||
export class CreateSubscriptionDto {
|
||||
|
||||
@@ -36,6 +36,10 @@ export class Subscription {
|
||||
link?: string;
|
||||
nodeId?: string;
|
||||
relayServerId?: number;
|
||||
flag?: string;
|
||||
name?: string;
|
||||
certificateFile?: string;
|
||||
keyFile?: string;
|
||||
}>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Subscription } from './entities/subscription.entity';
|
||||
@@ -8,6 +8,7 @@ import { UpdateSubscriptionDto } from './dto/update-subscription.dto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Node } from '../nodes/entities/node.entity';
|
||||
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
@@ -29,6 +30,7 @@ export class SubscriptionsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateSubscriptionDto) {
|
||||
await this.validateInboundsConfig(dto.inboundsConfig);
|
||||
const sub = this.subRepo.create({
|
||||
name: dto.name,
|
||||
uuid: uuidv4(),
|
||||
@@ -57,6 +59,7 @@ export class SubscriptionsService {
|
||||
}
|
||||
|
||||
if (dto.inboundsConfig) {
|
||||
await this.validateInboundsConfig(dto.inboundsConfig);
|
||||
sub.inboundsConfig = dto.inboundsConfig;
|
||||
}
|
||||
|
||||
@@ -86,7 +89,18 @@ export class SubscriptionsService {
|
||||
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
for (const inbound of sub.inbounds) {
|
||||
await this.xuiService.deleteInbound(inbound.xuiId, inbound.node);
|
||||
if (!inbound.xuiId || inbound.xuiId <= 0) continue;
|
||||
|
||||
const isDeleted = await this.xuiService.deleteInbound(
|
||||
inbound.xuiId,
|
||||
await this.resolveInboundNode(inbound),
|
||||
);
|
||||
|
||||
if (!isDeleted) {
|
||||
throw new BadRequestException(
|
||||
`Failed to delete inbound ${inbound.xuiId} from 3x-ui`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +109,97 @@ export class SubscriptionsService {
|
||||
|
||||
private async resolveNode(nodeId?: string | null) {
|
||||
if (!nodeId) return null;
|
||||
return this.nodeRepo
|
||||
const node = await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId })
|
||||
.getOne();
|
||||
|
||||
if (!node) {
|
||||
throw new BadRequestException('Node not found');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private async resolveInboundNode(inbound: Inbound) {
|
||||
if (!inbound.nodeId) return undefined;
|
||||
|
||||
return (
|
||||
(await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||
.getOne()) ?? inbound.node
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRelay(relayServerId?: number | null) {
|
||||
if (!relayServerId) return null;
|
||||
return this.tunnelRepo.findOne({ where: { id: relayServerId } });
|
||||
const relay = await this.tunnelRepo.findOne({
|
||||
where: { id: relayServerId },
|
||||
});
|
||||
|
||||
if (!relay) {
|
||||
throw new BadRequestException('Relay server not found');
|
||||
}
|
||||
|
||||
return relay;
|
||||
}
|
||||
|
||||
private async validateInboundsConfig(
|
||||
inboundsConfig?: CreateSubscriptionDto['inboundsConfig'],
|
||||
) {
|
||||
for (const config of inboundsConfig || []) {
|
||||
if (config.type === 'custom') continue;
|
||||
|
||||
if (config.nodeId) {
|
||||
const node = await this.nodeRepo.findOne({
|
||||
where: { id: config.nodeId },
|
||||
});
|
||||
if (!node) {
|
||||
throw new BadRequestException('Node not found');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.relayServerId) {
|
||||
const relay = await this.tunnelRepo.findOne({
|
||||
where: { id: config.relayServerId },
|
||||
});
|
||||
if (!relay) {
|
||||
throw new BadRequestException('Relay server not found');
|
||||
}
|
||||
|
||||
if (config.nodeId && relay.nodeId && relay.nodeId !== config.nodeId) {
|
||||
throw new BadRequestException(
|
||||
'Relay server belongs to another node',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
config.port === undefined ||
|
||||
config.port === null ||
|
||||
config.port === '' ||
|
||||
config.port === 'random'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const port =
|
||||
typeof config.port === 'number'
|
||||
? config.port
|
||||
: /^\d+$/.test(config.port)
|
||||
? Number(config.port)
|
||||
: NaN;
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new BadRequestException(
|
||||
'Port must be "random" or an integer from 1 to 65535',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export class CreateTunnelDto {
|
||||
@IsOptional()
|
||||
nodeId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
ip: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Tunnel } from './entities/tunnel.entity';
|
||||
import { Setting } from '../settings/entities/setting.entity';
|
||||
import { SshService } from './ssh.service';
|
||||
import { Node } from '../nodes/entities/node.entity';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node])],
|
||||
imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node, Subscription])],
|
||||
controllers: [TunnelsController],
|
||||
providers: [TunnelsService, SshService],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,10 @@ import { Tunnel } from './entities/tunnel.entity';
|
||||
import { SshService } from './ssh.service';
|
||||
import { Setting } from '../settings/entities/setting.entity';
|
||||
import { Node } from '../nodes/entities/node.entity';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
import { CreateTunnelDto } from './dto/create-tunnel.dto';
|
||||
import * as net from 'net';
|
||||
import * as dns from 'dns/promises';
|
||||
|
||||
@Injectable()
|
||||
export class TunnelsService {
|
||||
@@ -15,10 +18,14 @@ export class TunnelsService {
|
||||
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
|
||||
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
|
||||
@InjectRepository(Node) private nodeRepo: Repository<Node>,
|
||||
@InjectRepository(Subscription)
|
||||
private subscriptionRepo: Repository<Subscription>,
|
||||
private sshService: SshService,
|
||||
) {}
|
||||
|
||||
async create(createTunnelDto: CreateTunnelDto) {
|
||||
const address = await this.resolveRelayAddress(createTunnelDto.ip);
|
||||
|
||||
const node = createTunnelDto.nodeId
|
||||
? await this.nodeRepo.findOne({ where: { id: createTunnelDto.nodeId } })
|
||||
: await this.nodeRepo.findOne({ where: { isMain: true } });
|
||||
@@ -27,20 +34,19 @@ export class TunnelsService {
|
||||
throw new HttpException('Node not found', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const ip = this.getNodeAddress(node);
|
||||
if (!ip) {
|
||||
throw new HttpException(
|
||||
'Cannot determine relay IP from node URL',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const tunnel = this.tunnelRepo.create({
|
||||
const tunnelPayload = {
|
||||
...createTunnelDto,
|
||||
ip,
|
||||
ip: address.ip,
|
||||
node,
|
||||
nodeId: node.id,
|
||||
});
|
||||
};
|
||||
|
||||
const domain = createTunnelDto.domain || address.domain;
|
||||
if (domain) {
|
||||
Object.assign(tunnelPayload, { domain });
|
||||
}
|
||||
|
||||
const tunnel = this.tunnelRepo.create(tunnelPayload);
|
||||
return this.tunnelRepo.save(tunnel);
|
||||
}
|
||||
|
||||
@@ -53,9 +59,37 @@ export class TunnelsService {
|
||||
await this.uninstallScript(id);
|
||||
}
|
||||
|
||||
await this.cleanupRelayDependencies(id);
|
||||
return this.tunnelRepo.delete(id);
|
||||
}
|
||||
|
||||
private async cleanupRelayDependencies(id: number) {
|
||||
const subscriptions = await this.subscriptionRepo.find({
|
||||
where: [{ relayServerId: id }],
|
||||
});
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
sub.relayServerId = undefined;
|
||||
sub.relayServer = undefined;
|
||||
await this.subscriptionRepo.save(sub);
|
||||
}
|
||||
|
||||
const configuredSubscriptions = await this.subscriptionRepo.find();
|
||||
for (const sub of configuredSubscriptions) {
|
||||
const config = sub.inboundsConfig || [];
|
||||
const nextConfig = config.map((item) => {
|
||||
if (item.relayServerId !== id) return item;
|
||||
const { relayServerId: _relayServerId, ...rest } = item;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (JSON.stringify(nextConfig) !== JSON.stringify(config)) {
|
||||
sub.inboundsConfig = nextConfig;
|
||||
await this.subscriptionRepo.save(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async installScript(id: number) {
|
||||
const tunnel = await this.tunnelRepo
|
||||
.createQueryBuilder('tunnel')
|
||||
@@ -79,7 +113,10 @@ export class TunnelsService {
|
||||
);
|
||||
}
|
||||
const mainServerIp =
|
||||
targetNode?.host || this.getNodeAddress(targetNode) || hostSetting.value;
|
||||
targetNode?.ip ||
|
||||
targetNode?.host ||
|
||||
this.getNodeAddress(targetNode) ||
|
||||
hostSetting?.value;
|
||||
|
||||
this.logger.debug(
|
||||
`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`,
|
||||
@@ -166,4 +203,42 @@ export class TunnelsService {
|
||||
return node.url;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRelayAddress(value: string) {
|
||||
const address = value.trim();
|
||||
if (!address) {
|
||||
throw new HttpException(
|
||||
'Relay server address is required',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (net.isIP(address) !== 0) {
|
||||
return { ip: address, domain: undefined };
|
||||
}
|
||||
|
||||
if (!this.isValidHostname(address)) {
|
||||
throw new HttpException(
|
||||
'Relay server address is invalid',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dns.lookup(address);
|
||||
return { ip: result.address, domain: address };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
'Relay server domain cannot be resolved',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidHostname(value: string) {
|
||||
if (value.length > 253) return false;
|
||||
return /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { Setting } from '../settings/entities/setting.entity';
|
||||
import {
|
||||
XuiResponse,
|
||||
@@ -29,7 +30,7 @@ export class XuiService {
|
||||
) {
|
||||
this.api = axios.create({
|
||||
timeout: 15000,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
proxy: false,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -61,11 +62,20 @@ export class XuiService {
|
||||
return axios.create({
|
||||
baseURL,
|
||||
timeout: 15000,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
proxy: false,
|
||||
...this.getAgentConfig(baseURL),
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
private getAgentConfig(baseURL?: string) {
|
||||
if (!baseURL || baseURL.startsWith('https://')) {
|
||||
return { httpsAgent: new https.Agent({ rejectUnauthorized: false }) };
|
||||
}
|
||||
|
||||
return { httpAgent: new http.Agent() };
|
||||
}
|
||||
|
||||
private async createAuthenticatedApi(node?: Node): Promise<AxiosInstance | null> {
|
||||
if (!node) {
|
||||
const success = await this.login();
|
||||
@@ -121,6 +131,9 @@ export class XuiService {
|
||||
|
||||
this.logger.log(`Attempting login to 3x-ui: ${config['xui_url']}`);
|
||||
this.api.defaults.baseURL = config['xui_url'];
|
||||
const agentConfig = this.getAgentConfig(config['xui_url']);
|
||||
this.api.defaults.httpAgent = agentConfig.httpAgent;
|
||||
this.api.defaults.httpsAgent = agentConfig.httpsAgent;
|
||||
|
||||
const res = await this.api.post<LoginResponse>('/login', {
|
||||
username: config['xui_login'],
|
||||
@@ -211,16 +224,35 @@ export class XuiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteInbound(id: number, node?: Node) {
|
||||
async deleteInbound(id: number, node?: Node): Promise<boolean> {
|
||||
if (!id || id <= 0) {
|
||||
this.logger.debug(`Skipping 3x-ui inbound deletion for non-remote id: ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = await this.createAuthenticatedApi(node);
|
||||
if (!api) return;
|
||||
await api.post(`/panel/api/inbounds/del/${id}`);
|
||||
this.logger.debug(`Инбаунд ${id} удален`);
|
||||
if (!api) {
|
||||
this.logger.error(`3x-ui authentication failed before deleting inbound ${id}`);
|
||||
return false;
|
||||
}
|
||||
const res = await api.post<XuiResponse<unknown>>(
|
||||
`/panel/api/inbounds/del/${id}`,
|
||||
);
|
||||
if (!res.data?.success) {
|
||||
this.logger.error(
|
||||
`3x-ui rejected inbound deletion ${id}: ${res.data?.msg || 'unknown error'}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.logger.debug(`Inbound ${id} deleted`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
const error = e as AxiosError;
|
||||
this.logger.error(`Ошибка удаления инбаунда ${id}: ${error.message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async checkConnection(
|
||||
@@ -234,7 +266,8 @@ export class XuiService {
|
||||
const tempApi = axios.create({
|
||||
baseURL: url,
|
||||
timeout: 5000,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
proxy: false,
|
||||
...this.getAgentConfig(url),
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user