mirror of https://github.com/wg-easy/wg-easy
committed by
GitHub
3 changed files with 416 additions and 0 deletions
@ -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<string>; |
|||
6: Set<string>; |
|||
}; |
|||
|
|||
type RoutesClient = Pick<ClientType, 'enabled' | 'serverAllowedIps'>; |
|||
|
|||
/** |
|||
* 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 <inf>` 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<string>, |
|||
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<void> { |
|||
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, |
|||
}; |
|||
@ -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([]); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue