feat(veil-core): add Veil VPN protocol module alongside Xray

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 <h> --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.
This commit is contained in:
Rxflex 2026-05-12 02:24:35 +02:00
parent a5acdeb288
commit a70565f2fc
26 changed files with 800 additions and 5 deletions

View file

@ -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"]
CMD ["node", "dist/main.js"]

View file

@ -1,4 +1,5 @@
export * from './handler';
export * from './plugin';
export * from './stats';
export * from './veil';
export * from './xray';

View file

@ -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;

View file

@ -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}`,

View file

@ -1,4 +1,5 @@
export * from './handler';
export * from './plugin';
export * from './stats';
export * from './veil';
export * from './xray';

View file

@ -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<typeof RequestSchema>;
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<typeof ResponseSchema>;
}

View file

@ -0,0 +1,3 @@
export * from './get-node-health-check.command';
export * from './start.command';
export * from './stop.command';

View file

@ -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<typeof RequestSchema>;
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<typeof ResponseSchema>;
}

View file

@ -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<typeof RequestSchema>;
export const ResponseSchema = z.object({
response: z.object({
isStopped: z.boolean(),
}),
});
export type Response = z.infer<typeof ResponseSchema>;
}

View file

@ -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,
],

View file

@ -0,0 +1,3 @@
import { StopVeilHandler } from './stop-veil';
export const COMMANDS = [StopVeilHandler];

View file

@ -0,0 +1,2 @@
export * from './stop-veil.command';
export * from './stop-veil.handler';

View file

@ -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<ICommandResponse<StopVeilResponseModel>> {
constructor(
public readonly args: { withOnlineCheck?: boolean } = {},
) {
super();
}
}

View file

@ -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<StopVeilCommand, ICommandResponse<StopVeilResponseModel>>
{
constructor(private readonly veilService: VeilService) {}
async execute(command: StopVeilCommand): Promise<ICommandResponse<StopVeilResponseModel>> {
return this.veilService.stopVeil({
withOnlineCheck: command.args.withOnlineCheck ?? false,
});
}
}

View file

@ -0,0 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import { GetNodeHealthCheckVeilCommand } from '@libs/contracts/commands';
export class GetNodeHealthCheckVeilResponseDto extends createZodDto(
GetNodeHealthCheckVeilCommand.ResponseSchema,
) {}

View file

@ -0,0 +1,3 @@
export * from './get-node-health-check.dto';
export * from './start-veil.dto';
export * from './stop-veil.dto';

View file

@ -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) {}

View file

@ -0,0 +1,5 @@
import { createZodDto } from 'nestjs-zod';
import { StopVeilCommand } from '@libs/contracts/commands';
export class StopVeilResponseDto extends createZodDto(StopVeilCommand.ResponseSchema) {}

View file

@ -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;
}
}

View file

@ -0,0 +1,3 @@
export * from './get-node-health-check.model';
export * from './start-veil.response.model';
export * from './stop-veil.response.model';

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<StartVeilResponseDto> {
const response = await this.veilService.startVeil(body, ip);
const data = errorHandler(response);
return {
response: data,
};
}
@Get(VEIL_ROUTES.STOP)
public async stopVeil(): Promise<StopVeilResponseDto> {
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<GetNodeHealthCheckVeilResponseDto> {
const response = await this.veilService.getNodeHealthCheck();
const data = errorHandler(response);
return {
response: data,
};
}
}

View file

@ -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();
}
}

View file

@ -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<string>('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<ICommandResponse<StartVeilResponseModel>> {
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<ICommandResponse<StopVeilResponseModel>> {
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<GetNodeHealthCheckVeilResponseModel>
> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<null | string> {
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;
}
}
}

View file

@ -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
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