mirror of
https://github.com/remnawave/node.git
synced 2026-08-28 04:31:56 +00:00
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:
parent
a5acdeb288
commit
a70565f2fc
26 changed files with 800 additions and 5 deletions
40
Dockerfile
40
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"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './handler';
|
||||
export * from './plugin';
|
||||
export * from './stats';
|
||||
export * from './veil';
|
||||
export * from './xray';
|
||||
|
|
|
|||
7
libs/contract/api/controllers/veil.ts
Normal file
7
libs/contract/api/controllers/veil.ts
Normal 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;
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './handler';
|
||||
export * from './plugin';
|
||||
export * from './stats';
|
||||
export * from './veil';
|
||||
export * from './xray';
|
||||
|
|
|
|||
24
libs/contract/commands/veil/get-node-health-check.command.ts
Normal file
24
libs/contract/commands/veil/get-node-health-check.command.ts
Normal 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>;
|
||||
}
|
||||
3
libs/contract/commands/veil/index.ts
Normal file
3
libs/contract/commands/veil/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './get-node-health-check.command';
|
||||
export * from './start.command';
|
||||
export * from './stop.command';
|
||||
55
libs/contract/commands/veil/start.command.ts
Normal file
55
libs/contract/commands/veil/start.command.ts
Normal 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>;
|
||||
}
|
||||
17
libs/contract/commands/veil/stop.command.ts
Normal file
17
libs/contract/commands/veil/stop.command.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
3
src/modules/veil-core/commands/index.ts
Normal file
3
src/modules/veil-core/commands/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { StopVeilHandler } from './stop-veil';
|
||||
|
||||
export const COMMANDS = [StopVeilHandler];
|
||||
2
src/modules/veil-core/commands/stop-veil/index.ts
Normal file
2
src/modules/veil-core/commands/stop-veil/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './stop-veil.command';
|
||||
export * from './stop-veil.handler';
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/modules/veil-core/dtos/get-node-health-check.dto.ts
Normal file
7
src/modules/veil-core/dtos/get-node-health-check.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
import { GetNodeHealthCheckVeilCommand } from '@libs/contracts/commands';
|
||||
|
||||
export class GetNodeHealthCheckVeilResponseDto extends createZodDto(
|
||||
GetNodeHealthCheckVeilCommand.ResponseSchema,
|
||||
) {}
|
||||
3
src/modules/veil-core/dtos/index.ts
Normal file
3
src/modules/veil-core/dtos/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './get-node-health-check.dto';
|
||||
export * from './start-veil.dto';
|
||||
export * from './stop-veil.dto';
|
||||
6
src/modules/veil-core/dtos/start-veil.dto.ts
Normal file
6
src/modules/veil-core/dtos/start-veil.dto.ts
Normal 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) {}
|
||||
5
src/modules/veil-core/dtos/stop-veil.dto.ts
Normal file
5
src/modules/veil-core/dtos/stop-veil.dto.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
import { StopVeilCommand } from '@libs/contracts/commands';
|
||||
|
||||
export class StopVeilResponseDto extends createZodDto(StopVeilCommand.ResponseSchema) {}
|
||||
22
src/modules/veil-core/models/get-node-health-check.model.ts
Normal file
22
src/modules/veil-core/models/get-node-health-check.model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
src/modules/veil-core/models/index.ts
Normal file
3
src/modules/veil-core/models/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './get-node-health-check.model';
|
||||
export * from './start-veil.response.model';
|
||||
export * from './stop-veil.response.model';
|
||||
27
src/modules/veil-core/models/start-veil.response.model.ts
Normal file
27
src/modules/veil-core/models/start-veil.response.model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
src/modules/veil-core/models/stop-veil.response.model.ts
Normal file
9
src/modules/veil-core/models/stop-veil.response.model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
60
src/modules/veil-core/veil.controller.ts
Normal file
60
src/modules/veil-core/veil.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
src/modules/veil-core/veil.module.ts
Normal file
24
src/modules/veil-core/veil.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
426
src/modules/veil-core/veil.service.ts
Normal file
426
src/modules/veil-core/veil.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue