diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts index a810dfde..3f248c33 100644 --- a/src/server/utils/WireGuard.ts +++ b/src/server/utils/WireGuard.ts @@ -6,6 +6,7 @@ import Database from '#server/utils/Database'; import { OLD_ENV, WG_ENV } from '#server/utils/config'; import { firewall } from '#server/utils/firewall'; import { encodeQRCode } from '#server/utils/qr'; +import { routes } from '#server/utils/routes'; import type { ID } from '#server/utils/types'; import { wg } from '#server/utils/wgHelper'; import { setIntervalImmediately } from '#shared/utils/time'; @@ -25,6 +26,7 @@ class WireGuard { const wgInterface = await Database.interfaces.get(); await this.#saveWireguardConfig(wgInterface); await this.#syncWireguardConfig(wgInterface); + await this.#applyRoutes(wgInterface); await this.#applyFirewallRules(wgInterface); } @@ -42,6 +44,17 @@ class WireGuard { ); } + /** + * Reconcile host routes so Server Allowed IPs take effect without a restart. + * `wg syncconf` updates peers but never touches the host routing table. + */ + async #applyRoutes(wgInterface: InterfaceType) { + const clients = await Database.clients.getAll(); + await routes.reconcile(wgInterface, clients, { + enableIpv6: !WG_ENV.DISABLE_IPV6, + }); + } + /** * Generates and saves WireGuard config from database * diff --git a/src/server/utils/routes.ts b/src/server/utils/routes.ts new file mode 100644 index 00000000..a5203e77 --- /dev/null +++ b/src/server/utils/routes.ts @@ -0,0 +1,221 @@ +import { parseCidr, containsCidr } from 'cidr-tools'; +import { stringifyIp } from 'ip-bigint'; +import { createDebug } from 'obug'; + +import { exec } from '#server/utils/cmd'; +import type { ClientType } from '#db/repositories/client/types'; +import type { InterfaceType } from '#db/repositories/interface/types'; + +const ROUTES_DEBUG = createDebug('Routes'); + +// Mutex to prevent concurrent route reconciles +let reconcileInProgress = false; +let reconcileQueued = false; + +type IpVersion = 4 | 6; + +type NormalizedRoute = { + cidr: string; + version: IpVersion; +}; + +type DeviceRoute = { + cidr: string; + managed: boolean; +}; + +type DesiredRoutes = { + 4: Set; + 6: Set; +}; + +type RoutesClient = Pick; + +/** + * Canonicalize a Server Allowed IPs entry to `network/prefix` form. + * `192.168.120.5/22` -> `192.168.120.0/22`; a bare IP gains `/32` or `/128`. + * Returns null for input cidr-tools cannot parse (serverAllowedIps is validated + * only as a permissive string). + */ +function normalizeRoute(entry: string): NormalizedRoute | null { + try { + const parsed = parseCidr(entry); + const network = stringifyIp({ + number: parsed.start, + version: parsed.version, + }); + return { cidr: `${network}/${parsed.prefix}`, version: parsed.version }; + } catch { + return null; + } +} + +/** + * Whether wg-easy should add/remove this route. Excludes default routes (they + * need wg-quick's policy routing) and IPv6 link-local / multicast. + */ +function isManageable(cidr: string, version: IpVersion): boolean { + if (cidr.endsWith('/0')) { + return false; + } + if ( + version === 6 && + (containsCidr('fe80::/10', cidr) || containsCidr('ff00::/8', cidr)) + ) { + return false; + } + return true; +} + +/** + * Build the desired managed routes (per IP family) from enabled clients' + * Server Allowed IPs. IPv6 is dropped when IPv6 is disabled. + */ +function collectDesiredRoutes( + clients: RoutesClient[], + options: { enableIpv6: boolean } +): DesiredRoutes { + const desired: DesiredRoutes = { 4: new Set(), 6: new Set() }; + + for (const client of clients) { + if (!client.enabled) { + continue; + } + for (const entry of client.serverAllowedIps ?? []) { + const normalized = normalizeRoute(entry); + if (!normalized) { + continue; + } + if (!isManageable(normalized.cidr, normalized.version)) { + continue; + } + if (normalized.version === 6 && !options.enableIpv6) { + continue; + } + desired[normalized.version].add(normalized.cidr); + } + } + + return desired; +} + +/** + * Parse `ip route show dev ` output into routes tagged with whether wg-easy + * manages them. Kernel-installed routes (the connected subnet, link-local, + * multicast) are present but flagged unmanaged, so they are never removed and + * never re-added. + */ +function parseDeviceRoutes(output: string): DeviceRoute[] { + const result: DeviceRoute[] = []; + + for (const line of output.trim().split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const dest = trimmed.split(/\s+/)[0]; + if (!dest || dest === 'default') { + continue; + } + const normalized = normalizeRoute(dest); + if (!normalized) { + continue; + } + const managed = + !trimmed.includes('proto kernel') && + isManageable(normalized.cidr, normalized.version); + result.push({ cidr: normalized.cidr, managed }); + } + + return result; +} + +/** + * Compute which routes to add and which managed routes to remove. A desired + * route already present in any form (including a kernel route) is not re-added; + * only managed routes are removed. + */ +function diffRoutes( + desired: Set, + current: DeviceRoute[] +): { toAdd: string[]; toDel: string[] } { + const currentCidrs = new Set(current.map((route) => route.cidr)); + + const toAdd = [...desired].filter((cidr) => !currentCidrs.has(cidr)); + const toDel = current + .filter((route) => route.managed && !desired.has(route.cidr)) + .map((route) => route.cidr); + + return { toAdd, toDel }; +} + +export const routes = { + /** + * Reconcile the host routing table for the interface so it matches the Server + * Allowed IPs of all enabled clients. Runs after `wg syncconf`, which updates + * peers but not routes. Adds missing routes and removes stale managed ones, + * without bouncing the interface. + */ + async reconcile( + wgInterface: InterfaceType, + clients: RoutesClient[], + options: { enableIpv6: boolean } + ): Promise { + if (reconcileInProgress) { + ROUTES_DEBUG('Reconcile already in progress, queuing'); + reconcileQueued = true; + return; + } + + reconcileInProgress = true; + + try { + const desired = collectDesiredRoutes(clients, options); + const families: IpVersion[] = options.enableIpv6 ? [4, 6] : [4]; + + for (const version of families) { + const output = await exec( + `ip -${version} route show dev ${wgInterface.name}`, + { log: false } + ); + const current = parseDeviceRoutes(output); + const { toAdd, toDel } = diffRoutes(desired[version], current); + + // Delete before add to avoid a transient conflict on a prefix change. + for (const cidr of toDel) { + try { + await exec( + `ip -${version} route del ${cidr} dev ${wgInterface.name}` + ); + } catch (err) { + ROUTES_DEBUG(`Failed to remove route ${cidr}:`, err); + } + } + for (const cidr of toAdd) { + try { + await exec( + `ip -${version} route add ${cidr} dev ${wgInterface.name}` + ); + } catch (err) { + ROUTES_DEBUG(`Failed to add route ${cidr}:`, err); + } + } + } + } finally { + reconcileInProgress = false; + if (reconcileQueued) { + reconcileQueued = false; + ROUTES_DEBUG('Processing queued reconcile'); + await this.reconcile(wgInterface, clients, options); + } + } + }, +}; + +export const routesTestExports = { + normalizeRoute, + isManageable, + collectDesiredRoutes, + parseDeviceRoutes, + diffRoutes, +}; diff --git a/src/test/unit/routes.spec.ts b/src/test/unit/routes.spec.ts new file mode 100644 index 00000000..d058bdb4 --- /dev/null +++ b/src/test/unit/routes.spec.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'vitest'; + +import { routesTestExports } from '#server/utils/routes'; + +const { + normalizeRoute, + isManageable, + collectDesiredRoutes, + parseDeviceRoutes, + diffRoutes, +} = routesTestExports; + +describe('routes', () => { + describe('normalizeRoute', () => { + test('canonicalizes IPv4 host bits to the network address', () => { + expect(normalizeRoute('192.168.120.5/22')).toEqual({ + cidr: '192.168.120.0/22', + version: 4, + }); + }); + test('adds /32 to a bare IPv4 address', () => { + expect(normalizeRoute('10.0.0.5')).toEqual({ + cidr: '10.0.0.5/32', + version: 4, + }); + }); + test('canonicalizes IPv6 host bits to the network address', () => { + expect(normalizeRoute('2001:db8::5/64')).toEqual({ + cidr: '2001:db8::/64', + version: 6, + }); + }); + test('adds /128 to a bare IPv6 address', () => { + expect(normalizeRoute('2001:db8::1')).toEqual({ + cidr: '2001:db8::1/128', + version: 6, + }); + }); + test('returns null for unparseable input', () => { + expect(normalizeRoute('garbage')).toBeNull(); + expect(normalizeRoute('')).toBeNull(); + }); + }); + + describe('isManageable', () => { + test('rejects default routes', () => { + expect(isManageable('0.0.0.0/0', 4)).toBe(false); + expect(isManageable('::/0', 6)).toBe(false); + }); + test('rejects IPv6 link-local and multicast', () => { + expect(isManageable('fe80::/64', 6)).toBe(false); + expect(isManageable('ff00::/8', 6)).toBe(false); + }); + test('accepts ordinary subnets', () => { + expect(isManageable('192.168.120.0/22', 4)).toBe(true); + expect(isManageable('2001:db8::/64', 6)).toBe(true); + }); + }); + + describe('collectDesiredRoutes', () => { + test('unions enabled clients and splits by family', () => { + const result = collectDesiredRoutes( + [ + { + enabled: true, + serverAllowedIps: ['192.168.120.0/22', '2001:db8::/64'], + }, + { enabled: true, serverAllowedIps: ['10.10.0.0/24'] }, + ], + { enableIpv6: true } + ); + expect([...result[4]].sort()).toEqual([ + '10.10.0.0/24', + '192.168.120.0/22', + ]); + expect([...result[6]]).toEqual(['2001:db8::/64']); + }); + test('ignores disabled clients', () => { + const result = collectDesiredRoutes( + [{ enabled: false, serverAllowedIps: ['192.168.120.0/22'] }], + { enableIpv6: true } + ); + expect(result[4].size).toBe(0); + }); + test('dedupes and normalizes host bits across clients', () => { + const result = collectDesiredRoutes( + [ + { enabled: true, serverAllowedIps: ['192.168.120.5/22'] }, + { enabled: true, serverAllowedIps: ['192.168.120.99/22'] }, + ], + { enableIpv6: true } + ); + expect([...result[4]]).toEqual(['192.168.120.0/22']); + }); + test('drops default routes', () => { + const result = collectDesiredRoutes( + [{ enabled: true, serverAllowedIps: ['0.0.0.0/0', '::/0'] }], + { enableIpv6: true } + ); + expect(result[4].size).toBe(0); + expect(result[6].size).toBe(0); + }); + test('drops IPv6 when disabled', () => { + const result = collectDesiredRoutes( + [{ enabled: true, serverAllowedIps: ['2001:db8::/64'] }], + { enableIpv6: false } + ); + expect(result[6].size).toBe(0); + }); + test('skips unparseable entries', () => { + const result = collectDesiredRoutes( + [{ enabled: true, serverAllowedIps: ['garbage', '10.0.0.0/24'] }], + { enableIpv6: true } + ); + expect([...result[4]]).toEqual(['10.0.0.0/24']); + }); + }); + + describe('parseDeviceRoutes', () => { + test('flags the kernel connected route as unmanaged', () => { + const output = [ + '100.255.1.0/24 proto kernel scope link src 100.255.1.1', + '192.168.120.0/22 scope link', + ].join('\n'); + expect(parseDeviceRoutes(output)).toEqual([ + { cidr: '100.255.1.0/24', managed: false }, + { cidr: '192.168.120.0/22', managed: true }, + ]); + }); + test('skips default routes', () => { + expect(parseDeviceRoutes('default via 10.0.0.1')).toEqual([]); + }); + test('handles empty output', () => { + expect(parseDeviceRoutes('')).toEqual([]); + expect(parseDeviceRoutes('\n \n')).toEqual([]); + }); + test('flags IPv6 link-local kernel route as unmanaged', () => { + const output = [ + 'fe80::/64 proto kernel metric 256 pref medium', + '2001:db8::/64 metric 1024 pref medium', + ].join('\n'); + expect(parseDeviceRoutes(output)).toEqual([ + { cidr: 'fe80::/64', managed: false }, + { cidr: '2001:db8::/64', managed: true }, + ]); + }); + }); + + describe('diffRoutes', () => { + test('adds desired routes that are not present', () => { + const current = [{ cidr: '100.255.1.0/24', managed: false }]; + const { toAdd, toDel } = diffRoutes( + new Set(['192.168.120.0/22']), + current + ); + expect(toAdd).toEqual(['192.168.120.0/22']); + expect(toDel).toEqual([]); + }); + test('removes stale managed routes', () => { + const current = [ + { cidr: '192.168.120.0/22', managed: true }, + { cidr: '10.10.0.0/24', managed: true }, + ]; + const { toAdd, toDel } = diffRoutes( + new Set(['192.168.120.0/22']), + current + ); + expect(toAdd).toEqual([]); + expect(toDel).toEqual(['10.10.0.0/24']); + }); + test('never removes unmanaged routes', () => { + const current = [{ cidr: '100.255.1.0/24', managed: false }]; + const { toDel } = diffRoutes(new Set(), current); + expect(toDel).toEqual([]); + }); + test('does not re-add a route already present as a kernel route', () => { + const current = [{ cidr: '100.255.1.0/24', managed: false }]; + const { toAdd } = diffRoutes(new Set(['100.255.1.0/24']), current); + expect(toAdd).toEqual([]); + }); + }); +});