2026-02-27 10:18:23 +00:00
|
|
|
import * as plugins from './smartvpn.plugins.js';
|
|
|
|
|
import { VpnBridge } from './smartvpn.classes.vpnbridge.js';
|
|
|
|
|
import type {
|
|
|
|
|
IVpnServerOptions,
|
|
|
|
|
IVpnServerConfig,
|
|
|
|
|
IVpnStatus,
|
|
|
|
|
IVpnServerStatistics,
|
|
|
|
|
IVpnClientInfo,
|
|
|
|
|
IVpnKeypair,
|
2026-03-15 18:10:25 +00:00
|
|
|
IVpnClientTelemetry,
|
2026-03-29 15:24:41 +00:00
|
|
|
IWgPeerConfig,
|
|
|
|
|
IWgPeerInfo,
|
2026-03-29 17:04:27 +00:00
|
|
|
IClientEntry,
|
|
|
|
|
IClientConfigBundle,
|
2026-03-30 14:32:02 +00:00
|
|
|
IDestinationPolicy,
|
2026-02-27 10:18:23 +00:00
|
|
|
TVpnServerCommands,
|
|
|
|
|
} from './smartvpn.interfaces.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* VPN Server — manages a smartvpn daemon in server mode.
|
|
|
|
|
*/
|
|
|
|
|
export class VpnServer extends plugins.events.EventEmitter {
|
|
|
|
|
private bridge: VpnBridge<TVpnServerCommands>;
|
|
|
|
|
private options: IVpnServerOptions;
|
2026-03-30 14:32:02 +00:00
|
|
|
private nft?: plugins.smartnftables.SmartNftables;
|
|
|
|
|
private nftHealthInterval?: ReturnType<typeof setInterval>;
|
|
|
|
|
private nftSubnet?: string;
|
|
|
|
|
private nftPolicy?: IDestinationPolicy;
|
2026-02-27 10:18:23 +00:00
|
|
|
|
|
|
|
|
constructor(options: IVpnServerOptions) {
|
|
|
|
|
super();
|
|
|
|
|
this.options = options;
|
|
|
|
|
this.bridge = new VpnBridge<TVpnServerCommands>({
|
|
|
|
|
transport: options.transport,
|
|
|
|
|
mode: 'server',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Forward bridge events
|
|
|
|
|
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
|
|
|
|
this.emit('exit', { code, signal });
|
|
|
|
|
});
|
|
|
|
|
this.bridge.on('reconnected', () => {
|
|
|
|
|
this.emit('reconnected');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start the daemon bridge (spawn or connect).
|
|
|
|
|
*/
|
|
|
|
|
public async start(config?: IVpnServerConfig): Promise<void> {
|
|
|
|
|
const started = await this.bridge.start();
|
|
|
|
|
if (!started) {
|
|
|
|
|
throw new Error('VpnServer: failed to start daemon bridge');
|
|
|
|
|
}
|
|
|
|
|
const cfg = config || this.options.config;
|
|
|
|
|
if (cfg) {
|
|
|
|
|
await this.bridge.sendCommand('start', { config: cfg });
|
2026-03-30 14:32:02 +00:00
|
|
|
|
|
|
|
|
// For TUN mode with a destination policy, set up nftables rules
|
|
|
|
|
if (cfg.forwardingMode === 'tun' && cfg.destinationPolicy) {
|
|
|
|
|
await this.setupTunDestinationPolicy(cfg.subnet, cfg.destinationPolicy);
|
|
|
|
|
}
|
2026-02-27 10:18:23 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop the VPN server.
|
|
|
|
|
*/
|
|
|
|
|
public async stopServer(): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get server status.
|
|
|
|
|
*/
|
|
|
|
|
public async getStatus(): Promise<IVpnStatus> {
|
|
|
|
|
return this.bridge.sendCommand('getStatus', {} as Record<string, never>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get server statistics.
|
|
|
|
|
*/
|
|
|
|
|
public async getStatistics(): Promise<IVpnServerStatistics> {
|
|
|
|
|
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List connected clients.
|
|
|
|
|
*/
|
|
|
|
|
public async listClients(): Promise<IVpnClientInfo[]> {
|
|
|
|
|
const result = await this.bridge.sendCommand('listClients', {} as Record<string, never>);
|
|
|
|
|
return result.clients;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Disconnect a specific client.
|
|
|
|
|
*/
|
|
|
|
|
public async disconnectClient(clientId: string): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('disconnectClient', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a new Noise keypair.
|
|
|
|
|
*/
|
|
|
|
|
public async generateKeypair(): Promise<IVpnKeypair> {
|
|
|
|
|
return this.bridge.sendCommand('generateKeypair', {} as Record<string, never>);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 18:10:25 +00:00
|
|
|
/**
|
|
|
|
|
* Set rate limit for a specific client.
|
|
|
|
|
*/
|
|
|
|
|
public async setClientRateLimit(
|
|
|
|
|
clientId: string,
|
|
|
|
|
rateBytesPerSec: number,
|
|
|
|
|
burstBytes: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('setClientRateLimit', {
|
|
|
|
|
clientId,
|
|
|
|
|
rateBytesPerSec,
|
|
|
|
|
burstBytes,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove rate limit for a specific client (unlimited).
|
|
|
|
|
*/
|
|
|
|
|
public async removeClientRateLimit(clientId: string): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('removeClientRateLimit', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get telemetry for a specific client.
|
|
|
|
|
*/
|
|
|
|
|
public async getClientTelemetry(clientId: string): Promise<IVpnClientTelemetry> {
|
|
|
|
|
return this.bridge.sendCommand('getClientTelemetry', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 15:24:41 +00:00
|
|
|
/**
|
|
|
|
|
* Generate a WireGuard-compatible X25519 keypair.
|
|
|
|
|
*/
|
|
|
|
|
public async generateWgKeypair(): Promise<IVpnKeypair> {
|
|
|
|
|
return this.bridge.sendCommand('generateWgKeypair', {} as Record<string, never>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add a WireGuard peer (server must be running in wireguard mode).
|
|
|
|
|
*/
|
|
|
|
|
public async addWgPeer(peer: IWgPeerConfig): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('addWgPeer', { peer });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a WireGuard peer by public key.
|
|
|
|
|
*/
|
|
|
|
|
public async removeWgPeer(publicKey: string): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('removeWgPeer', { publicKey });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List WireGuard peers with stats.
|
|
|
|
|
*/
|
|
|
|
|
public async listWgPeers(): Promise<IWgPeerInfo[]> {
|
|
|
|
|
const result = await this.bridge.sendCommand('listWgPeers', {} as Record<string, never>);
|
|
|
|
|
return result.peers;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 17:04:27 +00:00
|
|
|
// ── Client Registry (Hub) Methods ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a new client. Generates keypairs, assigns IP, returns full config bundle.
|
|
|
|
|
* The secrets (private keys) are only returned at creation time.
|
|
|
|
|
*/
|
|
|
|
|
public async createClient(opts: Partial<IClientEntry>): Promise<IClientConfigBundle> {
|
|
|
|
|
return this.bridge.sendCommand('createClient', { client: opts });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a registered client (also disconnects if connected).
|
|
|
|
|
*/
|
|
|
|
|
public async removeClient(clientId: string): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('removeClient', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a registered client by ID.
|
|
|
|
|
*/
|
|
|
|
|
public async getClient(clientId: string): Promise<IClientEntry> {
|
|
|
|
|
return this.bridge.sendCommand('getClient', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List all registered clients.
|
|
|
|
|
*/
|
|
|
|
|
public async listRegisteredClients(): Promise<IClientEntry[]> {
|
|
|
|
|
const result = await this.bridge.sendCommand('listRegisteredClients', {} as Record<string, never>);
|
|
|
|
|
return result.clients;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update a registered client's fields (ACLs, tags, description, etc.).
|
|
|
|
|
*/
|
|
|
|
|
public async updateClient(clientId: string, update: Partial<IClientEntry>): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('updateClient', { clientId, update });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enable a previously disabled client.
|
|
|
|
|
*/
|
|
|
|
|
public async enableClient(clientId: string): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('enableClient', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Disable a client (also disconnects if connected).
|
|
|
|
|
*/
|
|
|
|
|
public async disableClient(clientId: string): Promise<void> {
|
|
|
|
|
await this.bridge.sendCommand('disableClient', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rotate a client's keys. Returns a new config bundle with fresh keypairs.
|
|
|
|
|
*/
|
|
|
|
|
public async rotateClientKey(clientId: string): Promise<IClientConfigBundle> {
|
|
|
|
|
return this.bridge.sendCommand('rotateClientKey', { clientId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Export a client config (without secrets) in the specified format.
|
|
|
|
|
*/
|
|
|
|
|
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
|
|
|
|
const result = await this.bridge.sendCommand('exportClientConfig', { clientId, format });
|
|
|
|
|
return result.config;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a standalone Noise IK keypair (not tied to a client).
|
|
|
|
|
*/
|
|
|
|
|
public async generateClientKeypair(): Promise<IVpnKeypair> {
|
|
|
|
|
return this.bridge.sendCommand('generateClientKeypair', {} as Record<string, never>);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 14:32:02 +00:00
|
|
|
// ── 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 10:18:23 +00:00
|
|
|
/**
|
|
|
|
|
* Stop the daemon bridge.
|
|
|
|
|
*/
|
|
|
|
|
public stop(): void {
|
2026-03-30 14:32:02 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-27 10:18:23 +00:00
|
|
|
this.bridge.stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether the bridge is running.
|
|
|
|
|
*/
|
|
|
|
|
public get running(): boolean {
|
|
|
|
|
return this.bridge.running;
|
|
|
|
|
}
|
|
|
|
|
}
|