Files
smartvpn/ts/smartvpn.classes.vpnserver.ts

356 lines
10 KiB
TypeScript

import * as plugins from './smartvpn.plugins.js';
import { VpnBridge } from './smartvpn.classes.vpnbridge.js';
import type {
IVpnServerOptions,
IVpnServerConfig,
IVpnStatus,
IVpnServerStatistics,
IVpnClientInfo,
IVpnKeypair,
IVpnClientTelemetry,
IWgPeerConfig,
IWgPeerInfo,
IClientEntry,
IClientConfigBundle,
IDestinationPolicy,
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;
private nft?: plugins.smartnftables.SmartNftables;
private nftHealthInterval?: ReturnType<typeof setInterval>;
private nftSubnet?: string;
private nftPolicy?: IDestinationPolicy;
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 });
// For TUN mode with a destination policy, set up nftables rules
if (cfg.forwardingMode === 'tun' && cfg.destinationPolicy) {
await this.setupTunDestinationPolicy(cfg.subnet, cfg.destinationPolicy);
}
}
}
/**
* 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>);
}
/**
* 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 });
}
/**
* 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;
}
// ── 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>);
}
// ── 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();
}
/**
* Whether the bridge is running.
*/
public get running(): boolean {
return this.bridge.running;
}
}