From 38449a83419e052b75399e1bd929f7358e98287f Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Wed, 3 Jun 2026 02:16:04 +0800 Subject: [PATCH 01/13] feat: add warp node contract --- libs/contract/api/controllers/index.ts | 1 + libs/contract/api/controllers/warp.ts | 7 +++++ libs/contract/api/routes.ts | 5 ++++ libs/contract/commands/index.ts | 1 + .../contract/commands/warp/disable.command.ts | 14 ++++++++++ libs/contract/commands/warp/enable.command.ts | 14 ++++++++++ libs/contract/commands/warp/index.ts | 3 +++ libs/contract/commands/warp/status.command.ts | 14 ++++++++++ libs/contract/models/node-system.schema.ts | 12 +++++++++ test/warp-status.test.mjs | 27 +++++++++++++++++++ 10 files changed, 98 insertions(+) create mode 100644 libs/contract/api/controllers/warp.ts create mode 100644 libs/contract/commands/warp/disable.command.ts create mode 100644 libs/contract/commands/warp/enable.command.ts create mode 100644 libs/contract/commands/warp/index.ts create mode 100644 libs/contract/commands/warp/status.command.ts create mode 100644 test/warp-status.test.mjs diff --git a/libs/contract/api/controllers/index.ts b/libs/contract/api/controllers/index.ts index 9c59bb2..2c7fdcc 100644 --- a/libs/contract/api/controllers/index.ts +++ b/libs/contract/api/controllers/index.ts @@ -2,4 +2,5 @@ export * from './handler'; export * from './plugin'; export * from './stats'; export * from './vision'; +export * from './warp'; export * from './xray'; diff --git a/libs/contract/api/controllers/warp.ts b/libs/contract/api/controllers/warp.ts new file mode 100644 index 0000000..75a792f --- /dev/null +++ b/libs/contract/api/controllers/warp.ts @@ -0,0 +1,7 @@ +export const WARP_CONTROLLER = 'warp' as const; + +export const WARP_ROUTES = { + STATUS: 'status', + ENABLE: 'enable', + DISABLE: 'disable', +} as const; diff --git a/libs/contract/api/routes.ts b/libs/contract/api/routes.ts index b86127e..cbdf693 100644 --- a/libs/contract/api/routes.ts +++ b/libs/contract/api/routes.ts @@ -45,4 +45,9 @@ export const REST_API = { RECREATE_TABLES: `${ROOT}/${CONTROLLERS.PLUGIN_CONTROLLER}/${CONTROLLERS.PLUGIN_ROUTES.NFTABLES.RECREATE_TABLES}`, }, }, + WARP: { + STATUS: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.STATUS}`, + ENABLE: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.ENABLE}`, + DISABLE: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.DISABLE}`, + }, } as const; diff --git a/libs/contract/commands/index.ts b/libs/contract/commands/index.ts index 9c59bb2..2c7fdcc 100644 --- a/libs/contract/commands/index.ts +++ b/libs/contract/commands/index.ts @@ -2,4 +2,5 @@ export * from './handler'; export * from './plugin'; export * from './stats'; export * from './vision'; +export * from './warp'; export * from './xray'; diff --git a/libs/contract/commands/warp/disable.command.ts b/libs/contract/commands/warp/disable.command.ts new file mode 100644 index 0000000..9dfa98f --- /dev/null +++ b/libs/contract/commands/warp/disable.command.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WarpStatusSchema } from '../../models'; +import { REST_API } from '../../api'; + +export namespace DisableWarpCommand { + export const url = REST_API.WARP.DISABLE; + + export const ResponseSchema = z.object({ + response: WarpStatusSchema, + }); + + export type Response = z.infer; +} diff --git a/libs/contract/commands/warp/enable.command.ts b/libs/contract/commands/warp/enable.command.ts new file mode 100644 index 0000000..1a3ce6e --- /dev/null +++ b/libs/contract/commands/warp/enable.command.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WarpStatusSchema } from '../../models'; +import { REST_API } from '../../api'; + +export namespace EnableWarpCommand { + export const url = REST_API.WARP.ENABLE; + + export const ResponseSchema = z.object({ + response: WarpStatusSchema, + }); + + export type Response = z.infer; +} diff --git a/libs/contract/commands/warp/index.ts b/libs/contract/commands/warp/index.ts new file mode 100644 index 0000000..ae39dd9 --- /dev/null +++ b/libs/contract/commands/warp/index.ts @@ -0,0 +1,3 @@ +export * from './disable.command'; +export * from './enable.command'; +export * from './status.command'; diff --git a/libs/contract/commands/warp/status.command.ts b/libs/contract/commands/warp/status.command.ts new file mode 100644 index 0000000..e8c9f5b --- /dev/null +++ b/libs/contract/commands/warp/status.command.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WarpStatusSchema } from '../../models'; +import { REST_API } from '../../api'; + +export namespace GetWarpStatusCommand { + export const url = REST_API.WARP.STATUS; + + export const ResponseSchema = z.object({ + response: WarpStatusSchema, + }); + + export type Response = z.infer; +} diff --git a/libs/contract/models/node-system.schema.ts b/libs/contract/models/node-system.schema.ts index 3c237b8..bbb9fe0 100644 --- a/libs/contract/models/node-system.schema.ts +++ b/libs/contract/models/node-system.schema.ts @@ -8,6 +8,16 @@ export const NetworkInterfaceSchema = z.object({ txTotal: z.number(), }); +export const WarpStatusSchema = z.object({ + installed: z.boolean(), + running: z.boolean(), + interfaceName: z.string().nullable(), + publicIp: z.string().nullable(), + warp: z.enum(['on', 'off', 'unknown']), + colo: z.string().nullable(), + lastError: z.string().nullable(), +}); + export const NodeSystemInfoSchema = z.object({ arch: z.string(), cpus: z.number().int(), @@ -27,6 +37,7 @@ export const NodeSystemStatsSchema = z.object({ uptime: z.number(), loadAvg: z.array(z.number()), interface: z.nullable(NetworkInterfaceSchema), + warp: z.optional(WarpStatusSchema), }); export type TNodeSystemStats = z.infer; @@ -39,3 +50,4 @@ export const NodeSystemSchema = z.object({ export type TNetworkInterface = z.infer; export type TNodeSystemInfo = z.infer; export type TNodeSystem = z.infer; +export type TWarpStatus = z.infer; diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs new file mode 100644 index 0000000..f1d5158 --- /dev/null +++ b/test/warp-status.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const root = fileURLToPath(new URL('../', import.meta.url)); + +const readProjectFile = (path) => readFileSync(join(root, path), 'utf8'); + +describe('WARP contract shape', () => { + it('declares WARP routes and system stats field', () => { + const routes = readProjectFile('libs/contract/api/routes.ts'); + const controllers = readProjectFile('libs/contract/api/controllers/index.ts'); + const commands = readProjectFile('libs/contract/commands/index.ts'); + const nodeSystem = readProjectFile('libs/contract/models/node-system.schema.ts'); + + assert.match(controllers, /warp/); + assert.match(routes, /WARP/); + assert.match(routes, /STATUS/); + assert.match(routes, /ENABLE/); + assert.match(routes, /DISABLE/); + assert.match(commands, /warp/); + assert.match(nodeSystem, /WarpStatusSchema/); + assert.match(nodeSystem, /warp: z\.optional/); + }); +}); From e83cf3dc0a57ad11198650c40c428df6bc0950f0 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Wed, 3 Jun 2026 02:30:48 +0800 Subject: [PATCH 02/13] feat: add warp control endpoints --- src/modules/remnawave-node.modules.ts | 3 +- src/modules/stats/stats.module.ts | 3 +- src/modules/stats/stats.service.ts | 4 + src/modules/warp/warp.controller.ts | 40 +++++ src/modules/warp/warp.module.ts | 11 ++ src/modules/warp/warp.service.ts | 220 ++++++++++++++++++++++++++ src/modules/warp/warp.types.ts | 10 ++ src/modules/xray-core/xray.module.ts | 3 +- src/modules/xray-core/xray.service.ts | 10 +- test/warp-status.test.mjs | 16 ++ 10 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 src/modules/warp/warp.controller.ts create mode 100644 src/modules/warp/warp.module.ts create mode 100644 src/modules/warp/warp.service.ts create mode 100644 src/modules/warp/warp.types.ts diff --git a/src/modules/remnawave-node.modules.ts b/src/modules/remnawave-node.modules.ts index a2e0654..b0f0e3e 100644 --- a/src/modules/remnawave-node.modules.ts +++ b/src/modules/remnawave-node.modules.ts @@ -4,10 +4,11 @@ import { NetworkStatsModule } from './network-stats/network-stats.module'; import { HandlerModule } from './handler/handler.module'; import { PluginModule } from './_plugin/plugin.module'; import { XrayModule } from './xray-core/xray.module'; +import { WarpModule } from './warp/warp.module'; import { StatsModule } from './stats/stats.module'; @Module({ - imports: [NetworkStatsModule, PluginModule, StatsModule, XrayModule, HandlerModule], + imports: [NetworkStatsModule, PluginModule, WarpModule, StatsModule, XrayModule, HandlerModule], providers: [], }) export class RemnawaveNodeModules implements OnApplicationShutdown { diff --git a/src/modules/stats/stats.module.ts b/src/modules/stats/stats.module.ts index e3804f1..ae65c10 100644 --- a/src/modules/stats/stats.module.ts +++ b/src/modules/stats/stats.module.ts @@ -1,10 +1,11 @@ import { CqrsModule } from '@nestjs/cqrs'; import { Module } from '@nestjs/common'; +import { WarpModule } from '../warp/warp.module'; import { StatsController } from './stats.controller'; import { StatsService } from './stats.service'; @Module({ - imports: [CqrsModule], + imports: [CqrsModule, WarpModule], providers: [StatsService], controllers: [StatsController], }) diff --git a/src/modules/stats/stats.service.ts b/src/modules/stats/stats.service.ts index 81778cd..ec94d0a 100644 --- a/src/modules/stats/stats.service.ts +++ b/src/modules/stats/stats.service.ts @@ -25,12 +25,14 @@ import { import { GetInterfaceStatsQuery } from '../network-stats/queries/get-interface-stats/get-interface-stats.query'; import { GetTorrentBlockerReportsCountQuery } from '../_plugin/queries/get-torrent-blocker-reports-count'; import { IGetUserOnlineStatusRequest } from './interfaces'; +import { WarpService } from '../warp/warp.service'; @Injectable() export class StatsService { constructor( @InjectXtls() private readonly xtlsSdk: XtlsApi, private readonly queryBus: QueryBus, + private readonly warpService: WarpService, ) {} private readonly logger = new Logger(StatsService.name); @@ -74,6 +76,7 @@ export class StatsService { const interfaceStats = await this.queryBus.execute(new GetInterfaceStatsQuery()); const systemStats = getSystemStats(); + const warpStatus = await this.warpService.getStatus(); const reportsCount = await this.queryBus.execute( new GetTorrentBlockerReportsCountQuery(), ); @@ -90,6 +93,7 @@ export class StatsService { { ...systemStats, interface: interfaceStats, + warp: warpStatus, }, ), }; diff --git a/src/modules/warp/warp.controller.ts b/src/modules/warp/warp.controller.ts new file mode 100644 index 0000000..53fc0fe --- /dev/null +++ b/src/modules/warp/warp.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Post, UseFilters, UseGuards } from '@nestjs/common'; + +import { JwtDefaultGuard } from '@common/guards/jwt-guards'; +import { HttpExceptionFilter } from '@common/exception'; +import { WARP_CONTROLLER, WARP_ROUTES } from '@libs/contracts/api'; +import { + DisableWarpCommand, + EnableWarpCommand, + GetWarpStatusCommand, +} from '@libs/contracts/commands'; + +import { WarpService } from './warp.service'; + +@UseFilters(HttpExceptionFilter) +@UseGuards(JwtDefaultGuard) +@Controller(WARP_CONTROLLER) +export class WarpController { + constructor(private readonly warpService: WarpService) {} + + @Get(WARP_ROUTES.STATUS) + public async status(): Promise { + return { + response: await this.warpService.getStatus(), + }; + } + + @Post(WARP_ROUTES.ENABLE) + public async enable(): Promise { + return { + response: await this.warpService.enable(), + }; + } + + @Post(WARP_ROUTES.DISABLE) + public async disable(): Promise { + return { + response: await this.warpService.disable(), + }; + } +} diff --git a/src/modules/warp/warp.module.ts b/src/modules/warp/warp.module.ts new file mode 100644 index 0000000..89370e7 --- /dev/null +++ b/src/modules/warp/warp.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { WarpController } from './warp.controller'; +import { WarpService } from './warp.service'; + +@Module({ + controllers: [WarpController], + providers: [WarpService], + exports: [WarpService], +}) +export class WarpModule {} diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts new file mode 100644 index 0000000..d5ebebd --- /dev/null +++ b/src/modules/warp/warp.service.ts @@ -0,0 +1,220 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { Injectable, Logger } from '@nestjs/common'; + +import type { TWarpStatus } from '@libs/contracts/models'; + +import { TWarpCommandResult } from './warp.types'; + +const execFileAsync = promisify(execFile); + +const WARP_INTERFACE = 'warp'; +const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf'; +const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace'; +const WARP_INSTALL_URL = 'https://raw.githubusercontent.com/distillium/warp-native/main/install.sh'; + +@Injectable() +export class WarpService { + private readonly logger = new Logger(WarpService.name); + + public async getStatus(lastError: string | null = null): Promise { + if (process.platform !== 'linux') { + return this.status({ lastError: lastError ?? 'WARP is supported only on Linux nodes' }); + } + + const running = await this.isInterfaceRunning(); + const installed = this.hasWarpConfig() || running; + const trace = running ? await this.getTrace() : null; + + return this.status({ + installed, + running, + interfaceName: running ? WARP_INTERFACE : null, + publicIp: trace?.ip ?? null, + warp: trace?.warp ?? (running ? 'unknown' : 'off'), + colo: trace?.colo ?? null, + lastError, + }); + } + + public async enable(): Promise { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); + } + + const permissionError = await this.getPermissionError(); + if (permissionError) { + return this.getStatus(permissionError); + } + + try { + await this.installIfMissing(); + await this.execFixed('/usr/bin/wg-quick', ['up', WARP_INTERFACE], 20_000); + return await this.getStatus(); + } catch (error) { + const message = this.toSafeError(error); + this.logger.warn(`Failed to enable WARP: ${message}`); + return this.getStatus(message); + } + } + + public async disable(): Promise { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); + } + + try { + await this.execFixed('/usr/bin/wg-quick', ['down', WARP_INTERFACE], 20_000); + return await this.getStatus(); + } catch (error) { + const message = this.toSafeError(error); + this.logger.warn(`Failed to disable WARP: ${message}`); + return this.getStatus(message); + } + } + + private async installIfMissing(): Promise { + if (this.hasWarpConfig()) return; + + await this.ensureAlpinePackages(); + await this.execFixed('/bin/sh', ['-c', `curl -fsSL ${WARP_INSTALL_URL} | bash`], 180_000); + } + + private async ensureAlpinePackages(): Promise { + if (!existsSync('/sbin/apk')) return; + + await this.execFixed('/sbin/apk', [ + 'add', + '--no-cache', + 'bash', + 'curl', + 'iproute2', + 'openresolv', + 'wireguard-tools', + ], 120_000); + } + + private hasWarpConfig(): boolean { + return existsSync(WARP_CONFIG_PATH); + } + + private async isInterfaceRunning(): Promise { + try { + await this.execFixed('/sbin/ip', ['link', 'show', WARP_INTERFACE], 5_000); + return true; + } catch { + try { + await this.execFixed('/usr/sbin/ip', ['link', 'show', WARP_INTERFACE], 5_000); + return true; + } catch { + return false; + } + } + } + + private async getTrace(): Promise<{ ip: string | null; warp: TWarpStatus['warp']; colo: string | null } | null> { + try { + const result = await this.execFixed( + '/usr/bin/curl', + ['--max-time', '5', '-fsSL', WARP_TRACE_URL], + 8_000, + ); + + const fields = new Map( + result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [key, ...value] = line.split('='); + return [key, value.join('=')] as const; + }), + ); + + const warp = fields.get('warp'); + return { + ip: fields.get('ip') ?? null, + warp: warp === 'on' || warp === 'off' ? warp : 'unknown', + colo: fields.get('colo') ?? null, + }; + } catch { + return null; + } + } + + private async getPermissionError(): Promise { + if (!this.hasNetAdminCapability()) { + return 'NET_ADMIN capability is required to manage WARP'; + } + + try { + await this.execFixed('/sbin/ip', ['link'], 5_000); + return null; + } catch { + try { + await this.execFixed('/usr/sbin/ip', ['link'], 5_000); + return null; + } catch (error) { + return `Cannot inspect network interfaces: ${this.toSafeError(error)}`; + } + } + } + + private hasNetAdminCapability(): boolean { + try { + const status = readFileSync('/proc/self/status', 'utf8'); + const match = status.match(/^CapEff:\s*([0-9a-f]+)$/im); + if (!match) return false; + + const effective = BigInt(`0x${match[1]}`); + const capNetAdmin = BigInt(1) << BigInt(12); + return (effective & capNetAdmin) !== BigInt(0); + } catch { + return false; + } + } + + private async execFixed( + command: string, + args: string[], + timeout: number, + ): Promise { + const result = await execFileAsync(command, args, { + encoding: 'utf8', + timeout, + maxBuffer: 1024 * 1024, + }); + + return { + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + }; + } + + private status(partial: Partial): TWarpStatus { + return { + installed: false, + running: false, + interfaceName: null, + publicIp: null, + warp: 'unknown', + colo: null, + lastError: null, + ...partial, + }; + } + + private toSafeError(error: unknown): string { + if (error instanceof Error) { + return this.limitOutput(error.message); + } + + return this.limitOutput(String(error)); + } + + private limitOutput(value: string): string { + return value.replaceAll(WARP_INSTALL_URL, '[warp-install-url]').slice(0, 500); + } +} diff --git a/src/modules/warp/warp.types.ts b/src/modules/warp/warp.types.ts new file mode 100644 index 0000000..ec7ef546 --- /dev/null +++ b/src/modules/warp/warp.types.ts @@ -0,0 +1,10 @@ +import type { TWarpStatus } from '@libs/contracts/models'; + +export type TWarpCommandResult = { + stdout: string; + stderr: string; +}; + +export type TWarpMutableStatus = TWarpStatus & { + lastError: string | null; +}; diff --git a/src/modules/xray-core/xray.module.ts b/src/modules/xray-core/xray.module.ts index ddaf8d4..98356b3 100644 --- a/src/modules/xray-core/xray.module.ts +++ b/src/modules/xray-core/xray.module.ts @@ -2,12 +2,13 @@ import { Logger, Module, OnModuleDestroy } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { InternalModule } from '../internal/internal.module'; +import { WarpModule } from '../warp/warp.module'; import { XrayController } from './xray.controller'; import { XrayService } from './xray.service'; import { COMMANDS } from './commands'; @Module({ - imports: [InternalModule, CqrsModule], + imports: [InternalModule, CqrsModule, WarpModule], providers: [XrayService, ...COMMANDS], controllers: [XrayController], exports: [XrayService], diff --git a/src/modules/xray-core/xray.service.ts b/src/modules/xray-core/xray.service.ts index e6bd85c..27c7170 100644 --- a/src/modules/xray-core/xray.service.ts +++ b/src/modules/xray-core/xray.service.ts @@ -29,6 +29,7 @@ import { GetInterfaceStatsQuery } from '../network-stats/queries/get-interface-s import { ResetPluginsCommand } from '../_plugin/commands/reset-plugins/reset-plugins.command'; import { GetTorrentBlockerStateQuery } from '../_plugin/queries/get-torrent-blocker-state'; import { InternalService } from '../internal/internal.service'; +import { WarpService } from '../warp/warp.service'; const XRAY_PROCESS_NAME = 'xray' as const; @@ -54,6 +55,7 @@ export class XrayService implements OnApplicationBootstrap { private readonly configService: ConfigService, private readonly queryBus: QueryBus, private readonly commandBus: CommandBus, + private readonly warpService: WarpService, ) { this.internal = { socketPath: this.configService.getOrThrow('INTERNAL_SOCKET_PATH'), @@ -99,11 +101,15 @@ export class XrayService implements OnApplicationBootstrap { ip: string, ): Promise> { const interfaceStats = await this.queryBus.execute(new GetInterfaceStatsQuery()); + const warpStatus = await this.warpService.getStatus(); const tm = performance.now(); const system = { info: getSystemInfo(), - stats: getSystemStats(), - interface: interfaceStats, + stats: { + ...getSystemStats(), + interface: interfaceStats, + warp: warpStatus, + }, }; try { diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index f1d5158..95e5456 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -24,4 +24,20 @@ describe('WARP contract shape', () => { assert.match(nodeSystem, /WarpStatusSchema/); assert.match(nodeSystem, /warp: z\.optional/); }); + + it('declares WARP service without a generic command executor', () => { + const service = readProjectFile('src/modules/warp/warp.service.ts'); + const controller = readProjectFile('src/modules/warp/warp.controller.ts'); + const module = readProjectFile('src/modules/warp/warp.module.ts'); + + assert.match(service, /class WarpService/); + assert.match(service, /execFile/); + assert.match(service, /getStatus/); + assert.match(service, /enable/); + assert.match(service, /disable/); + assert.doesNotMatch(service, /executeCommand/); + assert.doesNotMatch(service, /runShell/); + assert.match(controller, /class WarpController/); + assert.match(module, /class WarpModule/); + }); }); From 18482ad3658babbe236f1fac897555fa576b93d3 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Wed, 3 Jun 2026 07:47:59 +0800 Subject: [PATCH 03/13] fix warp runtime status handling --- docker-compose-prod.yml | 2 + src/modules/warp/warp.service.ts | 90 +++++++++++++++++++++++++++++--- test/warp-status.test.mjs | 12 +++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 291e0a9..60ee998 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -7,6 +7,8 @@ services: restart: always cap_add: - NET_ADMIN + volumes: + - /etc/wireguard:/etc/wireguard ulimits: nofile: soft: 1048576 diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index d5ebebd..8de3ac4 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -14,6 +14,13 @@ const WARP_INTERFACE = 'warp'; const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf'; const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace'; const WARP_INSTALL_URL = 'https://raw.githubusercontent.com/distillium/warp-native/main/install.sh'; +const WARP_EXEC_MAX_BUFFER = 16 * 1024 * 1024; + +type TWarpTrace = { + ip: string | null; + warp: TWarpStatus['warp']; + colo: string | null; +}; @Injectable() export class WarpService { @@ -26,6 +33,7 @@ export class WarpService { const running = await this.isInterfaceRunning(); const installed = this.hasWarpConfig() || running; + const hasWireGuardHandshake = running ? await this.hasWireGuardHandshake() : false; const trace = running ? await this.getTrace() : null; return this.status({ @@ -33,7 +41,7 @@ export class WarpService { running, interfaceName: running ? WARP_INTERFACE : null, publicIp: trace?.ip ?? null, - warp: trace?.warp ?? (running ? 'unknown' : 'off'), + warp: this.getEffectiveWarpState(running, hasWireGuardHandshake, trace), colo: trace?.colo ?? null, lastError, }); @@ -44,6 +52,10 @@ export class WarpService { return this.getStatus('WARP is supported only on Linux nodes'); } + if (await this.isInterfaceRunning()) { + return this.getStatus(); + } + const permissionError = await this.getPermissionError(); if (permissionError) { return this.getStatus(permissionError); @@ -66,17 +78,79 @@ export class WarpService { } try { - await this.execFixed('/usr/bin/wg-quick', ['down', WARP_INTERFACE], 20_000); + if (!(await this.isInterfaceRunning())) { + return await this.getStatus(); + } + + if (this.hasWarpConfig()) { + await this.execFixed('/usr/bin/wg-quick', ['down', WARP_INTERFACE], 20_000); + } else { + await this.deleteInterface(); + } + return await this.getStatus(); } catch (error) { - const message = this.toSafeError(error); - this.logger.warn(`Failed to disable WARP: ${message}`); - return this.getStatus(message); + try { + await this.deleteInterface(); + return await this.getStatus(); + } catch (fallbackError) { + const message = `${this.toSafeError(error)}; fallback delete failed: ${this.toSafeError(fallbackError)}`; + this.logger.warn(`Failed to disable WARP: ${message}`); + return this.getStatus(message); + } + } + } + + private getEffectiveWarpState( + running: boolean, + hasWireGuardHandshake: boolean, + trace: TWarpTrace | null, + ): TWarpStatus['warp'] { + if (!running) return 'off'; + if (hasWireGuardHandshake || trace?.warp === 'on') return 'on'; + if (trace?.warp === 'off') return 'unknown'; + + return trace?.warp ?? 'unknown'; + } + + private async deleteInterface(): Promise { + const errors: string[] = []; + + for (const command of ['/sbin/ip', '/usr/sbin/ip']) { + try { + await this.execFixed(command, ['link', 'delete', WARP_INTERFACE], 20_000); + return; + } catch (error) { + errors.push(`${command}: ${this.toSafeError(error)}`); + } + } + + throw new Error(errors.join('; ')); + } + + private async hasWireGuardHandshake(): Promise { + try { + const result = await this.execFixed( + '/usr/bin/wg', + ['show', WARP_INTERFACE, 'latest-handshakes'], + 5_000, + ); + + return result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .some((line) => { + const [, timestamp] = line.split(/\s+/); + return Number(timestamp) > 0; + }); + } catch { + return false; } } private async installIfMissing(): Promise { - if (this.hasWarpConfig()) return; + if (this.hasWarpConfig() || await this.isInterfaceRunning()) return; await this.ensureAlpinePackages(); await this.execFixed('/bin/sh', ['-c', `curl -fsSL ${WARP_INSTALL_URL} | bash`], 180_000); @@ -114,7 +188,7 @@ export class WarpService { } } - private async getTrace(): Promise<{ ip: string | null; warp: TWarpStatus['warp']; colo: string | null } | null> { + private async getTrace(): Promise { try { const result = await this.execFixed( '/usr/bin/curl', @@ -184,7 +258,7 @@ export class WarpService { const result = await execFileAsync(command, args, { encoding: 'utf8', timeout, - maxBuffer: 1024 * 1024, + maxBuffer: WARP_EXEC_MAX_BUFFER, }); return { diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index 95e5456..e4ab94f 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -40,4 +40,16 @@ describe('WARP contract shape', () => { assert.match(controller, /class WarpController/); assert.match(module, /class WarpModule/); }); + + it('uses WireGuard runtime state for WARP status and safe control', () => { + const service = readProjectFile('src/modules/warp/warp.service.ts'); + const compose = readProjectFile('docker-compose-prod.yml'); + + assert.match(service, /show', WARP_INTERFACE, 'latest-handshakes'/); + assert.match(service, /hasWireGuardHandshake \|\| trace\?\.warp === 'on'/); + assert.match(service, /if \(await this\.isInterfaceRunning\(\)\) \{/); + assert.match(service, /link', 'delete', WARP_INTERFACE/); + assert.match(service, /maxBuffer: WARP_EXEC_MAX_BUFFER/); + assert.match(compose, /\/etc\/wireguard:\/etc\/wireguard/); + }); }); From 4bbac177d93c6913e7a436441ef4d44d51401ff1 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Wed, 3 Jun 2026 08:00:57 +0800 Subject: [PATCH 04/13] fix warp runtime image dependencies --- Dockerfile | 12 ++++++++++-- test/warp-status.test.mjs | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 994a56a..bcdd79d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,15 @@ COPY docker-entrypoint.sh /usr/local/bin/ COPY package*.json ./ COPY ./libs ./libs -RUN apk add --no-cache supervisor libnftnl libmnl && \ +RUN apk add --no-cache \ + bash \ + curl \ + iproute2 \ + libmnl \ + libnftnl \ + openresolv \ + supervisor \ + wireguard-tools && \ mkdir -p /var/log/supervisor && \ chmod +x /usr/local/bin/docker-entrypoint.sh && \ ln -s /usr/local/bin/xray /usr/local/bin/rw-core @@ -64,4 +72,4 @@ ENV XTLS_API_PORT=61000 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -CMD ["node", "dist/src/main"] \ No newline at end of file +CMD ["node", "dist/src/main"] diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index e4ab94f..f103113 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -44,6 +44,7 @@ describe('WARP contract shape', () => { it('uses WireGuard runtime state for WARP status and safe control', () => { const service = readProjectFile('src/modules/warp/warp.service.ts'); const compose = readProjectFile('docker-compose-prod.yml'); + const dockerfile = readProjectFile('Dockerfile'); assert.match(service, /show', WARP_INTERFACE, 'latest-handshakes'/); assert.match(service, /hasWireGuardHandshake \|\| trace\?\.warp === 'on'/); @@ -51,5 +52,8 @@ describe('WARP contract shape', () => { assert.match(service, /link', 'delete', WARP_INTERFACE/); assert.match(service, /maxBuffer: WARP_EXEC_MAX_BUFFER/); assert.match(compose, /\/etc\/wireguard:\/etc\/wireguard/); + assert.match(dockerfile, /wireguard-tools/); + assert.match(dockerfile, /iproute2/); + assert.match(dockerfile, /openresolv/); }); }); From 26c2edbc4e3e4904600372ccd7624530f212a227 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Wed, 3 Jun 2026 20:24:06 +0800 Subject: [PATCH 05/13] chore: fix warp lint formatting --- src/modules/remnawave-node.modules.ts | 2 +- src/modules/stats/stats.module.ts | 2 +- src/modules/warp/warp.controller.ts | 2 +- src/modules/warp/warp.service.ts | 20 ++++++++------------ src/modules/warp/warp.types.ts | 4 ++-- src/modules/xray-core/xray.module.ts | 2 +- 6 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/modules/remnawave-node.modules.ts b/src/modules/remnawave-node.modules.ts index b0f0e3e..87848aa 100644 --- a/src/modules/remnawave-node.modules.ts +++ b/src/modules/remnawave-node.modules.ts @@ -4,8 +4,8 @@ import { NetworkStatsModule } from './network-stats/network-stats.module'; import { HandlerModule } from './handler/handler.module'; import { PluginModule } from './_plugin/plugin.module'; import { XrayModule } from './xray-core/xray.module'; -import { WarpModule } from './warp/warp.module'; import { StatsModule } from './stats/stats.module'; +import { WarpModule } from './warp/warp.module'; @Module({ imports: [NetworkStatsModule, PluginModule, WarpModule, StatsModule, XrayModule, HandlerModule], diff --git a/src/modules/stats/stats.module.ts b/src/modules/stats/stats.module.ts index ae65c10..ad20a1e 100644 --- a/src/modules/stats/stats.module.ts +++ b/src/modules/stats/stats.module.ts @@ -1,8 +1,8 @@ import { CqrsModule } from '@nestjs/cqrs'; import { Module } from '@nestjs/common'; -import { WarpModule } from '../warp/warp.module'; import { StatsController } from './stats.controller'; +import { WarpModule } from '../warp/warp.module'; import { StatsService } from './stats.service'; @Module({ imports: [CqrsModule, WarpModule], diff --git a/src/modules/warp/warp.controller.ts b/src/modules/warp/warp.controller.ts index 53fc0fe..33fe71b 100644 --- a/src/modules/warp/warp.controller.ts +++ b/src/modules/warp/warp.controller.ts @@ -2,12 +2,12 @@ import { Controller, Get, Post, UseFilters, UseGuards } from '@nestjs/common'; import { JwtDefaultGuard } from '@common/guards/jwt-guards'; import { HttpExceptionFilter } from '@common/exception'; -import { WARP_CONTROLLER, WARP_ROUTES } from '@libs/contracts/api'; import { DisableWarpCommand, EnableWarpCommand, GetWarpStatusCommand, } from '@libs/contracts/commands'; +import { WARP_CONTROLLER, WARP_ROUTES } from '@libs/contracts/api'; import { WarpService } from './warp.service'; diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 8de3ac4..329ea9b 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -2,10 +2,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { Injectable, Logger } from '@nestjs/common'; - import type { TWarpStatus } from '@libs/contracts/models'; +import { Injectable, Logger } from '@nestjs/common'; + import { TWarpCommandResult } from './warp.types'; const execFileAsync = promisify(execFile); @@ -150,7 +150,7 @@ export class WarpService { } private async installIfMissing(): Promise { - if (this.hasWarpConfig() || await this.isInterfaceRunning()) return; + if (this.hasWarpConfig() || (await this.isInterfaceRunning())) return; await this.ensureAlpinePackages(); await this.execFixed('/bin/sh', ['-c', `curl -fsSL ${WARP_INSTALL_URL} | bash`], 180_000); @@ -159,15 +159,11 @@ export class WarpService { private async ensureAlpinePackages(): Promise { if (!existsSync('/sbin/apk')) return; - await this.execFixed('/sbin/apk', [ - 'add', - '--no-cache', - 'bash', - 'curl', - 'iproute2', - 'openresolv', - 'wireguard-tools', - ], 120_000); + await this.execFixed( + '/sbin/apk', + ['add', '--no-cache', 'bash', 'curl', 'iproute2', 'openresolv', 'wireguard-tools'], + 120_000, + ); } private hasWarpConfig(): boolean { diff --git a/src/modules/warp/warp.types.ts b/src/modules/warp/warp.types.ts index ec7ef546..40594ec 100644 --- a/src/modules/warp/warp.types.ts +++ b/src/modules/warp/warp.types.ts @@ -5,6 +5,6 @@ export type TWarpCommandResult = { stderr: string; }; -export type TWarpMutableStatus = TWarpStatus & { +export type TWarpMutableStatus = { lastError: string | null; -}; +} & TWarpStatus; diff --git a/src/modules/xray-core/xray.module.ts b/src/modules/xray-core/xray.module.ts index 98356b3..84630b6 100644 --- a/src/modules/xray-core/xray.module.ts +++ b/src/modules/xray-core/xray.module.ts @@ -2,8 +2,8 @@ import { Logger, Module, OnModuleDestroy } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { InternalModule } from '../internal/internal.module'; -import { WarpModule } from '../warp/warp.module'; import { XrayController } from './xray.controller'; +import { WarpModule } from '../warp/warp.module'; import { XrayService } from './xray.service'; import { COMMANDS } from './commands'; From fca1b187edcf881581cd9d151705daaf28ba7aee Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 01:59:34 +0800 Subject: [PATCH 06/13] fix: make warp install non-interactive --- src/modules/warp/warp.service.ts | 169 +++++++++++++++++++++++++++---- test/warp-status.test.mjs | 13 ++- 2 files changed, 162 insertions(+), 20 deletions(-) diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 329ea9b..5f1dd88 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; +import { spawn } from 'node:child_process'; import type { TWarpStatus } from '@libs/contracts/models'; @@ -8,13 +7,61 @@ import { Injectable, Logger } from '@nestjs/common'; import { TWarpCommandResult } from './warp.types'; -const execFileAsync = promisify(execFile); - const WARP_INTERFACE = 'warp'; const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf'; const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace'; -const WARP_INSTALL_URL = 'https://raw.githubusercontent.com/distillium/warp-native/main/install.sh'; -const WARP_EXEC_MAX_BUFFER = 16 * 1024 * 1024; +const WGCF_RELEASES_API_URL = 'https://api.github.com/repos/ViRb3/wgcf/releases/latest'; +const WARP_OUTPUT_TAIL_BYTES = 64 * 1024; +const WARP_KILL_GRACE_MS = 5_000; +const WARP_INSTALL_SCRIPT = String.raw` +set -euo pipefail + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) WGCF_ARCH="linux_amd64" ;; + aarch64|arm64) WGCF_ARCH="linux_arm64" ;; + armv7l|armv7) WGCF_ARCH="linux_armv7" ;; + armv6l|armv6) WGCF_ARCH="linux_armv6" ;; + armv5l|armv5) WGCF_ARCH="linux_armv5" ;; + i386|i686) WGCF_ARCH="linux_386" ;; + s390x) WGCF_ARCH="linux_s390x" ;; + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac + +if ! command -v wgcf >/dev/null 2>&1; then + WGCF_URL="$( + curl -fsSL ${WGCF_RELEASES_API_URL} \ + | grep -Eo 'https://[^"]+wgcf_[^"]+_'"$WGCF_ARCH"'"?' \ + | head -n 1 \ + | tr -d '"' + )" + + if [ -z "$WGCF_URL" ]; then + echo "Could not resolve wgcf download URL for $WGCF_ARCH" >&2 + exit 1 + fi + + curl -fsSL "$WGCF_URL" -o /usr/local/bin/wgcf + chmod +x /usr/local/bin/wgcf +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT +cd "$TMP_DIR" + +wgcf register --accept-tos >/dev/null +wgcf generate >/dev/null +test -s wgcf-profile.conf + +sed -i -E '/^DNS =/d' wgcf-profile.conf +sed -i -E 's#^(Address = [^,]+),.*#\1#' wgcf-profile.conf +grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf +grep -q '^PersistentKeepalive = ' wgcf-profile.conf \ + || sed -i '/^Endpoint =/a PersistentKeepalive = 25' wgcf-profile.conf + +mkdir -p /etc/wireguard +install -m 600 wgcf-profile.conf ${WARP_CONFIG_PATH} +`; type TWarpTrace = { ip: string | null; @@ -153,7 +200,7 @@ export class WarpService { if (this.hasWarpConfig() || (await this.isInterfaceRunning())) return; await this.ensureAlpinePackages(); - await this.execFixed('/bin/sh', ['-c', `curl -fsSL ${WARP_INSTALL_URL} | bash`], 180_000); + await this.execFixed('/bin/bash', ['-o', 'pipefail', '-c', WARP_INSTALL_SCRIPT], 180_000); } private async ensureAlpinePackages(): Promise { @@ -161,7 +208,16 @@ export class WarpService { await this.execFixed( '/sbin/apk', - ['add', '--no-cache', 'bash', 'curl', 'iproute2', 'openresolv', 'wireguard-tools'], + [ + 'add', + '--no-cache', + 'bash', + 'ca-certificates', + 'curl', + 'iproute2', + 'openresolv', + 'wireguard-tools', + ], 120_000, ); } @@ -251,16 +307,69 @@ export class WarpService { args: string[], timeout: number, ): Promise { - const result = await execFileAsync(command, args, { - encoding: 'utf8', - timeout, - maxBuffer: WARP_EXEC_MAX_BUFFER, - }); + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + let timedOut = false; + let killTimer: NodeJS.Timeout | null = null; + + const timer = setTimeout(() => { + timedOut = true; + this.killProcessGroup(child.pid, 'SIGTERM'); + killTimer = setTimeout(() => { + this.killProcessGroup(child.pid, 'SIGKILL'); + }, WARP_KILL_GRACE_MS); + }, timeout); + + const settle = (callback: () => void) => { + if (settled) return; + + settled = true; + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + callback(); + }; - return { - stdout: String(result.stdout ?? ''), - stderr: String(result.stderr ?? ''), - }; + child.stdout?.on('data', (chunk: Buffer) => { + stdout = this.appendOutputTail(stdout, chunk); + }); + + child.stderr?.on('data', (chunk: Buffer) => { + stderr = this.appendOutputTail(stderr, chunk); + }); + + child.on('error', (error) => { + settle(() => reject(error)); + }); + + child.on('close', (code, signal) => { + settle(() => { + if (timedOut) { + reject(new Error(`${command} timed out after ${timeout}ms`)); + return; + } + + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + const details = [ + `${command} exited with code ${code ?? 'null'}`, + signal ? `signal ${signal}` : null, + stderr.trim() ? `stderr: ${stderr.trim()}` : null, + stdout.trim() ? `stdout: ${stdout.trim()}` : null, + ].filter(Boolean); + + reject(new Error(details.join('; '))); + }); + }); + }); } private status(partial: Partial): TWarpStatus { @@ -285,6 +394,30 @@ export class WarpService { } private limitOutput(value: string): string { - return value.replaceAll(WARP_INSTALL_URL, '[warp-install-url]').slice(0, 500); + return value.replaceAll(WGCF_RELEASES_API_URL, '[wgcf-releases-url]').slice(0, 500); + } + + private appendOutputTail(current: string, chunk: Buffer): string { + const next = `${current}${chunk.toString('utf8')}`; + + if (Buffer.byteLength(next, 'utf8') <= WARP_OUTPUT_TAIL_BYTES) { + return next; + } + + return next.slice(-WARP_OUTPUT_TAIL_BYTES); + } + + private killProcessGroup(pid: number | undefined, signal: NodeJS.Signals): void { + if (!pid) return; + + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + // The process may already have exited. + } + } } } diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index f103113..e543978 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -31,7 +31,7 @@ describe('WARP contract shape', () => { const module = readProjectFile('src/modules/warp/warp.module.ts'); assert.match(service, /class WarpService/); - assert.match(service, /execFile/); + assert.match(service, /spawn/); assert.match(service, /getStatus/); assert.match(service, /enable/); assert.match(service, /disable/); @@ -50,7 +50,16 @@ describe('WARP contract shape', () => { assert.match(service, /hasWireGuardHandshake \|\| trace\?\.warp === 'on'/); assert.match(service, /if \(await this\.isInterfaceRunning\(\)\) \{/); assert.match(service, /link', 'delete', WARP_INTERFACE/); - assert.match(service, /maxBuffer: WARP_EXEC_MAX_BUFFER/); + assert.match(service, /wgcf register --accept-tos/); + assert.match(service, /wgcf generate/); + assert.match(service, /WGCF_RELEASES_API_URL/); + assert.match(service, /install -m 600 wgcf-profile\.conf/); + assert.match(service, /WARP_OUTPUT_TAIL_BYTES/); + assert.match(service, /detached: true/); + assert.match(service, /killProcessGroup/); + assert.match(service, /process\.kill\(-pid, signal\)/); + assert.doesNotMatch(service, /maxBuffer/); + assert.doesNotMatch(service, /warp-native/); assert.match(compose, /\/etc\/wireguard:\/etc\/wireguard/); assert.match(dockerfile, /wireguard-tools/); assert.match(dockerfile, /iproute2/); From abaf3cac62aed090f156527e90e6bd21fe5a9ace Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 03:02:47 +0800 Subject: [PATCH 07/13] fix: force warp endpoint to ipv4 --- src/modules/warp/warp.service.ts | 44 ++++++++++++++++++++++++++++++-- test/warp-status.test.mjs | 4 +++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 5f1dd88..114405f 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import type { TWarpStatus } from '@libs/contracts/models'; @@ -11,6 +11,7 @@ const WARP_INTERFACE = 'warp'; const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf'; const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace'; const WGCF_RELEASES_API_URL = 'https://api.github.com/repos/ViRb3/wgcf/releases/latest'; +const WARP_ENDPOINT = '162.159.192.1:2408'; const WARP_OUTPUT_TAIL_BYTES = 64 * 1024; const WARP_KILL_GRACE_MS = 5_000; const WARP_INSTALL_SCRIPT = String.raw` @@ -55,6 +56,7 @@ test -s wgcf-profile.conf sed -i -E '/^DNS =/d' wgcf-profile.conf sed -i -E 's#^(Address = [^,]+),.*#\1#' wgcf-profile.conf +sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_ENDPOINT}#' wgcf-profile.conf grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf grep -q '^PersistentKeepalive = ' wgcf-profile.conf \ || sed -i '/^Endpoint =/a PersistentKeepalive = 25' wgcf-profile.conf @@ -100,7 +102,10 @@ export class WarpService { } if (await this.isInterfaceRunning()) { - return this.getStatus(); + const currentStatus = await this.getStatus(); + if (currentStatus.warp === 'on') { + return currentStatus; + } } const permissionError = await this.getPermissionError(); @@ -110,6 +115,8 @@ export class WarpService { try { await this.installIfMissing(); + this.normalizeWarpConfig(); + await this.stopRunningInterface(); await this.execFixed('/usr/bin/wg-quick', ['up', WARP_INTERFACE], 20_000); return await this.getStatus(); } catch (error) { @@ -175,6 +182,39 @@ export class WarpService { throw new Error(errors.join('; ')); } + private async stopRunningInterface(): Promise { + if (!(await this.isInterfaceRunning())) return; + + if (this.hasWarpConfig()) { + await this.execFixed('/usr/bin/wg-quick', ['down', WARP_INTERFACE], 20_000); + return; + } + + await this.deleteInterface(); + } + + private normalizeWarpConfig(): void { + if (!this.hasWarpConfig()) return; + + const original = readFileSync(WARP_CONFIG_PATH, 'utf8'); + let updated = original.replace(/^Endpoint = .*$/m, `Endpoint = ${WARP_ENDPOINT}`); + + if (!/^Table = off$/m.test(updated)) { + updated = updated.replace(/^MTU = .*$/m, (line) => `${line}\nTable = off`); + } + + if (!/^PersistentKeepalive = /m.test(updated)) { + updated = updated.replace( + /^Endpoint = .*$/m, + (line) => `${line}\nPersistentKeepalive = 25`, + ); + } + + if (updated !== original) { + writeFileSync(WARP_CONFIG_PATH, updated, { mode: 0o600 }); + } + } + private async hasWireGuardHandshake(): Promise { try { const result = await this.execFixed( diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index e543978..b897939 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -53,6 +53,10 @@ describe('WARP contract shape', () => { assert.match(service, /wgcf register --accept-tos/); assert.match(service, /wgcf generate/); assert.match(service, /WGCF_RELEASES_API_URL/); + assert.match(service, /WARP_ENDPOINT = '162\.159\.192\.1:2408'/); + assert.match(service, /Endpoint = \$\{WARP_ENDPOINT\}/); + assert.match(service, /normalizeWarpConfig/); + assert.match(service, /stopRunningInterface/); assert.match(service, /install -m 600 wgcf-profile\.conf/); assert.match(service, /WARP_OUTPUT_TAIL_BYTES/); assert.match(service, /detached: true/); From 9dcf863e2ab2c8bcebbf860a38dcef6a6111f014 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 03:26:35 +0800 Subject: [PATCH 08/13] fix: probe warp status through interface --- src/modules/warp/warp.service.ts | 2 +- test/warp-status.test.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 114405f..8bbd971 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -284,7 +284,7 @@ export class WarpService { try { const result = await this.execFixed( '/usr/bin/curl', - ['--max-time', '5', '-fsSL', WARP_TRACE_URL], + ['--max-time', '5', '-4', '--interface', WARP_INTERFACE, '-fsSL', WARP_TRACE_URL], 8_000, ); diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index b897939..cf8bac7 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -55,6 +55,7 @@ describe('WARP contract shape', () => { assert.match(service, /WGCF_RELEASES_API_URL/); assert.match(service, /WARP_ENDPOINT = '162\.159\.192\.1:2408'/); assert.match(service, /Endpoint = \$\{WARP_ENDPOINT\}/); + assert.match(service, /'--interface', WARP_INTERFACE/); assert.match(service, /normalizeWarpConfig/); assert.match(service, /stopRunningInterface/); assert.match(service, /install -m 600 wgcf-profile\.conf/); From 9fa897270b696b9f6d16979af705a3f1071c32f0 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 04:14:10 +0800 Subject: [PATCH 09/13] fix: support dual-stack warp probing --- libs/contract/models/node-system.schema.ts | 10 ++++ src/modules/warp/warp.service.ts | 70 ++++++++++++++++------ test/warp-status.test.mjs | 14 ++++- 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/libs/contract/models/node-system.schema.ts b/libs/contract/models/node-system.schema.ts index bbb9fe0..cdf5a44 100644 --- a/libs/contract/models/node-system.schema.ts +++ b/libs/contract/models/node-system.schema.ts @@ -8,13 +8,23 @@ export const NetworkInterfaceSchema = z.object({ txTotal: z.number(), }); +const WarpTraceSchema = z.object({ + publicIp: z.string().nullable(), + warp: z.enum(['on', 'off', 'unknown']), + colo: z.string().nullable(), +}); + export const WarpStatusSchema = z.object({ installed: z.boolean(), running: z.boolean(), interfaceName: z.string().nullable(), publicIp: z.string().nullable(), + publicIpv4: z.string().nullable(), + publicIpv6: z.string().nullable(), warp: z.enum(['on', 'off', 'unknown']), colo: z.string().nullable(), + ipv4: WarpTraceSchema.nullable(), + ipv6: WarpTraceSchema.nullable(), lastError: z.string().nullable(), }); diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 8bbd971..f265189 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -55,7 +55,6 @@ wgcf generate >/dev/null test -s wgcf-profile.conf sed -i -E '/^DNS =/d' wgcf-profile.conf -sed -i -E 's#^(Address = [^,]+),.*#\1#' wgcf-profile.conf sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_ENDPOINT}#' wgcf-profile.conf grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf grep -q '^PersistentKeepalive = ' wgcf-profile.conf \ @@ -66,11 +65,13 @@ install -m 600 wgcf-profile.conf ${WARP_CONFIG_PATH} `; type TWarpTrace = { - ip: string | null; + publicIp: string | null; warp: TWarpStatus['warp']; colo: string | null; }; +type TWarpTraceIpVersion = '4' | '6'; + @Injectable() export class WarpService { private readonly logger = new Logger(WarpService.name); @@ -83,15 +84,20 @@ export class WarpService { const running = await this.isInterfaceRunning(); const installed = this.hasWarpConfig() || running; const hasWireGuardHandshake = running ? await this.hasWireGuardHandshake() : false; - const trace = running ? await this.getTrace() : null; + const traceIpv4 = running ? await this.getTrace('4') : null; + const traceIpv6 = running ? await this.getTrace('6') : null; return this.status({ installed, running, interfaceName: running ? WARP_INTERFACE : null, - publicIp: trace?.ip ?? null, - warp: this.getEffectiveWarpState(running, hasWireGuardHandshake, trace), - colo: trace?.colo ?? null, + publicIp: traceIpv4?.publicIp ?? traceIpv6?.publicIp ?? null, + publicIpv4: traceIpv4?.publicIp ?? null, + publicIpv6: traceIpv6?.publicIp ?? null, + warp: this.getEffectiveWarpState(running, hasWireGuardHandshake, traceIpv4, traceIpv6), + colo: traceIpv4?.colo ?? traceIpv6?.colo ?? null, + ipv4: traceIpv4, + ipv6: traceIpv6, lastError, }); } @@ -103,7 +109,7 @@ export class WarpService { if (await this.isInterfaceRunning()) { const currentStatus = await this.getStatus(); - if (currentStatus.warp === 'on') { + if (currentStatus.warp === 'on' && this.hasDualStackConfig()) { return currentStatus; } } @@ -114,7 +120,7 @@ export class WarpService { } try { - await this.installIfMissing(); + await this.installIfMissingOrSingleStack(); this.normalizeWarpConfig(); await this.stopRunningInterface(); await this.execFixed('/usr/bin/wg-quick', ['up', WARP_INTERFACE], 20_000); @@ -158,13 +164,17 @@ export class WarpService { private getEffectiveWarpState( running: boolean, hasWireGuardHandshake: boolean, - trace: TWarpTrace | null, + traceIpv4: TWarpTrace | null, + traceIpv6: TWarpTrace | null, ): TWarpStatus['warp'] { if (!running) return 'off'; - if (hasWireGuardHandshake || trace?.warp === 'on') return 'on'; - if (trace?.warp === 'off') return 'unknown'; + if (traceIpv4?.warp === 'on' && traceIpv6?.warp === 'on') return 'on'; + if (hasWireGuardHandshake && (traceIpv4?.warp === 'on' || traceIpv6?.warp === 'on')) { + return 'unknown'; + } + if (traceIpv4?.warp === 'off' || traceIpv6?.warp === 'off') return 'unknown'; - return trace?.warp ?? 'unknown'; + return traceIpv4?.warp ?? traceIpv6?.warp ?? 'unknown'; } private async deleteInterface(): Promise { @@ -236,8 +246,8 @@ export class WarpService { } } - private async installIfMissing(): Promise { - if (this.hasWarpConfig() || (await this.isInterfaceRunning())) return; + private async installIfMissingOrSingleStack(): Promise { + if (this.hasWarpConfig() && this.hasDualStackConfig()) return; await this.ensureAlpinePackages(); await this.execFixed('/bin/bash', ['-o', 'pipefail', '-c', WARP_INSTALL_SCRIPT], 180_000); @@ -266,6 +276,20 @@ export class WarpService { return existsSync(WARP_CONFIG_PATH); } + private hasDualStackConfig(): boolean { + if (!this.hasWarpConfig()) return false; + + const config = readFileSync(WARP_CONFIG_PATH, 'utf8'); + const addresses = [...config.matchAll(/^Address = (.+)$/gm)] + .flatMap((match) => match[1].split(',')) + .map((address) => address.trim()); + + const hasIpv4 = addresses.some((address) => /^\d{1,3}(?:\.\d{1,3}){3}\/\d+$/.test(address)); + const hasIpv6 = addresses.some((address) => /^[0-9a-f:]+\/\d+$/i.test(address)); + + return hasIpv4 && hasIpv6; + } + private async isInterfaceRunning(): Promise { try { await this.execFixed('/sbin/ip', ['link', 'show', WARP_INTERFACE], 5_000); @@ -280,11 +304,19 @@ export class WarpService { } } - private async getTrace(): Promise { + private async getTrace(ipVersion: TWarpTraceIpVersion): Promise { try { const result = await this.execFixed( '/usr/bin/curl', - ['--max-time', '5', '-4', '--interface', WARP_INTERFACE, '-fsSL', WARP_TRACE_URL], + [ + '--max-time', + '5', + `-${ipVersion}`, + '--interface', + WARP_INTERFACE, + '-fsSL', + WARP_TRACE_URL, + ], 8_000, ); @@ -301,7 +333,7 @@ export class WarpService { const warp = fields.get('warp'); return { - ip: fields.get('ip') ?? null, + publicIp: fields.get('ip') ?? null, warp: warp === 'on' || warp === 'off' ? warp : 'unknown', colo: fields.get('colo') ?? null, }; @@ -418,8 +450,12 @@ export class WarpService { running: false, interfaceName: null, publicIp: null, + publicIpv4: null, + publicIpv6: null, warp: 'unknown', colo: null, + ipv4: null, + ipv6: null, lastError: null, ...partial, }; diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index cf8bac7..8d724bd 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -45,17 +45,23 @@ describe('WARP contract shape', () => { const service = readProjectFile('src/modules/warp/warp.service.ts'); const compose = readProjectFile('docker-compose-prod.yml'); const dockerfile = readProjectFile('Dockerfile'); + const nodeSystem = readProjectFile('libs/contract/models/node-system.schema.ts'); assert.match(service, /show', WARP_INTERFACE, 'latest-handshakes'/); - assert.match(service, /hasWireGuardHandshake \|\| trace\?\.warp === 'on'/); + assert.match(service, /traceIpv4\?\.warp === 'on' && traceIpv6\?\.warp === 'on'/); assert.match(service, /if \(await this\.isInterfaceRunning\(\)\) \{/); assert.match(service, /link', 'delete', WARP_INTERFACE/); assert.match(service, /wgcf register --accept-tos/); assert.match(service, /wgcf generate/); + assert.doesNotMatch(service, /s#\^\(Address = \[\^,\]\+\),\.\*#\\1#/); assert.match(service, /WGCF_RELEASES_API_URL/); assert.match(service, /WARP_ENDPOINT = '162\.159\.192\.1:2408'/); assert.match(service, /Endpoint = \$\{WARP_ENDPOINT\}/); - assert.match(service, /'--interface', WARP_INTERFACE/); + assert.match(service, /getTrace\('4'\)/); + assert.match(service, /getTrace\('6'\)/); + assert.match(service, /`\-\$\{ipVersion\}`/); + assert.match(service, /'--interface',\s+WARP_INTERFACE/); + assert.match(service, /hasDualStackConfig/); assert.match(service, /normalizeWarpConfig/); assert.match(service, /stopRunningInterface/); assert.match(service, /install -m 600 wgcf-profile\.conf/); @@ -69,5 +75,9 @@ describe('WARP contract shape', () => { assert.match(dockerfile, /wireguard-tools/); assert.match(dockerfile, /iproute2/); assert.match(dockerfile, /openresolv/); + assert.match(nodeSystem, /publicIpv4: z\.string\(\)\.nullable\(\)/); + assert.match(nodeSystem, /publicIpv6: z\.string\(\)\.nullable\(\)/); + assert.match(nodeSystem, /ipv4: WarpTraceSchema\.nullable\(\)/); + assert.match(nodeSystem, /ipv6: WarpTraceSchema\.nullable\(\)/); }); }); From 87e24791bbad2ab6b23832eeba695766b3ffc9ea Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 04:37:30 +0800 Subject: [PATCH 10/13] fix: preserve dual-stack warp routes --- src/modules/warp/warp.service.ts | 34 +++++++++++++++++++++++++++++++- test/warp-status.test.mjs | 4 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index f265189..838e30f 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -12,6 +12,9 @@ const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf'; const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace'; const WGCF_RELEASES_API_URL = 'https://api.github.com/repos/ViRb3/wgcf/releases/latest'; const WARP_ENDPOINT = '162.159.192.1:2408'; +const WARP_IPV6_ROUTE_METRIC = 4242; +const WARP_IPV6_ROUTE_POST_UP = `PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}`; +const WARP_IPV6_ROUTE_PRE_DOWN = `PreDown = ip -6 route del ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC} 2>/dev/null || true`; const WARP_OUTPUT_TAIL_BYTES = 64 * 1024; const WARP_KILL_GRACE_MS = 5_000; const WARP_INSTALL_SCRIPT = String.raw` @@ -57,6 +60,14 @@ test -s wgcf-profile.conf sed -i -E '/^DNS =/d' wgcf-profile.conf sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_ENDPOINT}#' wgcf-profile.conf grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf +grep -q '^PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}$' wgcf-profile.conf || awk ' + { print } + /^Table = off$/ && !done { + print "PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}" + print "PreDown = ip -6 route del ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC} 2>/dev/null || true" + done=1 + } +' wgcf-profile.conf > wgcf-profile.conf.next && mv wgcf-profile.conf.next wgcf-profile.conf grep -q '^PersistentKeepalive = ' wgcf-profile.conf \ || sed -i '/^Endpoint =/a PersistentKeepalive = 25' wgcf-profile.conf @@ -109,7 +120,11 @@ export class WarpService { if (await this.isInterfaceRunning()) { const currentStatus = await this.getStatus(); - if (currentStatus.warp === 'on' && this.hasDualStackConfig()) { + if ( + currentStatus.warp === 'on' && + this.hasDualStackConfig() && + this.hasBoundIpv6RouteConfig() + ) { return currentStatus; } } @@ -220,6 +235,13 @@ export class WarpService { ); } + if (!this.hasBoundIpv6RouteConfig(updated)) { + updated = updated.replace( + /^Table = off$/m, + (line) => `${line}\n${WARP_IPV6_ROUTE_POST_UP}\n${WARP_IPV6_ROUTE_PRE_DOWN}`, + ); + } + if (updated !== original) { writeFileSync(WARP_CONFIG_PATH, updated, { mode: 0o600 }); } @@ -290,6 +312,16 @@ export class WarpService { return hasIpv4 && hasIpv6; } + private hasBoundIpv6RouteConfig(config: string | null = null): boolean { + if (config === null && !this.hasWarpConfig()) return false; + + const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8'); + return ( + warpConfig.includes(WARP_IPV6_ROUTE_POST_UP) && + warpConfig.includes(WARP_IPV6_ROUTE_PRE_DOWN) + ); + } + private async isInterfaceRunning(): Promise { try { await this.execFixed('/sbin/ip', ['link', 'show', WARP_INTERFACE], 5_000); diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index 8d724bd..fea4756 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -51,6 +51,10 @@ describe('WARP contract shape', () => { assert.match(service, /traceIpv4\?\.warp === 'on' && traceIpv6\?\.warp === 'on'/); assert.match(service, /if \(await this\.isInterfaceRunning\(\)\) \{/); assert.match(service, /link', 'delete', WARP_INTERFACE/); + assert.match(service, /WARP_IPV6_ROUTE_METRIC = 4242/); + assert.match(service, /PostUp = ip -6 route replace ::\/0 dev %i metric/); + assert.match(service, /PreDown = ip -6 route del ::\/0 dev %i metric/); + assert.match(service, /hasBoundIpv6RouteConfig/); assert.match(service, /wgcf register --accept-tos/); assert.match(service, /wgcf generate/); assert.doesNotMatch(service, /s#\^\(Address = \[\^,\]\+\),\.\*#\\1#/); From 09032ae45116c8cb8dd78ee64b6f6c5dd62d6dd7 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 06:55:48 +0800 Subject: [PATCH 11/13] feat: add dual-stack warp lifecycle controls --- libs/contract/api/controllers/warp.ts | 2 + libs/contract/api/routes.ts | 2 + libs/contract/commands/warp/index.ts | 2 + .../contract/commands/warp/install.command.ts | 14 + .../commands/warp/uninstall.command.ts | 14 + libs/contract/models/node-system.schema.ts | 28 ++ src/modules/stats/stats.service.ts | 8 +- src/modules/warp/warp.controller.ts | 16 + src/modules/warp/warp.service.ts | 335 +++++++++++++++--- src/modules/xray-core/xray.service.ts | 8 +- test/warp-status.test.mjs | 13 + 11 files changed, 394 insertions(+), 48 deletions(-) create mode 100644 libs/contract/commands/warp/install.command.ts create mode 100644 libs/contract/commands/warp/uninstall.command.ts diff --git a/libs/contract/api/controllers/warp.ts b/libs/contract/api/controllers/warp.ts index 75a792f..89c85fc 100644 --- a/libs/contract/api/controllers/warp.ts +++ b/libs/contract/api/controllers/warp.ts @@ -2,6 +2,8 @@ export const WARP_CONTROLLER = 'warp' as const; export const WARP_ROUTES = { STATUS: 'status', + INSTALL: 'install', ENABLE: 'enable', DISABLE: 'disable', + UNINSTALL: 'uninstall', } as const; diff --git a/libs/contract/api/routes.ts b/libs/contract/api/routes.ts index cbdf693..961c453 100644 --- a/libs/contract/api/routes.ts +++ b/libs/contract/api/routes.ts @@ -47,7 +47,9 @@ export const REST_API = { }, WARP: { STATUS: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.STATUS}`, + INSTALL: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.INSTALL}`, ENABLE: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.ENABLE}`, DISABLE: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.DISABLE}`, + UNINSTALL: `${ROOT}/${CONTROLLERS.WARP_CONTROLLER}/${CONTROLLERS.WARP_ROUTES.UNINSTALL}`, }, } as const; diff --git a/libs/contract/commands/warp/index.ts b/libs/contract/commands/warp/index.ts index ae39dd9..046475d 100644 --- a/libs/contract/commands/warp/index.ts +++ b/libs/contract/commands/warp/index.ts @@ -1,3 +1,5 @@ export * from './disable.command'; export * from './enable.command'; +export * from './install.command'; export * from './status.command'; +export * from './uninstall.command'; diff --git a/libs/contract/commands/warp/install.command.ts b/libs/contract/commands/warp/install.command.ts new file mode 100644 index 0000000..a28b450 --- /dev/null +++ b/libs/contract/commands/warp/install.command.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WarpStatusSchema } from '../../models'; +import { REST_API } from '../../api'; + +export namespace InstallWarpCommand { + export const url = REST_API.WARP.INSTALL; + + export const ResponseSchema = z.object({ + response: WarpStatusSchema, + }); + + export type Response = z.infer; +} diff --git a/libs/contract/commands/warp/uninstall.command.ts b/libs/contract/commands/warp/uninstall.command.ts new file mode 100644 index 0000000..243426b --- /dev/null +++ b/libs/contract/commands/warp/uninstall.command.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WarpStatusSchema } from '../../models'; +import { REST_API } from '../../api'; + +export namespace UninstallWarpCommand { + export const url = REST_API.WARP.UNINSTALL; + + export const ResponseSchema = z.object({ + response: WarpStatusSchema, + }); + + export type Response = z.infer; +} diff --git a/libs/contract/models/node-system.schema.ts b/libs/contract/models/node-system.schema.ts index cdf5a44..71fdb4e 100644 --- a/libs/contract/models/node-system.schema.ts +++ b/libs/contract/models/node-system.schema.ts @@ -14,6 +14,30 @@ const WarpTraceSchema = z.object({ colo: z.string().nullable(), }); +const PublicIpProbeSchema = z.object({ + publicIp: z.string().nullable(), + reachable: z.boolean(), + lastError: z.string().nullable(), +}); + +export const HostConnectivitySchema = z.object({ + publicIpv4: z.string().nullable(), + publicIpv6: z.string().nullable(), + supportsIpv4: z.boolean(), + supportsIpv6: z.boolean(), + ipv4: PublicIpProbeSchema.nullable(), + ipv6: PublicIpProbeSchema.nullable(), + lastError: z.string().nullable(), +}); + +export const WarpOperationSchema = z.object({ + state: z.enum(['idle', 'installing', 'enabling', 'disabling', 'uninstalling', 'error']), + startedAt: z.string().nullable(), + finishedAt: z.string().nullable(), + step: z.string().nullable(), + logs: z.array(z.string()), +}); + export const WarpStatusSchema = z.object({ installed: z.boolean(), running: z.boolean(), @@ -25,6 +49,7 @@ export const WarpStatusSchema = z.object({ colo: z.string().nullable(), ipv4: WarpTraceSchema.nullable(), ipv6: WarpTraceSchema.nullable(), + operation: WarpOperationSchema, lastError: z.string().nullable(), }); @@ -47,6 +72,7 @@ export const NodeSystemStatsSchema = z.object({ uptime: z.number(), loadAvg: z.array(z.number()), interface: z.nullable(NetworkInterfaceSchema), + host: z.optional(HostConnectivitySchema), warp: z.optional(WarpStatusSchema), }); @@ -61,3 +87,5 @@ export type TNetworkInterface = z.infer; export type TNodeSystemInfo = z.infer; export type TNodeSystem = z.infer; export type TWarpStatus = z.infer; +export type THostConnectivity = z.infer; +export type TWarpOperation = z.infer; diff --git a/src/modules/stats/stats.service.ts b/src/modules/stats/stats.service.ts index ec94d0a..e57293f 100644 --- a/src/modules/stats/stats.service.ts +++ b/src/modules/stats/stats.service.ts @@ -74,9 +74,12 @@ export class StatsService { }; } - const interfaceStats = await this.queryBus.execute(new GetInterfaceStatsQuery()); + const [interfaceStats, warpStatus, hostConnectivity] = await Promise.all([ + this.queryBus.execute(new GetInterfaceStatsQuery()), + this.warpService.getStatus(), + this.warpService.getHostConnectivity(), + ]); const systemStats = getSystemStats(); - const warpStatus = await this.warpService.getStatus(); const reportsCount = await this.queryBus.execute( new GetTorrentBlockerReportsCountQuery(), ); @@ -93,6 +96,7 @@ export class StatsService { { ...systemStats, interface: interfaceStats, + host: hostConnectivity, warp: warpStatus, }, ), diff --git a/src/modules/warp/warp.controller.ts b/src/modules/warp/warp.controller.ts index 33fe71b..de3674e 100644 --- a/src/modules/warp/warp.controller.ts +++ b/src/modules/warp/warp.controller.ts @@ -6,6 +6,8 @@ import { DisableWarpCommand, EnableWarpCommand, GetWarpStatusCommand, + InstallWarpCommand, + UninstallWarpCommand, } from '@libs/contracts/commands'; import { WARP_CONTROLLER, WARP_ROUTES } from '@libs/contracts/api'; @@ -31,10 +33,24 @@ export class WarpController { }; } + @Post(WARP_ROUTES.INSTALL) + public async install(): Promise { + return { + response: await this.warpService.install(), + }; + } + @Post(WARP_ROUTES.DISABLE) public async disable(): Promise { return { response: await this.warpService.disable(), }; } + + @Post(WARP_ROUTES.UNINSTALL) + public async uninstall(): Promise { + return { + response: await this.warpService.uninstall(), + }; + } } diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 838e30f..682c1dc 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; -import type { TWarpStatus } from '@libs/contracts/models'; +import type { THostConnectivity, TWarpOperation, TWarpStatus } from '@libs/contracts/models'; import { Injectable, Logger } from '@nestjs/common'; @@ -9,6 +9,7 @@ import { TWarpCommandResult } from './warp.types'; const WARP_INTERFACE = 'warp'; const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf'; +const WARP_TOOL_PATH = '/usr/local/bin/wgcf'; const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace'; const WGCF_RELEASES_API_URL = 'https://api.github.com/repos/ViRb3/wgcf/releases/latest'; const WARP_ENDPOINT = '162.159.192.1:2408'; @@ -33,6 +34,7 @@ case "$ARCH" in esac if ! command -v wgcf >/dev/null 2>&1; then + echo "[warp] resolving latest wgcf release" WGCF_URL="$( curl -fsSL ${WGCF_RELEASES_API_URL} \ | grep -Eo 'https://[^"]+wgcf_[^"]+_'"$WGCF_ARCH"'"?' \ @@ -45,18 +47,22 @@ if ! command -v wgcf >/dev/null 2>&1; then exit 1 fi - curl -fsSL "$WGCF_URL" -o /usr/local/bin/wgcf - chmod +x /usr/local/bin/wgcf + echo "[warp] downloading wgcf" + curl -fsSL "$WGCF_URL" -o ${WARP_TOOL_PATH} + chmod +x ${WARP_TOOL_PATH} fi TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT cd "$TMP_DIR" +echo "[warp] registering WARP account" wgcf register --accept-tos >/dev/null +echo "[warp] generating WireGuard profile" wgcf generate >/dev/null test -s wgcf-profile.conf +echo "[warp] normalizing WireGuard profile" sed -i -E '/^DNS =/d' wgcf-profile.conf sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_ENDPOINT}#' wgcf-profile.conf grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf @@ -72,6 +78,7 @@ grep -q '^PersistentKeepalive = ' wgcf-profile.conf \ || sed -i '/^Endpoint =/a PersistentKeepalive = 25' wgcf-profile.conf mkdir -p /etc/wireguard +echo "[warp] installing WireGuard profile" install -m 600 wgcf-profile.conf ${WARP_CONFIG_PATH} `; @@ -82,10 +89,42 @@ type TWarpTrace = { }; type TWarpTraceIpVersion = '4' | '6'; +type TPublicIpProbe = { + publicIp: string | null; + reachable: boolean; + lastError: string | null; +}; +type TWarpOperationState = TWarpOperation['state']; @Injectable() export class WarpService { private readonly logger = new Logger(WarpService.name); + private operation: TWarpOperation = this.createIdleOperation(); + + public async getHostConnectivity(): Promise { + if (process.platform !== 'linux') { + return this.hostConnectivity({ + lastError: 'Host connectivity probing is supported only on Linux nodes', + }); + } + + const [ipv4, ipv6] = await Promise.all([ + this.getPublicIpProbe('4'), + this.getPublicIpProbe('6'), + ]); + + const lastError = [ipv4.lastError, ipv6.lastError].filter(Boolean).join('; ') || null; + + return this.hostConnectivity({ + publicIpv4: ipv4.publicIp, + publicIpv6: ipv6.publicIp, + supportsIpv4: ipv4.reachable, + supportsIpv6: ipv6.reachable, + ipv4, + ipv6, + lastError, + }); + } public async getStatus(lastError: string | null = null): Promise { if (process.platform !== 'linux') { @@ -109,42 +148,81 @@ export class WarpService { colo: traceIpv4?.colo ?? traceIpv6?.colo ?? null, ipv4: traceIpv4, ipv6: traceIpv6, + operation: this.operation, lastError, }); } - public async enable(): Promise { + public async install(): Promise { if (process.platform !== 'linux') { return this.getStatus('WARP is supported only on Linux nodes'); } - if (await this.isInterfaceRunning()) { - const currentStatus = await this.getStatus(); - if ( - currentStatus.warp === 'on' && - this.hasDualStackConfig() && - this.hasBoundIpv6RouteConfig() - ) { - return currentStatus; + return this.withOperation('installing', 'Preparing WARP installation', async () => { + const permissionError = await this.getPermissionError(); + if (permissionError) { + this.failOperation(permissionError); + return this.getStatus(permissionError); } - } - const permissionError = await this.getPermissionError(); - if (permissionError) { - return this.getStatus(permissionError); - } + try { + await this.installIfMissingOrSingleStack(); + this.appendOperationLog('Normalizing WARP configuration'); + this.normalizeWarpConfig(); + this.finishOperation('WARP installed'); + return await this.getStatus(); + } catch (error) { + const message = this.toSafeError(error); + this.logger.warn(`Failed to install WARP: ${message}`); + this.failOperation(message); + return this.getStatus(message); + } + }); + } - try { - await this.installIfMissingOrSingleStack(); - this.normalizeWarpConfig(); - await this.stopRunningInterface(); - await this.execFixed('/usr/bin/wg-quick', ['up', WARP_INTERFACE], 20_000); - return await this.getStatus(); - } catch (error) { - const message = this.toSafeError(error); - this.logger.warn(`Failed to enable WARP: ${message}`); - return this.getStatus(message); + public async enable(): Promise { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); } + + return this.withOperation('enabling', 'Preparing WARP enable', async () => { + if (await this.isInterfaceRunning()) { + const currentStatus = await this.getStatus(); + if ( + currentStatus.warp === 'on' && + this.hasDualStackConfig() && + this.hasBoundIpv6RouteConfig() + ) { + this.finishOperation('WARP is already enabled'); + return currentStatus; + } + } + + const permissionError = await this.getPermissionError(); + if (permissionError) { + this.failOperation(permissionError); + return this.getStatus(permissionError); + } + + try { + await this.installIfMissingOrSingleStack(); + this.appendOperationLog('Normalizing WARP configuration'); + this.normalizeWarpConfig(); + this.appendOperationLog('Stopping any existing WARP interface'); + await this.stopRunningInterface(); + this.appendOperationLog('Starting WireGuard interface'); + await this.execFixed('/usr/bin/wg-quick', ['up', WARP_INTERFACE], 20_000, { + onOutput: (line) => this.appendOperationLog(line), + }); + this.finishOperation('WARP enabled'); + return await this.getStatus(); + } catch (error) { + const message = this.toSafeError(error); + this.logger.warn(`Failed to enable WARP: ${message}`); + this.failOperation(message); + return this.getStatus(message); + } + }); } public async disable(): Promise { @@ -152,28 +230,64 @@ export class WarpService { return this.getStatus('WARP is supported only on Linux nodes'); } - try { - if (!(await this.isInterfaceRunning())) { + return this.withOperation('disabling', 'Preparing WARP disable', async () => { + try { + if (!(await this.isInterfaceRunning())) { + this.finishOperation('WARP is already stopped'); + return await this.getStatus(); + } + + if (this.hasWarpConfig()) { + this.appendOperationLog('Stopping WireGuard interface'); + await this.execFixed('/usr/bin/wg-quick', ['down', WARP_INTERFACE], 20_000, { + onOutput: (line) => this.appendOperationLog(line), + }); + } else { + this.appendOperationLog('Deleting orphaned WARP interface'); + await this.deleteInterface(); + } + + this.finishOperation('WARP disabled'); return await this.getStatus(); + } catch (error) { + try { + this.appendOperationLog('Deleting WARP interface after disable failure'); + await this.deleteInterface(); + this.finishOperation('WARP disabled with fallback cleanup'); + return await this.getStatus(); + } catch (fallbackError) { + const message = `${this.toSafeError(error)}; fallback delete failed: ${this.toSafeError(fallbackError)}`; + this.logger.warn(`Failed to disable WARP: ${message}`); + this.failOperation(message); + return this.getStatus(message); + } } + }); + } - if (this.hasWarpConfig()) { - await this.execFixed('/usr/bin/wg-quick', ['down', WARP_INTERFACE], 20_000); - } else { - await this.deleteInterface(); - } + public async uninstall(): Promise { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); + } - return await this.getStatus(); - } catch (error) { + return this.withOperation('uninstalling', 'Preparing WARP uninstall', async () => { try { - await this.deleteInterface(); + if (await this.isInterfaceRunning()) { + this.appendOperationLog('Stopping WARP before uninstall'); + await this.stopRunningInterface(); + } + + this.removeFileIfExists(WARP_CONFIG_PATH); + this.removeFileIfExists(WARP_TOOL_PATH); + this.finishOperation('WARP uninstalled'); return await this.getStatus(); - } catch (fallbackError) { - const message = `${this.toSafeError(error)}; fallback delete failed: ${this.toSafeError(fallbackError)}`; - this.logger.warn(`Failed to disable WARP: ${message}`); + } catch (error) { + const message = this.toSafeError(error); + this.logger.warn(`Failed to uninstall WARP: ${message}`); + this.failOperation(message); return this.getStatus(message); } - } + }); } private getEffectiveWarpState( @@ -269,10 +383,17 @@ export class WarpService { } private async installIfMissingOrSingleStack(): Promise { - if (this.hasWarpConfig() && this.hasDualStackConfig()) return; + if (this.hasWarpConfig() && this.hasDualStackConfig()) { + this.appendOperationLog('Existing WARP profile is already dual-stack'); + return; + } + this.appendOperationLog('Ensuring WARP runtime packages'); await this.ensureAlpinePackages(); - await this.execFixed('/bin/bash', ['-o', 'pipefail', '-c', WARP_INSTALL_SCRIPT], 180_000); + this.appendOperationLog('Installing WARP profile'); + await this.execFixed('/bin/bash', ['-o', 'pipefail', '-c', WARP_INSTALL_SCRIPT], 180_000, { + onOutput: (line) => this.appendOperationLog(line), + }); } private async ensureAlpinePackages(): Promise { @@ -374,6 +495,38 @@ export class WarpService { } } + private async getPublicIpProbe(ipVersion: TWarpTraceIpVersion): Promise { + try { + const result = await this.execFixed( + '/usr/bin/curl', + ['--max-time', '5', `-${ipVersion}`, '-fsSL', WARP_TRACE_URL], + 8_000, + ); + const publicIp = this.parseTraceField(result.stdout, 'ip'); + + return { + publicIp, + reachable: publicIp !== null, + lastError: null, + }; + } catch (error) { + return { + publicIp: null, + reachable: false, + lastError: this.toSafeError(error), + }; + } + } + + private parseTraceField(output: string, field: string): string | null { + const line = output + .split('\n') + .map((item) => item.trim()) + .find((item) => item.startsWith(`${field}=`)); + + return line?.slice(field.length + 1) ?? null; + } + private async getPermissionError(): Promise { if (!this.hasNetAdminCapability()) { return 'NET_ADMIN capability is required to manage WARP'; @@ -410,6 +563,9 @@ export class WarpService { command: string, args: string[], timeout: number, + options: { + onOutput?: (line: string) => void; + } = {}, ): Promise { return await new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -441,10 +597,12 @@ export class WarpService { child.stdout?.on('data', (chunk: Buffer) => { stdout = this.appendOutputTail(stdout, chunk); + this.emitOutputLines(chunk, options.onOutput); }); child.stderr?.on('data', (chunk: Buffer) => { stderr = this.appendOutputTail(stderr, chunk); + this.emitOutputLines(chunk, options.onOutput); }); child.on('error', (error) => { @@ -488,11 +646,100 @@ export class WarpService { colo: null, ipv4: null, ipv6: null, + operation: this.operation, lastError: null, ...partial, }; } + private hostConnectivity(partial: Partial): THostConnectivity { + return { + publicIpv4: null, + publicIpv6: null, + supportsIpv4: false, + supportsIpv6: false, + ipv4: null, + ipv6: null, + lastError: null, + ...partial, + }; + } + + private createIdleOperation(): TWarpOperation { + return { + state: 'idle', + startedAt: null, + finishedAt: null, + step: null, + logs: [], + }; + } + + private async withOperation( + state: TWarpOperationState, + step: string, + action: () => Promise, + ): Promise { + this.operation = { + state, + startedAt: new Date().toISOString(), + finishedAt: null, + step, + logs: [step], + }; + + return await action(); + } + + private appendOperationLog(message: string): void { + const line = this.limitOutput(message.trim()); + if (!line) return; + + this.operation = { + ...this.operation, + step: line, + logs: [...this.operation.logs, line].slice(-50), + }; + } + + private finishOperation(step: string): void { + this.appendOperationLog(step); + this.operation = { + ...this.operation, + state: 'idle', + finishedAt: new Date().toISOString(), + step, + }; + } + + private failOperation(step: string): void { + this.appendOperationLog(step); + this.operation = { + ...this.operation, + state: 'error', + finishedAt: new Date().toISOString(), + step, + }; + } + + private emitOutputLines(chunk: Buffer, onOutput: undefined | ((line: string) => void)): void { + if (!onOutput) return; + + chunk + .toString('utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .forEach(onOutput); + } + + private removeFileIfExists(path: string): void { + if (!existsSync(path)) return; + + this.appendOperationLog(`Removing ${path}`); + unlinkSync(path); + } + private toSafeError(error: unknown): string { if (error instanceof Error) { return this.limitOutput(error.message); diff --git a/src/modules/xray-core/xray.service.ts b/src/modules/xray-core/xray.service.ts index 27c7170..35dd1c5 100644 --- a/src/modules/xray-core/xray.service.ts +++ b/src/modules/xray-core/xray.service.ts @@ -100,14 +100,18 @@ export class XrayService implements OnApplicationBootstrap { body: StartXrayCommand.Request, ip: string, ): Promise> { - const interfaceStats = await this.queryBus.execute(new GetInterfaceStatsQuery()); - const warpStatus = await this.warpService.getStatus(); + const [interfaceStats, warpStatus, hostConnectivity] = await Promise.all([ + this.queryBus.execute(new GetInterfaceStatsQuery()), + this.warpService.getStatus(), + this.warpService.getHostConnectivity(), + ]); const tm = performance.now(); const system = { info: getSystemInfo(), stats: { ...getSystemStats(), interface: interfaceStats, + host: hostConnectivity, warp: warpStatus, }, }; diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index fea4756..8afee36 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -18,11 +18,15 @@ describe('WARP contract shape', () => { assert.match(controllers, /warp/); assert.match(routes, /WARP/); assert.match(routes, /STATUS/); + assert.match(routes, /INSTALL/); assert.match(routes, /ENABLE/); assert.match(routes, /DISABLE/); + assert.match(routes, /UNINSTALL/); assert.match(commands, /warp/); assert.match(nodeSystem, /WarpStatusSchema/); + assert.match(nodeSystem, /HostConnectivitySchema/); assert.match(nodeSystem, /warp: z\.optional/); + assert.match(nodeSystem, /host: z\.optional/); }); it('declares WARP service without a generic command executor', () => { @@ -33,8 +37,10 @@ describe('WARP contract shape', () => { assert.match(service, /class WarpService/); assert.match(service, /spawn/); assert.match(service, /getStatus/); + assert.match(service, /install/); assert.match(service, /enable/); assert.match(service, /disable/); + assert.match(service, /uninstall/); assert.doesNotMatch(service, /executeCommand/); assert.doesNotMatch(service, /runShell/); assert.match(controller, /class WarpController/); @@ -55,6 +61,10 @@ describe('WARP contract shape', () => { assert.match(service, /PostUp = ip -6 route replace ::\/0 dev %i metric/); assert.match(service, /PreDown = ip -6 route del ::\/0 dev %i metric/); assert.match(service, /hasBoundIpv6RouteConfig/); + assert.match(service, /getHostConnectivity/); + assert.match(service, /operation/); + assert.match(service, /appendOperationLog/); + assert.match(service, /onOutput/); assert.match(service, /wgcf register --accept-tos/); assert.match(service, /wgcf generate/); assert.doesNotMatch(service, /s#\^\(Address = \[\^,\]\+\),\.\*#\\1#/); @@ -81,6 +91,9 @@ describe('WARP contract shape', () => { assert.match(dockerfile, /openresolv/); assert.match(nodeSystem, /publicIpv4: z\.string\(\)\.nullable\(\)/); assert.match(nodeSystem, /publicIpv6: z\.string\(\)\.nullable\(\)/); + assert.match(nodeSystem, /supportsIpv4: z\.boolean\(\)/); + assert.match(nodeSystem, /supportsIpv6: z\.boolean\(\)/); + assert.match(nodeSystem, /WarpOperationSchema/); assert.match(nodeSystem, /ipv4: WarpTraceSchema\.nullable\(\)/); assert.match(nodeSystem, /ipv6: WarpTraceSchema\.nullable\(\)/); }); From 41e3fddb437497286a20b5e4b4e9666932997c09 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Thu, 4 Jun 2026 09:25:41 +0800 Subject: [PATCH 12/13] fix: stabilize dual-stack warp install --- src/modules/warp/warp.service.ts | 131 ++++++++++++++++++++++++++----- test/warp-status.test.mjs | 9 +++ 2 files changed, 122 insertions(+), 18 deletions(-) diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index 682c1dc..a016e09 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -16,6 +16,7 @@ const WARP_ENDPOINT = '162.159.192.1:2408'; const WARP_IPV6_ROUTE_METRIC = 4242; const WARP_IPV6_ROUTE_POST_UP = `PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}`; const WARP_IPV6_ROUTE_PRE_DOWN = `PreDown = ip -6 route del ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC} 2>/dev/null || true`; +const WARP_MANAGED_IPV6_ROUTE_LINES = [WARP_IPV6_ROUTE_POST_UP, WARP_IPV6_ROUTE_PRE_DOWN]; const WARP_OUTPUT_TAIL_BYTES = 64 * 1024; const WARP_KILL_GRACE_MS = 5_000; const WARP_INSTALL_SCRIPT = String.raw` @@ -58,6 +59,8 @@ cd "$TMP_DIR" echo "[warp] registering WARP account" wgcf register --accept-tos >/dev/null +echo "[warp] updating WARP account" +wgcf update >/dev/null echo "[warp] generating WireGuard profile" wgcf generate >/dev/null test -s wgcf-profile.conf @@ -66,14 +69,6 @@ echo "[warp] normalizing WireGuard profile" sed -i -E '/^DNS =/d' wgcf-profile.conf sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_ENDPOINT}#' wgcf-profile.conf grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf -grep -q '^PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}$' wgcf-profile.conf || awk ' - { print } - /^Table = off$/ && !done { - print "PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}" - print "PreDown = ip -6 route del ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC} 2>/dev/null || true" - done=1 - } -' wgcf-profile.conf > wgcf-profile.conf.next && mv wgcf-profile.conf.next wgcf-profile.conf grep -q '^PersistentKeepalive = ' wgcf-profile.conf \ || sed -i '/^Endpoint =/a PersistentKeepalive = 25' wgcf-profile.conf @@ -191,7 +186,9 @@ export class WarpService { if ( currentStatus.warp === 'on' && this.hasDualStackConfig() && - this.hasBoundIpv6RouteConfig() + this.hasBoundIpv6RouteConfig() && + this.hasExpectedEndpointConfig() && + !this.hasDeprecatedIpv6EndpointRouteConfig() ) { this.finishOperation('WARP is already enabled'); return currentStatus; @@ -349,10 +346,13 @@ export class WarpService { ); } + updated = this.removeDeprecatedIpv6EndpointRouteLines(updated); + if (!this.hasBoundIpv6RouteConfig(updated)) { + updated = this.removeManagedIpv6RouteLines(updated); updated = updated.replace( /^Table = off$/m, - (line) => `${line}\n${WARP_IPV6_ROUTE_POST_UP}\n${WARP_IPV6_ROUTE_PRE_DOWN}`, + (line) => `${line}\n${WARP_MANAGED_IPV6_ROUTE_LINES.join('\n')}`, ); } @@ -437,9 +437,69 @@ export class WarpService { if (config === null && !this.hasWarpConfig()) return false; const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8'); - return ( - warpConfig.includes(WARP_IPV6_ROUTE_POST_UP) && - warpConfig.includes(WARP_IPV6_ROUTE_PRE_DOWN) + return WARP_MANAGED_IPV6_ROUTE_LINES.every((line) => warpConfig.includes(line)); + } + + private hasExpectedEndpointConfig(config: string | null = null): boolean { + if (config === null && !this.hasWarpConfig()) return false; + + const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8'); + return warpConfig.includes(`Endpoint = ${WARP_ENDPOINT}`); + } + + private hasDeprecatedIpv6EndpointRouteConfig(config: string | null = null): boolean { + if (config === null && !this.hasWarpConfig()) return false; + + const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8'); + return warpConfig.split('\n').some((line) => this.isDeprecatedIpv6EndpointRouteLine(line)); + } + + private removeManagedIpv6RouteLines(config: string): string { + return config + .split('\n') + .filter((line) => !this.isManagedIpv6RouteLine(line)) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); + } + + private removeDeprecatedIpv6EndpointRouteLines(config: string): string { + return config + .split('\n') + .filter((line) => !this.isDeprecatedIpv6EndpointRouteLine(line)) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); + } + + private isManagedIpv6RouteLine(line: string): boolean { + if (WARP_MANAGED_IPV6_ROUTE_LINES.includes(line)) return true; + if (/^PostUp = ip -6 route replace ::\/0 dev %i metric \d+$/.test(line)) return true; + if ( + /^PreDown = ip -6 route del ::\/0 dev %i metric \d+ 2>\/dev\/null \|\| true$/.test(line) + ) { + return true; + } + if ( + /^PreDown = ip -6 route del 2606:4700:d0::a29f:c001\/128 2>\/dev\/null \|\| true$/.test( + line, + ) + ) { + return true; + } + + return false; + } + + private isDeprecatedIpv6EndpointRouteLine(line: string): boolean { + if ( + /^PreDown = ip -6 route del 2606:4700:d0::a29f:c001\/128 2>\/dev\/null \|\| true$/.test( + line, + ) + ) { + return true; + } + + return /^PostUp = endpoint_route=.*ip -6 route replace 2606:4700:d0::a29f:c001\/128 .* metric \d+$/.test( + line, ); } @@ -497,11 +557,22 @@ export class WarpService { private async getPublicIpProbe(ipVersion: TWarpTraceIpVersion): Promise { try { - const result = await this.execFixed( - '/usr/bin/curl', - ['--max-time', '5', `-${ipVersion}`, '-fsSL', WARP_TRACE_URL], - 8_000, - ); + const hostInterface = await this.getHostDefaultInterface(ipVersion); + const isWarpRunning = await this.isInterfaceRunning(); + + if (ipVersion === '6' && hostInterface === null && isWarpRunning) { + return { + publicIp: null, + reachable: false, + lastError: 'No non-WARP IPv6 default interface is available', + }; + } + + const args = ['--max-time', '5', `-${ipVersion}`]; + if (hostInterface) args.push('--interface', hostInterface); + args.push('-fsSL', WARP_TRACE_URL); + + const result = await this.execFixed('/usr/bin/curl', args, 8_000); const publicIp = this.parseTraceField(result.stdout, 'ip'); return { @@ -518,6 +589,30 @@ export class WarpService { } } + private async getHostDefaultInterface(ipVersion: TWarpTraceIpVersion): Promise { + const args = + ipVersion === '4' ? ['route', 'show', 'default'] : ['-6', 'route', 'show', 'default']; + + for (const command of ['/sbin/ip', '/usr/sbin/ip']) { + try { + const result = await this.execFixed(command, args, 5_000); + const defaultRoute = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !line.includes(`dev ${WARP_INTERFACE}`)) + .find((line) => line.includes('default')); + + const match = defaultRoute?.match(/\bdev\s+(\S+)/); + if (match?.[1]) return match[1]; + } catch { + // Try the next iproute2 location used by common Linux distributions. + } + } + + return null; + } + private parseTraceField(output: string, field: string): string | null { const line = output .split('\n') diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index 8afee36..a18206e 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -66,16 +66,25 @@ describe('WARP contract shape', () => { assert.match(service, /appendOperationLog/); assert.match(service, /onOutput/); assert.match(service, /wgcf register --accept-tos/); + assert.match(service, /wgcf update/); assert.match(service, /wgcf generate/); assert.doesNotMatch(service, /s#\^\(Address = \[\^,\]\+\),\.\*#\\1#/); assert.match(service, /WGCF_RELEASES_API_URL/); assert.match(service, /WARP_ENDPOINT = '162\.159\.192\.1:2408'/); assert.match(service, /Endpoint = \$\{WARP_ENDPOINT\}/); + assert.doesNotMatch(service, /WARP_ENDPOINT = 'engage\.cloudflareclient\.com:2408'/); + assert.doesNotMatch(service, /WARP_IPV6_ENDPOINT_ROUTE_POST_UP/); assert.match(service, /getTrace\('4'\)/); assert.match(service, /getTrace\('6'\)/); assert.match(service, /`\-\$\{ipVersion\}`/); assert.match(service, /'--interface',\s+WARP_INTERFACE/); assert.match(service, /hasDualStackConfig/); + assert.match(service, /hasExpectedEndpointConfig/); + assert.match(service, /hasDeprecatedIpv6EndpointRouteConfig/); + assert.match(service, /!this\.hasDeprecatedIpv6EndpointRouteConfig\(\)/); + assert.match(service, /getHostDefaultInterface/); + assert.match(service, /`dev \$\{WARP_INTERFACE\}`/); + assert.match(service, /'--interface', hostInterface/); assert.match(service, /normalizeWarpConfig/); assert.match(service, /stopRunningInterface/); assert.match(service, /install -m 600 wgcf-profile\.conf/); From c48679f94b172adfc028a3ffadd5f9ab1fdb9b43 Mon Sep 17 00:00:00 2001 From: RickeyRen Date: Sat, 6 Jun 2026 05:26:39 +0800 Subject: [PATCH 13/13] feat: expose IP country metadata --- libs/contract/models/node-system.schema.ts | 2 ++ src/modules/warp/warp.service.ts | 17 +++++++++++++++++ test/warp-status.test.mjs | 3 +++ 3 files changed, 22 insertions(+) diff --git a/libs/contract/models/node-system.schema.ts b/libs/contract/models/node-system.schema.ts index 71fdb4e..60293ee 100644 --- a/libs/contract/models/node-system.schema.ts +++ b/libs/contract/models/node-system.schema.ts @@ -10,12 +10,14 @@ export const NetworkInterfaceSchema = z.object({ const WarpTraceSchema = z.object({ publicIp: z.string().nullable(), + countryCode: z.string().nullable(), warp: z.enum(['on', 'off', 'unknown']), colo: z.string().nullable(), }); const PublicIpProbeSchema = z.object({ publicIp: z.string().nullable(), + countryCode: z.string().nullable(), reachable: z.boolean(), lastError: z.string().nullable(), }); diff --git a/src/modules/warp/warp.service.ts b/src/modules/warp/warp.service.ts index a016e09..34e3ee2 100644 --- a/src/modules/warp/warp.service.ts +++ b/src/modules/warp/warp.service.ts @@ -79,6 +79,7 @@ install -m 600 wgcf-profile.conf ${WARP_CONFIG_PATH} type TWarpTrace = { publicIp: string | null; + countryCode: string | null; warp: TWarpStatus['warp']; colo: string | null; }; @@ -86,6 +87,7 @@ type TWarpTrace = { type TWarpTraceIpVersion = '4' | '6'; type TPublicIpProbe = { publicIp: string | null; + countryCode: string | null; reachable: boolean; lastError: string | null; }; @@ -547,6 +549,7 @@ export class WarpService { const warp = fields.get('warp'); return { publicIp: fields.get('ip') ?? null, + countryCode: this.normalizeCountryCode(fields.get('loc') ?? null), warp: warp === 'on' || warp === 'off' ? warp : 'unknown', colo: fields.get('colo') ?? null, }; @@ -563,6 +566,7 @@ export class WarpService { if (ipVersion === '6' && hostInterface === null && isWarpRunning) { return { publicIp: null, + countryCode: null, reachable: false, lastError: 'No non-WARP IPv6 default interface is available', }; @@ -577,12 +581,14 @@ export class WarpService { return { publicIp, + countryCode: this.parseCountryCode(result.stdout), reachable: publicIp !== null, lastError: null, }; } catch (error) { return { publicIp: null, + countryCode: null, reachable: false, lastError: this.toSafeError(error), }; @@ -622,6 +628,17 @@ export class WarpService { return line?.slice(field.length + 1) ?? null; } + private parseCountryCode(output: string): string | null { + return this.normalizeCountryCode(this.parseTraceField(output, 'loc')); + } + + private normalizeCountryCode(countryCode: string | null): string | null { + if (!countryCode) return null; + + const normalized = countryCode.trim().toUpperCase(); + return /^[A-Z]{2}$/.test(normalized) ? normalized : null; + } + private async getPermissionError(): Promise { if (!this.hasNetAdminCapability()) { return 'NET_ADMIN capability is required to manage WARP'; diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs index a18206e..8674bb4 100644 --- a/test/warp-status.test.mjs +++ b/test/warp-status.test.mjs @@ -100,10 +100,13 @@ describe('WARP contract shape', () => { assert.match(dockerfile, /openresolv/); assert.match(nodeSystem, /publicIpv4: z\.string\(\)\.nullable\(\)/); assert.match(nodeSystem, /publicIpv6: z\.string\(\)\.nullable\(\)/); + assert.match(nodeSystem, /countryCode: z\.string\(\)\.nullable\(\)/); assert.match(nodeSystem, /supportsIpv4: z\.boolean\(\)/); assert.match(nodeSystem, /supportsIpv6: z\.boolean\(\)/); assert.match(nodeSystem, /WarpOperationSchema/); assert.match(nodeSystem, /ipv4: WarpTraceSchema\.nullable\(\)/); assert.match(nodeSystem, /ipv6: WarpTraceSchema\.nullable\(\)/); + assert.match(service, /parseCountryCode/); + assert.match(service, /normalizeCountryCode/); }); });