Compare commits

...

4 Commits

Author SHA1 Message Date
ca990781b0 v11.21.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:45:46 +00:00
6807aefce8 feat(vpn): add tag-aware WireGuard AllowedIPs for VPN-gated routes 2026-03-31 00:45:46 +00:00
450ec4816e v11.20.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 00:08:54 +00:00
ab4310b775 fix(vpn-manager): persist WireGuard private keys for valid client exports and QR codes 2026-03-31 00:08:54 +00:00
7 changed files with 111 additions and 8 deletions

View File

@@ -1,5 +1,18 @@
# Changelog
## 2026-03-31 - 11.21.0 - feat(vpn)
add tag-aware WireGuard AllowedIPs for VPN-gated routes
- compute per-client WireGuard AllowedIPs from server-defined client tags and VPN-required proxy routes
- include the server public IP in AllowedIPs when a client can access VPN-gated domains so routed traffic reaches the proxy
- preserve and inject WireGuard private keys in generated and exported client configs for valid exports
## 2026-03-31 - 11.20.1 - fix(vpn-manager)
persist WireGuard private keys for valid client exports and QR codes
- Store each client's WireGuard private key when creating and rotating keys.
- Inject the stored private key into exported WireGuard configs so generated configs are complete and scannable.
## 2026-03-30 - 11.20.0 - feat(vpn-ui)
add QR code export for WireGuard client configurations

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.20.0",
"version": "11.21.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {

View File

@@ -1,6 +1,8 @@
import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({
// Server public IP (used for VPN AllowedIPs)
publicIp: '203.0.113.1',
// SmartProxy routes for development/demo
smartProxyConfig: {
routes: [
@@ -23,7 +25,19 @@ const devRouter = new DcRouter({
tls: { mode: 'passthrough' },
},
},
],
{
name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { required: true },
},
{
name: 'vpn-eng-dashboard',
match: { ports: [18080], domains: ['eng.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
},
] as any[],
},
// VPN with pre-defined clients
vpnConfig: {

View File

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

View File

@@ -2105,6 +2105,39 @@ export class DcRouter {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
getClientAllowedIPs: (clientTags: string[]) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
// Determine the server's public-facing IP(s) that VPN-gated domains resolve to
const publicIPs: string[] = [];
if (this.options.proxyIps?.length) {
publicIPs.push(...this.options.proxyIps);
}
if (this.options.publicIp) {
publicIPs.push(this.options.publicIp);
} else if (this.detectedPublicIp) {
publicIPs.push(this.detectedPublicIp);
}
if (!publicIPs.length) return [...ips];
// Check routes for VPN-gated tag match
const routes = this.options.smartProxyConfig?.routes || [];
for (const route of routes) {
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) continue;
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {
for (const ip of publicIPs) {
ips.add(`${ip}/32`);
}
break; // All routes resolve to the same server IPs
}
}
return [...ips];
},
});
await this.vpnManager.start();

View File

@@ -29,6 +29,10 @@ export interface IVpnManagerConfig {
allowList?: string[];
blockList?: string[];
};
/** Compute per-client AllowedIPs based on the client's server-defined tags.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (clientTags: string[]) => string[];
}
interface IPersistedServerKeys {
@@ -46,6 +50,8 @@ interface IPersistedClient {
assignedIp?: string;
noisePublicKey: string;
wgPublicKey: string;
/** WireGuard private key — stored so exports and QR codes produce valid configs */
wgPrivateKey?: string;
createdAt: number;
updatedAt: number;
expiresAt?: string;
@@ -188,7 +194,16 @@ export class VpnManager {
description: opts.description,
});
// Persist client entry (without private keys)
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
// Persist client entry (including WG private key for export/QR)
const persisted: IPersistedClient = {
clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true,
@@ -197,6 +212,8 @@ export class VpnManager {
assignedIp: bundle.entry.assignedIp,
noisePublicKey: bundle.entry.publicKey,
wgPublicKey: bundle.entry.wgPublicKey || '',
wgPrivateKey: bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(),
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
@@ -265,11 +282,13 @@ export class VpnManager {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
// Update persisted entry with new public keys
// Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
client.updatedAt = Date.now();
await this.persistClient(client);
}
@@ -278,11 +297,35 @@ export class VpnManager {
}
/**
* Export a client config (without secrets).
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
*/
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
if (!this.vpnServer) throw new Error('VPN server not running');
return this.vpnServer.exportClientConfig(clientId, format);
let config = await this.vpnServer.exportClientConfig(clientId, format);
if (format === 'wireguard') {
const persisted = this.clients.get(clientId);
// Inject stored WG private key so exports produce valid, scannable configs
if (persisted?.wgPrivateKey) {
config = config.replace(
'[Interface]\n',
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
);
}
// Override AllowedIPs with per-client values based on tag-matched routes
if (this.config.getClientAllowedIPs) {
const clientTags = persisted?.serverDefinedClientTags || [];
const allowedIPs = this.config.getClientAllowedIPs(clientTags);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
}
return config;
}
// ── Tag-based access control ───────────────────────────────────────────

View File

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