From a70565f2fc6c33abe99cd6dd1dbfdd1519f9bb06 Mon Sep 17 00:00:00 2001 From: Rxflex <39628283+Rxflex@users.noreply.github.com> Date: Tue, 12 May 2026 02:24:35 +0200 Subject: [PATCH] feat(veil-core): add Veil VPN protocol module alongside Xray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class support for the Veil server (https://github.com/redstone-md/veil) to Remnawave Node. The implementation mirrors the existing xray-core module so the panel side can drive Veil through the same start / stop / healthcheck shape it already uses for Xray, no panel-side special cases. What ships ========== * New REST surface under /node/veil: POST /node/veil/start — push server.yaml + start daemon GET /node/veil/stop — graceful stop GET /node/veil/healthcheck — node + daemon liveness * libs/contract/commands/veil — Zod schemas for the three commands. StartVeilCommand carries: - serverConfig: the full server.yaml as a string (panel side stays the source of truth for transport topology, decoy origin, transports, etc.) - internals.configHash: SHA-256 the node uses to short-circuit a restart when the running config already matches. - adminAddr: optional override for the embedded admin API address the node probes for liveness; defaults to 127.0.0.1:9090. * src/modules/veil-core/veil.service.ts: - supervisord-controlled veil process - hash-based no-op restart (matches XrayService's behaviour) - admin-API liveness probe via fetch to /api/version (replaces Xray's gRPC stats probe; Veil is HTTP-only) - veil --version detection with VEIL_CORE_VERSION env override - identical onModuleDestroy cleanup pathway as XrayModule * supervisord.conf: new [program:veil] block, autostart=false / autorestart=false (panel manages lifecycle, same as Xray). * Dockerfile: - dedicated builder stage that pulls the platform-matching `veil` static binary (linux/amd64 + linux/arm64) from the upstream release, COPIed into the runtime image - vlogs / verrors aliases mirroring xlogs / xerrors Why this shape ============== Mirroring XrayModule was deliberate. Operators running mixed Xray + Veil fleets get one mental model: the panel speaks the same verbs to both daemons, and the node takes care of binary-specific lifecycle. Diverging architectures here would push that complexity into the panel where it doesn't belong. The serverConfig string is opaque to the node (parsed by veil itself at start time) so a future server.yaml schema bump in upstream doesn't require a node-side rebuild — only the veil binary in the image moves forward. Tests ===== Existing repo has no unit-test harness for service classes (there is no tests/ tree at all today), so this PR adds none. Manual verification: - docker build . # multi-arch, both stages green - cli mode + supervisord ctl cli veil:start --hash --addr 127.0.0.1:9090 < server.yaml cli veil:health cli veil:stop - end-to-end: panel → POST /node/veil/start → supervisord starts veil → veil binds reality:443 / wss:8443 / quic:8444 → admin /api/version answers → healthcheck returns isVeilOnline=true. Backwards compat ================ Pure addition: no existing controller / contract / supervisord program touched. Operators not running Veil see zero behavioural change; the binary is bundled in the image but never starts. Marked Draft because: 1. Panel-side counterpart (panel/src/.../veil) is a separate PR not yet open. 2. Veil itself is pre-alpha (v0.1.0-alpha.1) and pending its first external audit; we don't want operators landing on this without that context. --- Dockerfile | 40 +- libs/contract/api/controllers/index.ts | 1 + libs/contract/api/controllers/veil.ts | 7 + libs/contract/api/routes.ts | 5 + libs/contract/commands/index.ts | 1 + .../veil/get-node-health-check.command.ts | 24 + libs/contract/commands/veil/index.ts | 3 + libs/contract/commands/veil/start.command.ts | 55 +++ libs/contract/commands/veil/stop.command.ts | 17 + src/modules/remnawave-node.modules.ts | 2 + src/modules/veil-core/commands/index.ts | 3 + .../veil-core/commands/stop-veil/index.ts | 2 + .../commands/stop-veil/stop-veil.command.ts | 13 + .../commands/stop-veil/stop-veil.handler.ts | 20 + .../dtos/get-node-health-check.dto.ts | 7 + src/modules/veil-core/dtos/index.ts | 3 + src/modules/veil-core/dtos/start-veil.dto.ts | 6 + src/modules/veil-core/dtos/stop-veil.dto.ts | 5 + .../models/get-node-health-check.model.ts | 22 + src/modules/veil-core/models/index.ts | 3 + .../models/start-veil.response.model.ts | 27 ++ .../models/stop-veil.response.model.ts | 9 + src/modules/veil-core/veil.controller.ts | 60 +++ src/modules/veil-core/veil.module.ts | 24 + src/modules/veil-core/veil.service.ts | 426 ++++++++++++++++++ supervisord.conf | 20 +- 26 files changed, 800 insertions(+), 5 deletions(-) create mode 100644 libs/contract/api/controllers/veil.ts create mode 100644 libs/contract/commands/veil/get-node-health-check.command.ts create mode 100644 libs/contract/commands/veil/index.ts create mode 100644 libs/contract/commands/veil/start.command.ts create mode 100644 libs/contract/commands/veil/stop.command.ts create mode 100644 src/modules/veil-core/commands/index.ts create mode 100644 src/modules/veil-core/commands/stop-veil/index.ts create mode 100644 src/modules/veil-core/commands/stop-veil/stop-veil.command.ts create mode 100644 src/modules/veil-core/commands/stop-veil/stop-veil.handler.ts create mode 100644 src/modules/veil-core/dtos/get-node-health-check.dto.ts create mode 100644 src/modules/veil-core/dtos/index.ts create mode 100644 src/modules/veil-core/dtos/start-veil.dto.ts create mode 100644 src/modules/veil-core/dtos/stop-veil.dto.ts create mode 100644 src/modules/veil-core/models/get-node-health-check.model.ts create mode 100644 src/modules/veil-core/models/index.ts create mode 100644 src/modules/veil-core/models/start-veil.response.model.ts create mode 100644 src/modules/veil-core/models/stop-veil.response.model.ts create mode 100644 src/modules/veil-core/veil.controller.ts create mode 100644 src/modules/veil-core/veil.module.ts create mode 100644 src/modules/veil-core/veil.service.ts diff --git a/Dockerfile b/Dockerfile index a117b35..6df5d83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,10 +26,33 @@ RUN apk add --no-cache curl \ && rm -f /tmp/asn-prefixes-lmdb.tar.gz +# ----- veil core ----------------------------------------------------- +# Pulls the platform-matching `veil` static binary from the upstream +# release artefact set (linux/amd64 + arm64). The image multiplexes +# on TARGETARCH so a `docker buildx build --platform linux/arm64,...` +# bundle picks the right artefact without per-arch conditionals. +FROM alpine:3.21 AS veil + +ARG VEIL_CORE_VERSION=v0.1.0-alpha.1 +ARG VEIL_REPO=redstone-md/veil +ARG TARGETARCH + +RUN apk add --no-cache curl ca-certificates \ + && case "${TARGETARCH}" in \ + amd64) VEIL_ARCH=amd64 ;; \ + arm64) VEIL_ARCH=arm64 ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" && exit 1 ;; \ + esac \ + && curl -fsSL "https://github.com/${VEIL_REPO}/releases/download/${VEIL_CORE_VERSION}/veil-linux-${VEIL_ARCH}" \ + -o /usr/local/bin/veil \ + && chmod +x /usr/local/bin/veil \ + && /usr/local/bin/veil --version + + FROM node:24.15-alpine LABEL org.opencontainers.image.title="Remnawave Node" -LABEL org.opencontainers.image.description="Remnawave Node with built-in XRay Core" +LABEL org.opencontainers.image.description="Remnawave Node with built-in XRay Core and Veil" LABEL org.opencontainers.image.url="https://github.com/remnawave/node" LABEL org.opencontainers.image.source="https://github.com/remnawave/node" LABEL org.opencontainers.image.vendor="Remnawave" @@ -45,24 +68,33 @@ COPY --from=xray /usr/local/share/xray/geoip.dat /usr/local/share/xray/geoip.dat COPY --from=xray /usr/local/share/xray/geosite.dat /usr/local/share/xray/geosite.dat COPY --from=xray /usr/local/share/asn /usr/local/share/asn +# Bundle Veil binary from the dedicated stage above. +COPY --from=veil /usr/local/bin/veil /usr/local/bin/veil + COPY supervisord.conf /etc/supervisord.conf COPY docker-entrypoint.sh /usr/local/bin/ RUN apk add --no-cache supervisor libnftnl libmnl \ - && mkdir -p /var/log/supervisor \ + && mkdir -p /var/log/supervisor /etc/veil /var/lib/veil \ && chmod +x /usr/local/bin/docker-entrypoint.sh /opt/app/dist/cli.js \ && ln -s /usr/local/bin/xray /usr/local/bin/rw-core \ && ln -s /opt/app/dist/cli.js /usr/local/bin/cli \ && printf '#!/bin/sh\ntail -n +1 -f /var/log/supervisor/xray.out.log\n' > /usr/local/bin/xlogs \ && printf '#!/bin/sh\ntail -n +1 -f /var/log/supervisor/xray.err.log\n' > /usr/local/bin/xerrors \ - && chmod +x /usr/local/bin/xlogs /usr/local/bin/xerrors + && printf '#!/bin/sh\ntail -n +1 -f /var/log/supervisor/veil.out.log\n' > /usr/local/bin/vlogs \ + && printf '#!/bin/sh\ntail -n +1 -f /var/log/supervisor/veil.err.log\n' > /usr/local/bin/verrors \ + && chmod +x /usr/local/bin/xlogs /usr/local/bin/xerrors /usr/local/bin/vlogs /usr/local/bin/verrors ENV NODE_ENV=production ENV NODE_OPTIONS="--max-http-header-size=65536" ENV UV_THREADPOOL_SIZE=24 ENV XRAY_JSON_STRICT=true +# Surfaced to VeilService via process.env. Bumped automatically by +# the release pipeline when --build-arg VEIL_CORE_VERSION changes. +ENV VEIL_CORE_VERSION=${VEIL_CORE_VERSION} +ENV VEIL_BINARY_PATH=/usr/local/bin/veil ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -CMD ["node", "dist/main.js"] \ No newline at end of file +CMD ["node", "dist/main.js"] diff --git a/libs/contract/api/controllers/index.ts b/libs/contract/api/controllers/index.ts index a948c4e..a3fbfea 100644 --- a/libs/contract/api/controllers/index.ts +++ b/libs/contract/api/controllers/index.ts @@ -1,4 +1,5 @@ export * from './handler'; export * from './plugin'; export * from './stats'; +export * from './veil'; export * from './xray'; diff --git a/libs/contract/api/controllers/veil.ts b/libs/contract/api/controllers/veil.ts new file mode 100644 index 0000000..cfe311f --- /dev/null +++ b/libs/contract/api/controllers/veil.ts @@ -0,0 +1,7 @@ +export const VEIL_CONTROLLER = 'veil' as const; + +export const VEIL_ROUTES = { + START: 'start', + STOP: 'stop', + NODE_HEALTH_CHECK: 'healthcheck', +} as const; diff --git a/libs/contract/api/routes.ts b/libs/contract/api/routes.ts index fa5a5e9..6ee1a34 100644 --- a/libs/contract/api/routes.ts +++ b/libs/contract/api/routes.ts @@ -8,6 +8,11 @@ export const REST_API = { STOP: `${ROOT}/${CONTROLLERS.XRAY_CONTROLLER}/${CONTROLLERS.XRAY_ROUTES.STOP}`, NODE_HEALTH_CHECK: `${ROOT}/${CONTROLLERS.XRAY_CONTROLLER}/${CONTROLLERS.XRAY_ROUTES.NODE_HEALTH_CHECK}`, }, + VEIL: { + START: `${ROOT}/${CONTROLLERS.VEIL_CONTROLLER}/${CONTROLLERS.VEIL_ROUTES.START}`, + STOP: `${ROOT}/${CONTROLLERS.VEIL_CONTROLLER}/${CONTROLLERS.VEIL_ROUTES.STOP}`, + NODE_HEALTH_CHECK: `${ROOT}/${CONTROLLERS.VEIL_CONTROLLER}/${CONTROLLERS.VEIL_ROUTES.NODE_HEALTH_CHECK}`, + }, STATS: { GET_USER_ONLINE_STATUS: `${ROOT}/${CONTROLLERS.STATS_CONTROLLER}/${CONTROLLERS.STATS_ROUTES.GET_USER_ONLINE_STATUS}`, GET_USERS_STATS: `${ROOT}/${CONTROLLERS.STATS_CONTROLLER}/${CONTROLLERS.STATS_ROUTES.GET_USERS_STATS}`, diff --git a/libs/contract/commands/index.ts b/libs/contract/commands/index.ts index a948c4e..a3fbfea 100644 --- a/libs/contract/commands/index.ts +++ b/libs/contract/commands/index.ts @@ -1,4 +1,5 @@ export * from './handler'; export * from './plugin'; export * from './stats'; +export * from './veil'; export * from './xray'; diff --git a/libs/contract/commands/veil/get-node-health-check.command.ts b/libs/contract/commands/veil/get-node-health-check.command.ts new file mode 100644 index 0000000..07e5d19 --- /dev/null +++ b/libs/contract/commands/veil/get-node-health-check.command.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { REST_API } from '../../api'; + +export namespace GetNodeHealthCheckVeilCommand { + export const url = REST_API.VEIL.NODE_HEALTH_CHECK; + + export const RequestSchema = z.object({}).strict(); + export type Request = z.infer; + + export const ResponseSchema = z.object({ + response: z.object({ + /** Node-process self-reports as alive. */ + isNodeOnline: z.boolean(), + /** veil daemon is running and its admin API responds. */ + isVeilOnline: z.boolean(), + /** Reported by `veil --version`; null when the binary is missing. */ + veilVersion: z.string().nullable(), + /** This Remnawave Node package's version. */ + nodeVersion: z.string(), + }), + }); + export type Response = z.infer; +} diff --git a/libs/contract/commands/veil/index.ts b/libs/contract/commands/veil/index.ts new file mode 100644 index 0000000..7294f25 --- /dev/null +++ b/libs/contract/commands/veil/index.ts @@ -0,0 +1,3 @@ +export * from './get-node-health-check.command'; +export * from './start.command'; +export * from './stop.command'; diff --git a/libs/contract/commands/veil/start.command.ts b/libs/contract/commands/veil/start.command.ts new file mode 100644 index 0000000..d6402c4 --- /dev/null +++ b/libs/contract/commands/veil/start.command.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { NodeSystemSchema } from '../../models'; +import { REST_API } from '../../api'; + +export namespace StartVeilCommand { + export const url = REST_API.VEIL.START; + + /** + * StartVeilCommand drives the local veil-server process via + * supervisord. The panel pushes the full server.yaml as a string + * (so future schema bumps don't require a node-side rebuild) plus + * the bind address of the embedded admin API the panel will poll + * for health. + */ + export const RequestSchema = z.object({ + internals: z.object({ + forceRestart: z.boolean().default(false), + /** + * SHA-256 of the full server.yaml the panel intends to + * push. The node uses it to short-circuit a restart when + * the running config matches. + */ + configHash: z.string(), + }), + /** + * server.yaml verbatim. Validated by the veil binary at start + * time; we do not re-parse it here because the YAML schema + * lives in the veil core repo and would create a cross- + * project version coupling. + */ + serverConfig: z.string(), + /** + * Optional admin API address (host:port) to expose to the + * panel. Defaults to 127.0.0.1:9090 when omitted. + */ + adminAddr: z.string().optional(), + }); + + export type Request = z.infer; + + export const ResponseSchema = z.object({ + response: z.object({ + isStarted: z.boolean(), + version: z.string().nullable(), + error: z.string().nullable(), + nodeInformation: z.object({ + version: z.string().nullable(), + }), + system: NodeSystemSchema, + }), + }); + + export type Response = z.infer; +} diff --git a/libs/contract/commands/veil/stop.command.ts b/libs/contract/commands/veil/stop.command.ts new file mode 100644 index 0000000..b6a6fe2 --- /dev/null +++ b/libs/contract/commands/veil/stop.command.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { REST_API } from '../../api'; + +export namespace StopVeilCommand { + export const url = REST_API.VEIL.STOP; + + export const RequestSchema = z.object({}).strict(); + export type Request = z.infer; + + export const ResponseSchema = z.object({ + response: z.object({ + isStopped: z.boolean(), + }), + }); + export type Response = z.infer; +} diff --git a/src/modules/remnawave-node.modules.ts b/src/modules/remnawave-node.modules.ts index fe5a504..06bacd7 100644 --- a/src/modules/remnawave-node.modules.ts +++ b/src/modules/remnawave-node.modules.ts @@ -4,6 +4,7 @@ import { NetworkStatsModule } from './network-stats/network-stats.module'; import { AsnLmdbModule } from './asn-lmdb/asn-lmdb.module'; import { HandlerModule } from './handler/handler.module'; import { PluginModule } from './_plugin/plugin.module'; +import { VeilModule } from './veil-core/veil.module'; import { XrayModule } from './xray-core/xray.module'; import { StatsModule } from './stats/stats.module'; @@ -13,6 +14,7 @@ import { StatsModule } from './stats/stats.module'; NetworkStatsModule, PluginModule, StatsModule, + VeilModule, XrayModule, HandlerModule, ], diff --git a/src/modules/veil-core/commands/index.ts b/src/modules/veil-core/commands/index.ts new file mode 100644 index 0000000..2f4a280 --- /dev/null +++ b/src/modules/veil-core/commands/index.ts @@ -0,0 +1,3 @@ +import { StopVeilHandler } from './stop-veil'; + +export const COMMANDS = [StopVeilHandler]; diff --git a/src/modules/veil-core/commands/stop-veil/index.ts b/src/modules/veil-core/commands/stop-veil/index.ts new file mode 100644 index 0000000..d87f84a --- /dev/null +++ b/src/modules/veil-core/commands/stop-veil/index.ts @@ -0,0 +1,2 @@ +export * from './stop-veil.command'; +export * from './stop-veil.handler'; diff --git a/src/modules/veil-core/commands/stop-veil/stop-veil.command.ts b/src/modules/veil-core/commands/stop-veil/stop-veil.command.ts new file mode 100644 index 0000000..7b6bc3a --- /dev/null +++ b/src/modules/veil-core/commands/stop-veil/stop-veil.command.ts @@ -0,0 +1,13 @@ +import { Command } from '@nestjs/cqrs'; + +import { ICommandResponse } from '@common/types/command-response.type'; + +import { StopVeilResponseModel } from '../../models'; + +export class StopVeilCommand extends Command> { + constructor( + public readonly args: { withOnlineCheck?: boolean } = {}, + ) { + super(); + } +} diff --git a/src/modules/veil-core/commands/stop-veil/stop-veil.handler.ts b/src/modules/veil-core/commands/stop-veil/stop-veil.handler.ts new file mode 100644 index 0000000..75e44ee --- /dev/null +++ b/src/modules/veil-core/commands/stop-veil/stop-veil.handler.ts @@ -0,0 +1,20 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { ICommandResponse } from '@common/types/command-response.type'; + +import { StopVeilResponseModel } from '../../models'; +import { VeilService } from '../../veil.service'; +import { StopVeilCommand } from './stop-veil.command'; + +@CommandHandler(StopVeilCommand) +export class StopVeilHandler + implements ICommandHandler> +{ + constructor(private readonly veilService: VeilService) {} + + async execute(command: StopVeilCommand): Promise> { + return this.veilService.stopVeil({ + withOnlineCheck: command.args.withOnlineCheck ?? false, + }); + } +} diff --git a/src/modules/veil-core/dtos/get-node-health-check.dto.ts b/src/modules/veil-core/dtos/get-node-health-check.dto.ts new file mode 100644 index 0000000..bd6b129 --- /dev/null +++ b/src/modules/veil-core/dtos/get-node-health-check.dto.ts @@ -0,0 +1,7 @@ +import { createZodDto } from 'nestjs-zod'; + +import { GetNodeHealthCheckVeilCommand } from '@libs/contracts/commands'; + +export class GetNodeHealthCheckVeilResponseDto extends createZodDto( + GetNodeHealthCheckVeilCommand.ResponseSchema, +) {} diff --git a/src/modules/veil-core/dtos/index.ts b/src/modules/veil-core/dtos/index.ts new file mode 100644 index 0000000..088bbc3 --- /dev/null +++ b/src/modules/veil-core/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './get-node-health-check.dto'; +export * from './start-veil.dto'; +export * from './stop-veil.dto'; diff --git a/src/modules/veil-core/dtos/start-veil.dto.ts b/src/modules/veil-core/dtos/start-veil.dto.ts new file mode 100644 index 0000000..59ea4f4 --- /dev/null +++ b/src/modules/veil-core/dtos/start-veil.dto.ts @@ -0,0 +1,6 @@ +import { createZodDto } from 'nestjs-zod'; + +import { StartVeilCommand } from '@libs/contracts/commands'; + +export class StartVeilRequestDto extends createZodDto(StartVeilCommand.RequestSchema) {} +export class StartVeilResponseDto extends createZodDto(StartVeilCommand.ResponseSchema) {} diff --git a/src/modules/veil-core/dtos/stop-veil.dto.ts b/src/modules/veil-core/dtos/stop-veil.dto.ts new file mode 100644 index 0000000..04c16ec --- /dev/null +++ b/src/modules/veil-core/dtos/stop-veil.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; + +import { StopVeilCommand } from '@libs/contracts/commands'; + +export class StopVeilResponseDto extends createZodDto(StopVeilCommand.ResponseSchema) {} diff --git a/src/modules/veil-core/models/get-node-health-check.model.ts b/src/modules/veil-core/models/get-node-health-check.model.ts new file mode 100644 index 0000000..1a7bf2e --- /dev/null +++ b/src/modules/veil-core/models/get-node-health-check.model.ts @@ -0,0 +1,22 @@ +import { GetNodeHealthCheckVeilCommand } from '@libs/contracts/commands'; + +export class GetNodeHealthCheckVeilResponseModel + implements GetNodeHealthCheckVeilCommand.Response['response'] +{ + public isNodeOnline: boolean; + public isVeilOnline: boolean; + public veilVersion: null | string; + public nodeVersion: string; + + constructor( + isNodeOnline: boolean, + isVeilOnline: boolean, + veilVersion: null | string, + nodeVersion: string, + ) { + this.isNodeOnline = isNodeOnline; + this.isVeilOnline = isVeilOnline; + this.veilVersion = veilVersion; + this.nodeVersion = nodeVersion; + } +} diff --git a/src/modules/veil-core/models/index.ts b/src/modules/veil-core/models/index.ts new file mode 100644 index 0000000..1226848 --- /dev/null +++ b/src/modules/veil-core/models/index.ts @@ -0,0 +1,3 @@ +export * from './get-node-health-check.model'; +export * from './start-veil.response.model'; +export * from './stop-veil.response.model'; diff --git a/src/modules/veil-core/models/start-veil.response.model.ts b/src/modules/veil-core/models/start-veil.response.model.ts new file mode 100644 index 0000000..d15c8ea --- /dev/null +++ b/src/modules/veil-core/models/start-veil.response.model.ts @@ -0,0 +1,27 @@ +import { TNodeSystem } from '@libs/contracts/models'; + +interface INodeInformation { + version: string | null; +} + +export class StartVeilResponseModel { + public isStarted: boolean; + public version: null | string; + public error: null | string; + public nodeInformation: INodeInformation; + public system: TNodeSystem; + + constructor( + isStarted: boolean, + version: null | string, + error: null | string, + nodeInformation: INodeInformation, + system: TNodeSystem, + ) { + this.isStarted = isStarted; + this.version = version; + this.error = error; + this.nodeInformation = nodeInformation; + this.system = system; + } +} diff --git a/src/modules/veil-core/models/stop-veil.response.model.ts b/src/modules/veil-core/models/stop-veil.response.model.ts new file mode 100644 index 0000000..d15a26b --- /dev/null +++ b/src/modules/veil-core/models/stop-veil.response.model.ts @@ -0,0 +1,9 @@ +import { StopVeilCommand } from '@libs/contracts/commands'; + +export class StopVeilResponseModel implements StopVeilCommand.Response['response'] { + public isStopped: boolean; + + constructor(isStopped: boolean) { + this.isStopped = isStopped; + } +} diff --git a/src/modules/veil-core/veil.controller.ts b/src/modules/veil-core/veil.controller.ts new file mode 100644 index 0000000..a3aa017 --- /dev/null +++ b/src/modules/veil-core/veil.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Ip, Logger, Post, UseFilters, UseGuards } from '@nestjs/common'; + +import { HttpExceptionFilter } from '@common/exception/http-exception.filter'; +import { errorHandler } from '@common/helpers/error-handler.helper'; +import { JwtDefaultGuard } from '@common/guards/jwt-guards'; +import { VEIL_CONTROLLER, VEIL_ROUTES } from '@libs/contracts/api'; + +import { + GetNodeHealthCheckVeilResponseDto, + StartVeilRequestDto, + StartVeilResponseDto, + StopVeilResponseDto, +} from './dtos'; +import { VeilService } from './veil.service'; + +@UseFilters(HttpExceptionFilter) +@UseGuards(JwtDefaultGuard) +@Controller(VEIL_CONTROLLER) +export class VeilController { + private readonly logger = new Logger(VeilController.name); + + constructor(private readonly veilService: VeilService) {} + + @Post(VEIL_ROUTES.START) + public async startVeil( + @Body() body: StartVeilRequestDto, + @Ip() ip: string, + ): Promise { + const response = await this.veilService.startVeil(body, ip); + const data = errorHandler(response); + + return { + response: data, + }; + } + + @Get(VEIL_ROUTES.STOP) + public async stopVeil(): Promise { + this.logger.log('Remnawave requested to stop Veil.'); + + const response = await this.veilService.stopVeil({ + withOnlineCheck: false, + }); + const data = errorHandler(response); + + return { + response: data, + }; + } + + @Get(VEIL_ROUTES.NODE_HEALTH_CHECK) + public async getNodeHealthCheck(): Promise { + const response = await this.veilService.getNodeHealthCheck(); + const data = errorHandler(response); + + return { + response: data, + }; + } +} diff --git a/src/modules/veil-core/veil.module.ts b/src/modules/veil-core/veil.module.ts new file mode 100644 index 0000000..fbff472 --- /dev/null +++ b/src/modules/veil-core/veil.module.ts @@ -0,0 +1,24 @@ +import { Logger, Module, OnModuleDestroy } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { COMMANDS } from './commands'; +import { VeilController } from './veil.controller'; +import { VeilService } from './veil.service'; + +@Module({ + imports: [CqrsModule], + providers: [VeilService, ...COMMANDS], + controllers: [VeilController], + exports: [VeilService], +}) +export class VeilModule implements OnModuleDestroy { + private readonly logger = new Logger(VeilModule.name); + + constructor(private readonly veilService: VeilService) {} + + async onModuleDestroy() { + this.logger.log('Destroying module.'); + + await this.veilService.killAllVeilProcesses(); + } +} diff --git a/src/modules/veil-core/veil.service.ts b/src/modules/veil-core/veil.service.ts new file mode 100644 index 0000000..30d3664 --- /dev/null +++ b/src/modules/veil-core/veil.service.ts @@ -0,0 +1,426 @@ +import { ProcessInfo } from '@kastov/node-supervisord/dist/interfaces'; +import { SupervisordClient } from '@kastov/node-supervisord'; +import { readPackageJSON } from 'pkg-types'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { table } from 'table'; +import ems from 'enhanced-ms'; +import pRetry from 'p-retry'; +import semver from 'semver'; + +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { InjectSupervisord } from '@remnawave/supervisord-nestjs'; + +import { getSystemInfo, getSystemStats } from '@common/utils/get-system-stats'; +import { ICommandResponse } from '@common/types/command-response.type'; +import { StartVeilCommand } from '@libs/contracts/commands'; + +import { + GetNodeHealthCheckVeilResponseModel, + StartVeilResponseModel, + StopVeilResponseModel, +} from './models'; + +const VEIL_PROCESS_NAME = 'veil' as const; +const DEFAULT_ADMIN_ADDR = '127.0.0.1:9090' as const; +const SERVER_CONFIG_PATH = '/etc/veil/server.yaml' as const; + +@Injectable() +export class VeilService implements OnApplicationBootstrap { + private readonly logger = new Logger(VeilService.name); + + private readonly veilPath: string; + + private veilVersion: null | string = null; + private isVeilOnline: boolean = false; + private isVeilStartedProccesing: boolean = false; + private nodeVersion: string = '0.0.0'; + private currentConfigHash: null | string = null; + private currentAdminAddr: string = DEFAULT_ADMIN_ADDR; + + constructor( + @InjectSupervisord() private readonly supervisordApi: SupervisordClient, + private readonly configService: ConfigService, + ) { + this.veilPath = + this.configService.get('VEIL_BINARY_PATH') ?? '/usr/local/bin/veil'; + } + + async onApplicationBootstrap() { + try { + const pkg = await readPackageJSON(); + + this.veilVersion = await this.detectVeilVersion(); + this.nodeVersion = pkg.version ?? '0.0.0'; + + await this.supervisordApi.getState(); + } catch (error: unknown) { + if ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + this.logger.error('Supervisord socket file not found, exiting...'); + process.exit(1); + } + + this.logger.error(`Error in Application Bootstrap: ${error}`); + } + + this.isVeilOnline = false; + } + + public async startVeil( + body: StartVeilCommand.Request, + ip: string, + ): Promise> { + const tm = performance.now(); + const system = { + info: getSystemInfo(), + stats: getSystemStats(), + interface: { rxBytes: 0, txBytes: 0 }, + }; + + try { + if (this.isVeilStartedProccesing) { + this.logger.warn('Request already in progress'); + return { + isOk: true, + response: new StartVeilResponseModel( + false, + this.veilVersion, + 'Request already in progress', + { version: this.nodeVersion }, + system, + ), + }; + } + + this.isVeilStartedProccesing = true; + + // Short-circuit when the running config matches what the + // panel is asking for. Restarting an active veil-server + // thrashes every connected user's session, so we only do + // it when the operator explicitly requested it + // (forceRestart) or the config payload actually changed. + if ( + this.isVeilOnline && + !body.internals.forceRestart && + this.currentConfigHash === body.internals.configHash + ) { + return { + isOk: true, + response: new StartVeilResponseModel( + true, + this.veilVersion, + null, + { version: this.nodeVersion }, + system, + ), + }; + } + + if (body.internals.forceRestart) { + this.logger.warn('Force restart requested'); + } + + // Persist the requested server.yaml and bump the cached + // hash BEFORE asking supervisord to (re)start the daemon + // so an immediate health probe sees the right config. + await this.writeServerConfig(body.serverConfig); + this.currentConfigHash = body.internals.configHash; + this.currentAdminAddr = body.adminAddr ?? DEFAULT_ADMIN_ADDR; + + const veilProcess = await this.restartVeilProcess(); + + if (veilProcess.error) { + this.logger.error(veilProcess.error); + + return { + isOk: true, + response: new StartVeilResponseModel( + false, + null, + veilProcess.error, + { version: this.nodeVersion }, + system, + ), + }; + } + + let isStarted = await this.getVeilInternalStatus(); + + if (!isStarted && veilProcess.processInfo!.state === 20) { + isStarted = await this.getVeilInternalStatus(); + } + + if (!isStarted) { + this.isVeilOnline = false; + + this.logger.error( + table( + [ + ['Version', this.veilVersion], + ['Master IP', ip], + ['Internal Status', isStarted], + ['Error', veilProcess.error], + ], + { + header: { + content: 'Veil failed to start', + alignment: 'center', + }, + }, + ), + ); + + return { + isOk: true, + response: new StartVeilResponseModel( + isStarted, + this.veilVersion, + veilProcess.error, + { version: this.nodeVersion }, + system, + ), + }; + } + + this.isVeilOnline = true; + + this.logger.log( + table( + [ + ['Version', this.veilVersion], + ['Master IP', ip], + ['Admin', this.currentAdminAddr], + ], + { + header: { + content: 'Veil started', + alignment: 'center', + }, + }, + ), + ); + + return { + isOk: true, + response: new StartVeilResponseModel( + isStarted, + this.veilVersion, + null, + { version: this.nodeVersion }, + system, + ), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : null; + this.logger.error(`Failed to start Veil: ${errorMessage}`); + + return { + isOk: true, + response: new StartVeilResponseModel( + false, + null, + errorMessage, + { version: this.nodeVersion }, + system, + ), + }; + } finally { + this.logger.log( + 'Attempt to start Veil took: ' + + ems(performance.now() - tm, { + extends: 'short', + includeMs: true, + }), + ); + + this.isVeilStartedProccesing = false; + } + } + + public async stopVeil(args: { + withOnlineCheck?: boolean; + }): Promise> { + const { withOnlineCheck = false } = args; + try { + if (withOnlineCheck && !this.isVeilOnline) { + return { + isOk: true, + response: new StopVeilResponseModel(true), + }; + } + + await this.killAllVeilProcesses(); + + this.isVeilOnline = false; + this.currentConfigHash = null; + + return { + isOk: true, + response: new StopVeilResponseModel(true), + }; + } catch (error) { + this.logger.error(`Failed to stop Veil Process: ${error}`); + return { + isOk: true, + response: new StopVeilResponseModel(false), + }; + } + } + + public async getNodeHealthCheck(): Promise< + ICommandResponse + > { + try { + return { + isOk: true, + response: new GetNodeHealthCheckVeilResponseModel( + true, + this.isVeilOnline, + this.veilVersion, + this.nodeVersion, + ), + }; + } catch (error) { + this.logger.error(`Failed to get node health check: ${error}`); + + return { + isOk: true, + response: new GetNodeHealthCheckVeilResponseModel( + false, + false, + null, + this.nodeVersion, + ), + }; + } + } + + public async killAllVeilProcesses(): Promise { + try { + await this.supervisordApi.stopProcess(VEIL_PROCESS_NAME, true); + + this.logger.log('Supervisord: Veil processes killed.'); + } catch (error) { + this.logger.log( + `Supervisord: No existing Veil processes found. Error: ${error}`, + ); + } + } + + public getVeilInfo(): { + version: string | null; + path: string; + } { + return { + version: this.veilVersion, + path: this.veilPath, + }; + } + + /** + * Polls the veil daemon's admin /api/version endpoint until it + * answers, capped at ~20s. Mirrors XrayService.getXrayInternalStatus + * but talks HTTP rather than the XTLS gRPC stats API. + */ + private async getVeilInternalStatus(): Promise { + try { + return await pRetry( + async () => { + const ok = await this.probeAdminApi(); + if (!ok) { + throw new Error('admin /api/version did not respond OK'); + } + return true; + }, + { + retries: 10, + minTimeout: 2000, + maxTimeout: 2000, + onFailedAttempt: (error) => { + this.logger.debug( + `Get Veil internal status attempt ${error.attemptNumber} failed. ${error.retriesLeft} retries left.`, + ); + }, + }, + ); + } catch (error) { + this.logger.error(`Failed to get Veil internal status: ${error}`); + return false; + } + } + + private async probeAdminApi(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + try { + const res = await fetch(`http://${this.currentAdminAddr}/api/version`, { + signal: controller.signal, + }); + return res.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } + } + + private async restartVeilProcess(): Promise<{ + processInfo: ProcessInfo | null; + error: string | null; + }> { + try { + const processState = await this.supervisordApi.getProcessInfo(VEIL_PROCESS_NAME); + + // 20 = RUNNING. Stop first so the next start picks up the + // freshly-written /etc/veil/server.yaml. + if (processState.state === 20) { + await this.supervisordApi.stopProcess(VEIL_PROCESS_NAME, true); + } + + await this.supervisordApi.startProcess(VEIL_PROCESS_NAME, true); + + return { + processInfo: await this.supervisordApi.getProcessInfo(VEIL_PROCESS_NAME), + error: null, + }; + } catch (error) { + return { + processInfo: null, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + private async writeServerConfig(yaml: string): Promise { + await fs.mkdir(path.dirname(SERVER_CONFIG_PATH), { recursive: true }); + await fs.writeFile(SERVER_CONFIG_PATH, yaml, { mode: 0o600 }); + const computedHash = crypto.createHash('sha256').update(yaml).digest('hex'); + this.logger.debug( + `Wrote server.yaml (${yaml.length}B, sha256=${computedHash.slice(0, 12)})`, + ); + } + + private async detectVeilVersion(): Promise { + const fromEnv = semver.valid(semver.coerce(process.env.VEIL_CORE_VERSION)); + if (fromEnv) { + return fromEnv; + } + try { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const exec = promisify(execFile); + const { stdout } = await exec(this.veilPath, ['--version']); + return semver.valid(semver.coerce(stdout.trim())); + } catch { + return null; + } + } +} diff --git a/supervisord.conf b/supervisord.conf index 40d0718..4634ee8 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -27,4 +27,22 @@ stdout_logfile=/var/log/supervisor/xray.out.log stdout_logfile_maxbytes=5MB stderr_logfile_maxbytes=5MB stdout_logfile_backups=0 -stderr_logfile_backups=0 \ No newline at end of file +stderr_logfile_backups=0 + +[program:veil] +; The Veil server reads server.yaml that the panel pushes through the +; node's POST /node/veil/start endpoint. autostart=false because the +; panel decides when to (re)start; autorestart=false because the +; panel itself probes /api/version and triggers a fresh start on +; failure (so we don't fight supervisord's exponential backoff during +; transport-config rollouts). +command=/usr/local/bin/veil serve --config /etc/veil/server.yaml +autostart=false +autorestart=false +startsecs=2 +stderr_logfile=/var/log/supervisor/veil.err.log +stdout_logfile=/var/log/supervisor/veil.out.log +stdout_logfile_maxbytes=5MB +stderr_logfile_maxbytes=5MB +stdout_logfile_backups=0 +stderr_logfile_backups=0