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