Browse Source

Merge 5ed1e1130d into 78b2838498

pull/2679/merge
noderaven 4 days ago
committed by GitHub
parent
commit
e0fe4af3bd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 13
      src/server/utils/WireGuard.ts
  2. 221
      src/server/utils/routes.ts
  3. 182
      src/test/unit/routes.spec.ts

13
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
*

221
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<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,
};

182
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([]);
});
});
});
Loading…
Cancel
Save