feat(vpnserver): add nftables-backed destination policy enforcement for TUN mode

This commit is contained in:
2026-03-30 14:32:02 +00:00
parent e513f8686b
commit 8eb26e1920
7 changed files with 302 additions and 23 deletions

View File

@@ -12,6 +12,7 @@ import type {
IWgPeerInfo,
IClientEntry,
IClientConfigBundle,
IDestinationPolicy,
TVpnServerCommands,
} from './smartvpn.interfaces.js';
@@ -21,6 +22,10 @@ import type {
export class VpnServer extends plugins.events.EventEmitter {
private bridge: VpnBridge<TVpnServerCommands>;
private options: IVpnServerOptions;
private nft?: plugins.smartnftables.SmartNftables;
private nftHealthInterval?: ReturnType<typeof setInterval>;
private nftSubnet?: string;
private nftPolicy?: IDestinationPolicy;
constructor(options: IVpnServerOptions) {
super();
@@ -50,6 +55,11 @@ export class VpnServer extends plugins.events.EventEmitter {
const cfg = config || this.options.config;
if (cfg) {
await this.bridge.sendCommand('start', { config: cfg });
// For TUN mode with a destination policy, set up nftables rules
if (cfg.forwardingMode === 'tun' && cfg.destinationPolicy) {
await this.setupTunDestinationPolicy(cfg.subnet, cfg.destinationPolicy);
}
}
}
@@ -229,10 +239,110 @@ export class VpnServer extends plugins.events.EventEmitter {
return this.bridge.sendCommand('generateClientKeypair', {} as Record<string, never>);
}
// ── TUN Destination Policy via nftables ──────────────────────────────
/**
* Set up nftables rules for TUN mode destination policy.
* Also starts a 60-second health check interval to re-apply if rules are removed externally.
*/
private async setupTunDestinationPolicy(subnet: string, policy: IDestinationPolicy): Promise<void> {
this.nftSubnet = subnet;
this.nftPolicy = policy;
this.nft = new plugins.smartnftables.SmartNftables({
tableName: 'smartvpn_tun',
dryRun: process.getuid?.() !== 0,
});
await this.nft.initialize();
await this.applyDestinationPolicyRules();
// Health check: re-apply rules if they disappear
this.nftHealthInterval = setInterval(async () => {
if (!this.nft) return;
try {
const exists = await this.nft.tableExists();
if (!exists) {
console.warn('[smartvpn] nftables rules missing, re-applying destination policy');
this.nft = new plugins.smartnftables.SmartNftables({
tableName: 'smartvpn_tun',
});
await this.nft.initialize();
await this.applyDestinationPolicyRules();
}
} catch (err) {
console.warn(`[smartvpn] nftables health check failed: ${err}`);
}
}, 60_000);
}
/**
* Apply destination policy as nftables rules.
* Order: blockList (drop) → allowList (accept) → default action.
*/
private async applyDestinationPolicyRules(): Promise<void> {
if (!this.nft || !this.nftSubnet || !this.nftPolicy) return;
const subnet = this.nftSubnet;
const policy = this.nftPolicy;
const family = 'ip';
const table = 'smartvpn_tun';
const commands: string[] = [];
// 1. Block list (deny wins — evaluated first)
if (policy.blockList) {
for (const dest of policy.blockList) {
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} drop`
);
}
}
// 2. Allow list (pass through directly — skip DNAT)
if (policy.allowList) {
for (const dest of policy.allowList) {
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} accept`
);
}
}
// 3. Default action
switch (policy.default) {
case 'forceTarget': {
const target = policy.target || '127.0.0.1';
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} dnat to ${target}`
);
break;
}
case 'block':
commands.push(
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} drop`
);
break;
case 'allow':
// No rule needed — kernel default allows
break;
}
if (commands.length > 0) {
await this.nft.applyRuleGroup('vpn-destination-policy', commands);
}
}
/**
* Stop the daemon bridge.
*/
public stop(): void {
// Clean up nftables rules
if (this.nftHealthInterval) {
clearInterval(this.nftHealthInterval);
this.nftHealthInterval = undefined;
}
if (this.nft) {
this.nft.cleanup().catch(() => {}); // best-effort cleanup
this.nft = undefined;
}
this.bridge.stop();
}