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