feat(vpn): add tag-based VPN route access control and support configured initial VPN clients

This commit is contained in:
2026-03-30 12:07:58 +00:00
parent 43618abeba
commit eb211348d2
14 changed files with 125 additions and 33 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-03-30 - 11.15.0 - feat(vpn)
add tag-based VPN route access control and support configured initial VPN clients
- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet
- create configured VPN clients automatically on startup and re-apply routes when VPN clients change
- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load
- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0
## 2026-03-30 - 11.14.0 - feat(docs) ## 2026-03-30 - 11.14.0 - feat(docs)
document VPN access control and add OpsServer VPN navigation document VPN access control and add OpsServer VPN navigation

View File

@@ -59,7 +59,7 @@
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.12.0", "@push.rocks/smartvpn": "1.13.0",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0", "@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",

10
pnpm-lock.yaml generated
View File

@@ -96,8 +96,8 @@ importers:
specifier: ^3.0.9 specifier: ^3.0.9
version: 3.0.9 version: 3.0.9
'@push.rocks/smartvpn': '@push.rocks/smartvpn':
specifier: 1.12.0 specifier: 1.13.0
version: 1.12.0 version: 1.13.0
'@push.rocks/taskbuffer': '@push.rocks/taskbuffer':
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
@@ -1330,8 +1330,8 @@ packages:
'@push.rocks/smartversion@3.0.5': '@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartvpn@1.12.0': '@push.rocks/smartvpn@1.13.0':
resolution: {integrity: sha512-lwZCK8fopkms3c6ZSrUghuVNFi7xOXMSkGDSptQM2K3tu2UbajhpdxlAVMODY8n6caQr5ZXp0kHdtwVU9WKi5Q==} resolution: {integrity: sha512-oQY+GIvB9OZQMFEI/f4zwKwaUWPgG8Fsz8AGhPDedvH32jYNYEb9B957yRAROf7ndyQM/LThm7mN/5cx8ALyLw==}
'@push.rocks/smartwatch@6.4.0': '@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -6562,7 +6562,7 @@ snapshots:
'@types/semver': 7.7.1 '@types/semver': 7.7.1
semver: 7.7.4 semver: 7.7.4
'@push.rocks/smartvpn@1.12.0': '@push.rocks/smartvpn@1.13.0':
dependencies: dependencies:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2 '@push.rocks/smartrust': 1.3.2

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.14.0', version: '11.15.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -208,6 +208,12 @@ export interface IDcRouterOptions {
serverEndpoint?: string; serverEndpoint?: string;
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */ /** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
forwardingMode?: 'tun' | 'socket'; forwardingMode?: 'tun' | 'socket';
/** Pre-defined VPN clients created on startup */
clients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
}; };
} }
@@ -453,7 +459,14 @@ export class DcRouter {
() => this.getConstructorRoutes(), () => this.getConstructorRoutes(),
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
() => this.options.vpnConfig?.enabled ? (this.options.vpnConfig.subnet || '10.8.0.0/24') : undefined, this.options.vpnConfig?.enabled
? (tags?: string[]) => {
if (tags?.length && this.vpnManager) {
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
}
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
}
: undefined,
); );
this.apiTokenManager = new ApiTokenManager(this.storageManager); this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
@@ -2086,6 +2099,11 @@ export class DcRouter {
dns: this.options.vpnConfig.dns, dns: this.options.vpnConfig.dns,
serverEndpoint: this.options.vpnConfig.serverEndpoint, serverEndpoint: this.options.vpnConfig.serverEndpoint,
forwardingMode: this.options.vpnConfig.forwardingMode, forwardingMode: this.options.vpnConfig.forwardingMode,
initialClients: this.options.vpnConfig.clients,
onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
}); });
await this.vpnManager.start(); await this.vpnManager.start();
@@ -2104,11 +2122,23 @@ export class DcRouter {
if (dcrouterRoute.vpn?.required) { if (dcrouterRoute.vpn?.required) {
injectedCount++; injectedCount++;
const existing = route.security?.ipAllowList || []; const existing = route.security?.ipAllowList || [];
let vpnAllowList: string[];
if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) {
// Tag-based: only specific client IPs
vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags(
dcrouterRoute.vpn.allowedServerDefinedClientTags,
);
} else {
// No tags specified: entire VPN subnet
vpnAllowList = [vpnSubnet];
}
return { return {
...route, ...route,
security: { security: {
...route.security, ...route.security,
ipAllowList: [...existing, vpnSubnet], ipAllowList: [...existing, ...vpnAllowList],
}, },
}; };
} }
@@ -2116,7 +2146,7 @@ export class DcRouter {
}); });
if (injectedCount > 0) { if (injectedCount > 0) {
logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`); logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`);
} }
return result; return result;

View File

@@ -23,7 +23,7 @@ export class RouteConfigManager {
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined, private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnSubnet?: () => string | undefined, private getVpnAllowList?: (tags?: string[]) => string[],
) {} ) {}
/** /**
@@ -246,7 +246,7 @@ export class RouteConfigManager {
// Private: apply merged routes to SmartProxy // Private: apply merged routes to SmartProxy
// ========================================================================= // =========================================================================
private async applyRoutes(): Promise<void> { public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy(); const smartProxy = this.getSmartProxy();
if (!smartProxy) return; if (!smartProxy) return;
@@ -262,9 +262,9 @@ export class RouteConfigManager {
enabledRoutes.push(route); enabledRoutes.push(route);
} }
// Add enabled programmatic routes (with HTTP/3 augmentation if enabled) // Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
const http3Config = this.getHttp3Config?.(); const http3Config = this.getHttp3Config?.();
const vpnSubnet = this.getVpnSubnet?.(); const vpnAllowList = this.getVpnAllowList;
for (const stored of this.storedRoutes.values()) { for (const stored of this.storedRoutes.values()) {
if (stored.enabled) { if (stored.enabled) {
let route = stored.route; let route = stored.route;
@@ -272,15 +272,16 @@ export class RouteConfigManager {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
} }
// Inject VPN security for programmatic routes with vpn.required // Inject VPN security for programmatic routes with vpn.required
if (vpnSubnet) { if (vpnAllowList) {
const dcRoute = route as IDcRouterRouteConfig; const dcRoute = route as IDcRouterRouteConfig;
if (dcRoute.vpn?.required) { if (dcRoute.vpn?.required) {
const existing = route.security?.ipAllowList || []; const existing = route.security?.ipAllowList || [];
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
route = { route = {
...route, ...route,
security: { security: {
...route.security, ...route.security,
ipAllowList: [...existing, vpnSubnet], ipAllowList: [...existing, ...allowList],
}, },
}; };
} }

View File

@@ -25,7 +25,7 @@ export class VpnHandler {
const clients = manager.listClients().map((c) => ({ const clients = manager.listClients().map((c) => ({
clientId: c.clientId, clientId: c.clientId,
enabled: c.enabled, enabled: c.enabled,
tags: c.tags, serverDefinedClientTags: c.serverDefinedClientTags,
description: c.description, description: c.description,
assignedIp: c.assignedIp, assignedIp: c.assignedIp,
createdAt: c.createdAt, createdAt: c.createdAt,
@@ -89,7 +89,7 @@ export class VpnHandler {
try { try {
const bundle = await manager.createClient({ const bundle = await manager.createClient({
clientId: dataArg.clientId, clientId: dataArg.clientId,
tags: dataArg.tags, serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description, description: dataArg.description,
}); });
@@ -98,7 +98,7 @@ export class VpnHandler {
client: { client: {
clientId: bundle.entry.clientId, clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true, enabled: bundle.entry.enabled ?? true,
tags: bundle.entry.tags, serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
description: bundle.entry.description, description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp, assignedIp: bundle.entry.assignedIp,
createdAt: Date.now(), createdAt: Date.now(),

View File

@@ -16,6 +16,14 @@ export interface IVpnManagerConfig {
serverEndpoint?: string; serverEndpoint?: string;
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */ /** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
forwardingMode?: 'tun' | 'socket'; forwardingMode?: 'tun' | 'socket';
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
} }
interface IPersistedServerKeys { interface IPersistedServerKeys {
@@ -28,7 +36,7 @@ interface IPersistedServerKeys {
interface IPersistedClient { interface IPersistedClient {
clientId: string; clientId: string;
enabled: boolean; enabled: boolean;
tags?: string[]; serverDefinedClientTags?: string[];
description?: string; description?: string;
assignedIp?: string; assignedIp?: string;
noisePublicKey: string; noisePublicKey: string;
@@ -36,6 +44,8 @@ interface IPersistedClient {
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
expiresAt?: string; expiresAt?: string;
/** @deprecated Legacy field — migrated to serverDefinedClientTags on load */
tags?: string[];
} }
/** /**
@@ -92,7 +102,7 @@ export class VpnManager {
publicKey: client.noisePublicKey, publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey, wgPublicKey: client.wgPublicKey,
enabled: client.enabled, enabled: client.enabled,
tags: client.tags, serverDefinedClientTags: client.serverDefinedClientTags,
description: client.description, description: client.description,
assignedIp: client.assignedIp, assignedIp: client.assignedIp,
expiresAt: client.expiresAt, expiresAt: client.expiresAt,
@@ -122,6 +132,21 @@ export class VpnManager {
}; };
await this.vpnServer.start(serverConfig); await this.vpnServer.start(serverConfig);
// Create initial clients from config (idempotent — skip already-persisted)
if (this.config.initialClients) {
for (const initial of this.config.initialClients) {
if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({
clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags,
description: initial.description,
});
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
}
}
}
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`); logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
} }
@@ -148,7 +173,7 @@ export class VpnManager {
*/ */
public async createClient(opts: { public async createClient(opts: {
clientId: string; clientId: string;
tags?: string[]; serverDefinedClientTags?: string[];
description?: string; description?: string;
}): Promise<plugins.smartvpn.IClientConfigBundle> { }): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) { if (!this.vpnServer) {
@@ -157,7 +182,7 @@ export class VpnManager {
const bundle = await this.vpnServer.createClient({ const bundle = await this.vpnServer.createClient({
clientId: opts.clientId, clientId: opts.clientId,
tags: opts.tags, serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description, description: opts.description,
}); });
@@ -174,7 +199,7 @@ export class VpnManager {
const persisted: IPersistedClient = { const persisted: IPersistedClient = {
clientId: bundle.entry.clientId, clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true, enabled: bundle.entry.enabled ?? true,
tags: bundle.entry.tags, serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
description: bundle.entry.description, description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp, assignedIp: bundle.entry.assignedIp,
noisePublicKey: bundle.entry.publicKey, noisePublicKey: bundle.entry.publicKey,
@@ -186,6 +211,7 @@ export class VpnManager {
this.clients.set(persisted.clientId, persisted); this.clients.set(persisted.clientId, persisted);
await this.persistClient(persisted); await this.persistClient(persisted);
this.config.onClientChanged?.();
return bundle; return bundle;
} }
@@ -199,6 +225,7 @@ export class VpnManager {
await this.vpnServer.removeClient(clientId); await this.vpnServer.removeClient(clientId);
this.clients.delete(clientId); this.clients.delete(clientId);
await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`); await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`);
this.config.onClientChanged?.();
} }
/** /**
@@ -220,6 +247,7 @@ export class VpnManager {
client.updatedAt = Date.now(); client.updatedAt = Date.now();
await this.persistClient(client); await this.persistClient(client);
} }
this.config.onClientChanged?.();
} }
/** /**
@@ -234,6 +262,7 @@ export class VpnManager {
client.updatedAt = Date.now(); client.updatedAt = Date.now();
await this.persistClient(client); await this.persistClient(client);
} }
this.config.onClientChanged?.();
} }
/** /**
@@ -283,6 +312,22 @@ export class VpnManager {
return config; return config;
} }
// ── Tag-based access control ───────────────────────────────────────────
/**
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
*/
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
const ips: string[] = [];
for (const client of this.clients.values()) {
if (!client.enabled || !client.assignedIp) continue;
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
ips.push(client.assignedIp);
}
}
return ips;
}
// ── Status and telemetry ─────────────────────────────────────────────── // ── Status and telemetry ───────────────────────────────────────────────
/** /**
@@ -364,6 +409,12 @@ export class VpnManager {
for (const key of keys) { for (const key of keys) {
const client = await this.storageManager.getJSON<IPersistedClient>(key); const client = await this.storageManager.getJSON<IPersistedClient>(key);
if (client) { if (client) {
// Migrate legacy `tags` → `serverDefinedClientTags`
if (!client.serverDefinedClientTags && client.tags) {
client.serverDefinedClientTags = client.tags;
delete client.tags;
await this.persistClient(client);
}
this.clients.set(client.clientId, client); this.clients.set(client.clientId, client);
} }
} }

View File

@@ -58,6 +58,8 @@ export interface IRouteRemoteIngress {
export interface IRouteVpn { export interface IRouteVpn {
/** Whether this route requires VPN access */ /** Whether this route requires VPN access */
required: boolean; required: boolean;
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
allowedServerDefinedClientTags?: string[];
} }
/** /**

View File

@@ -4,7 +4,7 @@
export interface IVpnClient { export interface IVpnClient {
clientId: string; clientId: string;
enabled: boolean; enabled: boolean;
tags?: string[]; serverDefinedClientTags?: string[];
description?: string; description?: string;
assignedIp?: string; assignedIp?: string;
createdAt: number; createdAt: number;

View File

@@ -49,7 +49,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
request: { request: {
identity: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
clientId: string; clientId: string;
tags?: string[]; serverDefinedClientTags?: string[];
description?: string; description?: string;
}; };
response: { response: {

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.14.0', version: '11.15.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -974,7 +974,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
export const createVpnClientAction = vpnStatePart.createAction<{ export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string; clientId: string;
tags?: string[]; serverDefinedClientTags?: string[];
description?: string; description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => { }>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext(); const context = getActionContext();
@@ -988,7 +988,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
const response = await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
clientId: dataArg.clientId, clientId: dataArg.clientId,
tags: dataArg.tags, serverDefinedClientTags: dataArg.serverDefinedClientTags,
description: dataArg.description, description: dataArg.description,
}); });

View File

@@ -255,8 +255,8 @@ export class OpsViewVpn extends DeesElement {
? html`<span class="statusBadge enabled">enabled</span>` ? html`<span class="statusBadge enabled">enabled</span>`
: html`<span class="statusBadge disabled">disabled</span>`, : html`<span class="statusBadge disabled">disabled</span>`,
'VPN IP': client.assignedIp || '-', 'VPN IP': client.assignedIp || '-',
'Tags': client.tags?.length 'Tags': client.serverDefinedClientTags?.length
? html`${client.tags.map(t => html`<span class="tagBadge">${t}</span>`)}` ? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
: '-', : '-',
'Description': client.description || '-', 'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(), 'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -312,11 +312,11 @@ export class OpsViewVpn extends DeesElement {
action: async (modal: any) => { action: async (modal: any) => {
const form = modal.shadowRoot!.querySelector('dees-form') as any; const form = modal.shadowRoot!.querySelector('dees-form') as any;
const data = await form.collectFormData(); const data = await form.collectFormData();
const tags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined; const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined;
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, { await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId, clientId: data.clientId,
description: data.description || undefined, description: data.description || undefined,
tags, serverDefinedClientTags,
}); });
modal.destroy(); modal.destroy();
}, },