feat(vpnserver): add nftables-backed destination policy enforcement for TUN mode
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user