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/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/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..89c85fc --- /dev/null +++ b/libs/contract/api/controllers/warp.ts @@ -0,0 +1,9 @@ +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 b86127e..961c453 100644 --- a/libs/contract/api/routes.ts +++ b/libs/contract/api/routes.ts @@ -45,4 +45,11 @@ 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}`, + 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/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..046475d --- /dev/null +++ b/libs/contract/commands/warp/index.ts @@ -0,0 +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/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/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 3c237b8..60293ee 100644 --- a/libs/contract/models/node-system.schema.ts +++ b/libs/contract/models/node-system.schema.ts @@ -8,6 +8,53 @@ export const NetworkInterfaceSchema = z.object({ txTotal: z.number(), }); +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(), +}); + +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(), + 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(), + operation: WarpOperationSchema, + lastError: z.string().nullable(), +}); + export const NodeSystemInfoSchema = z.object({ arch: z.string(), cpus: z.number().int(), @@ -27,6 +74,8 @@ 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), }); export type TNodeSystemStats = z.infer; @@ -39,3 +88,6 @@ 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; +export type THostConnectivity = z.infer; +export type TWarpOperation = z.infer; diff --git a/src/modules/remnawave-node.modules.ts b/src/modules/remnawave-node.modules.ts index a2e0654..87848aa 100644 --- a/src/modules/remnawave-node.modules.ts +++ b/src/modules/remnawave-node.modules.ts @@ -5,9 +5,10 @@ import { HandlerModule } from './handler/handler.module'; import { PluginModule } from './_plugin/plugin.module'; import { XrayModule } from './xray-core/xray.module'; import { StatsModule } from './stats/stats.module'; +import { WarpModule } from './warp/warp.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..ad20a1e 100644 --- a/src/modules/stats/stats.module.ts +++ b/src/modules/stats/stats.module.ts @@ -2,9 +2,10 @@ import { CqrsModule } from '@nestjs/cqrs'; import { Module } from '@nestjs/common'; import { StatsController } from './stats.controller'; +import { WarpModule } from '../warp/warp.module'; 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..e57293f 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); @@ -72,7 +74,11 @@ 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 reportsCount = await this.queryBus.execute( new GetTorrentBlockerReportsCountQuery(), @@ -90,6 +96,8 @@ 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 new file mode 100644 index 0000000..de3674e --- /dev/null +++ b/src/modules/warp/warp.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Get, Post, UseFilters, UseGuards } from '@nestjs/common'; + +import { JwtDefaultGuard } from '@common/guards/jwt-guards'; +import { HttpExceptionFilter } from '@common/exception'; +import { + DisableWarpCommand, + EnableWarpCommand, + GetWarpStatusCommand, + InstallWarpCommand, + UninstallWarpCommand, +} from '@libs/contracts/commands'; +import { WARP_CONTROLLER, WARP_ROUTES } from '@libs/contracts/api'; + +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.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.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..34e3ee2 --- /dev/null +++ b/src/modules/warp/warp.service.ts @@ -0,0 +1,890 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; + +import type { THostConnectivity, TWarpOperation, TWarpStatus } from '@libs/contracts/models'; + +import { Injectable, Logger } from '@nestjs/common'; + +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'; +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` +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 + echo "[warp] resolving latest wgcf release" + 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 + + 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] updating WARP account" +wgcf update >/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 +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} +`; + +type TWarpTrace = { + publicIp: string | null; + countryCode: string | null; + warp: TWarpStatus['warp']; + colo: string | null; +}; + +type TWarpTraceIpVersion = '4' | '6'; +type TPublicIpProbe = { + publicIp: string | null; + countryCode: 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') { + return this.status({ lastError: lastError ?? 'WARP is supported only on Linux nodes' }); + } + + const running = await this.isInterfaceRunning(); + const installed = this.hasWarpConfig() || running; + const hasWireGuardHandshake = running ? await this.hasWireGuardHandshake() : false; + 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: 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, + operation: this.operation, + lastError, + }); + } + + public async install(): Promise { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); + } + + return this.withOperation('installing', 'Preparing WARP installation', async () => { + 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.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); + } + }); + } + + 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.hasExpectedEndpointConfig() && + !this.hasDeprecatedIpv6EndpointRouteConfig() + ) { + 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 { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); + } + + 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); + } + } + }); + } + + public async uninstall(): Promise { + if (process.platform !== 'linux') { + return this.getStatus('WARP is supported only on Linux nodes'); + } + + return this.withOperation('uninstalling', 'Preparing WARP uninstall', async () => { + try { + 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 (error) { + const message = this.toSafeError(error); + this.logger.warn(`Failed to uninstall WARP: ${message}`); + this.failOperation(message); + return this.getStatus(message); + } + }); + } + + private getEffectiveWarpState( + running: boolean, + hasWireGuardHandshake: boolean, + traceIpv4: TWarpTrace | null, + traceIpv6: TWarpTrace | null, + ): TWarpStatus['warp'] { + if (!running) return 'off'; + 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 traceIpv4?.warp ?? traceIpv6?.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 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`, + ); + } + + updated = this.removeDeprecatedIpv6EndpointRouteLines(updated); + + if (!this.hasBoundIpv6RouteConfig(updated)) { + updated = this.removeManagedIpv6RouteLines(updated); + updated = updated.replace( + /^Table = off$/m, + (line) => `${line}\n${WARP_MANAGED_IPV6_ROUTE_LINES.join('\n')}`, + ); + } + + if (updated !== original) { + writeFileSync(WARP_CONFIG_PATH, updated, { mode: 0o600 }); + } + } + + 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 installIfMissingOrSingleStack(): Promise { + if (this.hasWarpConfig() && this.hasDualStackConfig()) { + this.appendOperationLog('Existing WARP profile is already dual-stack'); + return; + } + + this.appendOperationLog('Ensuring WARP runtime packages'); + await this.ensureAlpinePackages(); + 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 { + if (!existsSync('/sbin/apk')) return; + + await this.execFixed( + '/sbin/apk', + [ + 'add', + '--no-cache', + 'bash', + 'ca-certificates', + 'curl', + 'iproute2', + 'openresolv', + 'wireguard-tools', + ], + 120_000, + ); + } + + private hasWarpConfig(): boolean { + 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 hasBoundIpv6RouteConfig(config: string | null = null): boolean { + if (config === null && !this.hasWarpConfig()) return false; + + const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8'); + 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, + ); + } + + 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(ipVersion: TWarpTraceIpVersion): Promise { + try { + const result = await this.execFixed( + '/usr/bin/curl', + [ + '--max-time', + '5', + `-${ipVersion}`, + '--interface', + WARP_INTERFACE, + '-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 { + publicIp: fields.get('ip') ?? null, + countryCode: this.normalizeCountryCode(fields.get('loc') ?? null), + warp: warp === 'on' || warp === 'off' ? warp : 'unknown', + colo: fields.get('colo') ?? null, + }; + } catch { + return null; + } + } + + private async getPublicIpProbe(ipVersion: TWarpTraceIpVersion): Promise { + try { + const hostInterface = await this.getHostDefaultInterface(ipVersion); + const isWarpRunning = await this.isInterfaceRunning(); + + if (ipVersion === '6' && hostInterface === null && isWarpRunning) { + return { + publicIp: null, + countryCode: 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 { + publicIp, + countryCode: this.parseCountryCode(result.stdout), + reachable: publicIp !== null, + lastError: null, + }; + } catch (error) { + return { + publicIp: null, + countryCode: null, + reachable: false, + lastError: this.toSafeError(error), + }; + } + } + + 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') + .map((item) => item.trim()) + .find((item) => item.startsWith(`${field}=`)); + + 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'; + } + + 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, + options: { + onOutput?: (line: string) => void; + } = {}, + ): Promise { + 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(); + }; + + 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) => { + 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 { + return { + installed: false, + running: false, + interfaceName: null, + publicIp: null, + publicIpv4: null, + publicIpv6: null, + warp: 'unknown', + 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); + } + + return this.limitOutput(String(error)); + } + + private limitOutput(value: string): string { + 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/src/modules/warp/warp.types.ts b/src/modules/warp/warp.types.ts new file mode 100644 index 0000000..40594ec --- /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 = { + lastError: string | null; +} & TWarpStatus; diff --git a/src/modules/xray-core/xray.module.ts b/src/modules/xray-core/xray.module.ts index ddaf8d4..84630b6 100644 --- a/src/modules/xray-core/xray.module.ts +++ b/src/modules/xray-core/xray.module.ts @@ -3,11 +3,12 @@ import { CqrsModule } from '@nestjs/cqrs'; import { InternalModule } from '../internal/internal.module'; import { XrayController } from './xray.controller'; +import { WarpModule } from '../warp/warp.module'; 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..35dd1c5 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'), @@ -98,12 +100,20 @@ export class XrayService implements OnApplicationBootstrap { body: StartXrayCommand.Request, ip: string, ): Promise> { - 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 tm = performance.now(); const system = { info: getSystemInfo(), - stats: getSystemStats(), - interface: interfaceStats, + stats: { + ...getSystemStats(), + interface: interfaceStats, + host: hostConnectivity, + warp: warpStatus, + }, }; try { diff --git a/test/warp-status.test.mjs b/test/warp-status.test.mjs new file mode 100644 index 0000000..8674bb4 --- /dev/null +++ b/test/warp-status.test.mjs @@ -0,0 +1,112 @@ +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, /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', () => { + 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, /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/); + 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'); + 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, /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, /getHostConnectivity/); + assert.match(service, /operation/); + 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/); + 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/); + 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/); + }); +});