@adviser/ovn-fabric 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,622 +0,0 @@
1
- // src/generate-netns.ts — emitUplinkNetns(): the netns-side setup
2
- // commands for one uplink (creates its netns, moves its real interface
3
- // in, wires up the transfer-link veth, brings up SLAAC/dhclient, and
4
- // configures NAT for whichever segments currently resolve to it).
5
- //
6
- // Pulled into generate-ovn.ts's single per-host script (see that
7
- // file's scriptForHost) rather than being its own standalone generator
8
- // — "one file, copy it, run it, everything is done" applies to the
9
- // whole topology, not per-tier. This file exists separately only to
10
- // keep generate-ovn.ts from growing unwieldy; it has no CLI surface
11
- // of its own.
12
- //
13
- // Scope: the netns side of each uplink's transfer link. This is the
14
- // direction SLAAC/DHCP actually need real kernel mechanism, not OVN
15
- // config:
16
- // - IPv4: a DHCP client started directly on the real interface,
17
- // inside the netns, backgrounded so this synchronous setup script
18
- // can finish while lease acquisition continues independently.
19
- // WHICH client (dhclient, dhcpcd, ...) is pluggable per uplink —
20
- // see Uplink.discovery.client (types.ts) and emitIpv4Discovery
21
- // below, which dispatches to one small emit function per client,
22
- // all with the same idempotent shape. (ovn_uplink_sync.py
23
- // supervises dhclient via a real systemd unit with Restart=always
24
- // — this generator does not replace that level of supervision,
25
- // only the one-time wiring.)
26
- // - IPv6: kernel SLAAC autoconf via accept_ra. Forwarding (needed for
27
- // routing between the veth and the real interface) SUPPRESSES RA
28
- // acceptance unless accept_ra is explicitly set to 2 — confirmed
29
- // live, this session — so this always sets accept_ra=2 on the real
30
- // interface for any uplink with discovery.ipv6=slaac.
31
- //
32
- // NAT subnets are NOT stored on Uplink itself — they're derived by the
33
- // caller (generate-ovn.ts), at generation time, from which segments in
34
- // the same NetworkDefinition currently resolve (via
35
- // UplinkSelector.resolve()) to this uplink. Same "reflects resolution
36
- // AT GENERATION TIME" rule as the backbone join — re-run after
37
- // switching a segment's uplink to regenerate.
38
- //
39
- // Idempotent throughout: safe to re-run. Network namespace and
40
- // interface existence are checked before creating; iptables rules are
41
- // checked via -C before adding (iptables has no --may-exist).
42
-
43
- import type {
44
- Backdoor,
45
- DhcpClient,
46
- InterfaceKind,
47
- Segment,
48
- Uplink,
49
- } from "./types.ts";
50
-
51
- function netnsName(u: Uplink): string {
52
- return `ns-uplink-${u.name}`;
53
- }
54
-
55
- /** Real kernel interface an uplink's netns ultimately reaches the real
56
- * world (or, for "dummy" with no backdoor, a placeholder) through —
57
- * undefined for any InterfaceKind this generator can't wire up yet.
58
- * Shared by emitUplinkNetns (this uplink's own real interface) and the
59
- * backdoor NAT rule (the `via` uplink's real interface) so the
60
- * backdoor/vlan/dummy/physical dispatch only lives in one place.
61
- *
62
- * A real InterfaceKind (vlan/physical/wireguard) ALWAYS wins over a
63
- * backdoor, checked first below: an uplink can have both — a real
64
- * WireGuard tunnel needs its own bootstrap path to reach its peer at
65
- * all, which is exactly what a backdoor provides, but the TUNNEL is
66
- * still what's actually realIface for NAT/segment purposes, not the
67
- * backdoor's own veth (see emitBackdoorNat's fork on this same
68
- * check). Only when the uplink has NO real interface of its own (the
69
- * "dummy" placeholder kind) does the backdoor's netns-side veth (see
70
- * emitBackdoorVethAndRoute below) stand in as realIface instead.
71
- * Confirmed live, this session: an earlier version kept a separate,
72
- * address-less "dummy" device alongside the backdoor veth — two
73
- * interfaces for one job, with the actually-useful one (the backdoor
74
- * veth) never treated as `realIface` at all, so accept_ra/discovery/
75
- * NAT below never applied to it. One interface, not two — for the
76
- * dummy case. Added 2026-07-06 for real wireguard uplinks: same
77
- * principle, different fork of the SAME check. */
78
- function realIfaceFor(u: Uplink): string | undefined {
79
- if (u.if.kind === "wireguard") return u.if.ifaceName;
80
- if (u.backdoor !== undefined) return backdoorVethNetns(u.backdoor);
81
- if (u.if.kind === "vlan") {
82
- return u.if.ifaceName ?? `${u.if.vlanParent}.${u.if.vlanId}`;
83
- }
84
- if (u.if.kind === "dummy") return dummyIface(u);
85
- if (u.if.kind === "physical") return u.if.name;
86
- return undefined;
87
- }
88
-
89
- /** WireGuard: writes the conf file (built from `ifc.config` — see
90
- * WireguardInterfaceConfig, types.ts) to /etc/wireguard/<ifaceName>.conf
91
- * on the host, then shells out to `wg-quick up`, idempotently, inside
92
- * this uplink's own netns.
93
- *
94
- * The conf is written to a temp file first and compared (`cmp -s`)
95
- * against the real path before replacing it — same idempotency shape
96
- * as this script's own self-install step (generate-ovn.ts) — so an
97
- * unchanged config never bounces an already-up tunnel. Only when the
98
- * content actually changed does it bring the old tunnel down first
99
- * (wg-quick has no in-place reconfigure; the interface must be
100
- * recreated to pick up new keys/peers).
101
- *
102
- * `wg show <ifaceName>` is the idempotency check for actually bringing
103
- * it up: wg-quick has no --may-exist equivalent, but a running
104
- * interface with that name means it already did its job.
105
- *
106
- * NOTE: this embeds ifc.config.privateKey directly in the generated
107
- * script — see InterfaceKind's "wireguard" variant (types.ts) for why
108
- * that's a deliberate, explicit exception to this project's usual
109
- * credential-handling policy, not an oversight. */
110
- function emitWireguardInterface(
111
- u: Uplink,
112
- ns: string,
113
- ifc: Extract<InterfaceKind, { kind: "wireguard" }>,
114
- ): string[] {
115
- const cfg = ifc.config;
116
- const confPath = `/etc/wireguard/${ifc.ifaceName}.conf`;
117
- const tmpPath = `/tmp/${ifc.ifaceName}.conf.new`;
118
-
119
- const confBody = [
120
- "[Interface]",
121
- `PrivateKey = ${cfg.privateKey}`,
122
- `Address = ${cfg.address}`,
123
- ...(cfg.listenPort !== undefined ? [`ListenPort = ${cfg.listenPort}`] : []),
124
- ...(cfg.dns !== undefined ? [`DNS = ${cfg.dns}`] : []),
125
- "[Peer]",
126
- `PublicKey = ${cfg.peer.publicKey}`,
127
- `AllowedIPs = ${cfg.peer.allowedIps}`,
128
- `Endpoint = ${cfg.peer.endpoint}`,
129
- ...(cfg.peer.persistentKeepalive !== undefined
130
- ? [`PersistentKeepalive = ${cfg.peer.persistentKeepalive}`]
131
- : []),
132
- ];
133
-
134
- return [
135
- `# --- wireguard: ${u.name} (${ifc.ifaceName}) ---`,
136
- "mkdir -p /etc/wireguard",
137
- "chmod 700 /etc/wireguard",
138
- `cat > ${tmpPath} << 'WGCONF'`,
139
- ...confBody,
140
- "WGCONF",
141
- `chmod 600 ${tmpPath}`,
142
- `cmp -s ${tmpPath} ${confPath} 2>/dev/null && rm -f ${tmpPath} || {`,
143
- ` ip netns exec ${ns} wg-quick down ${confPath} >/dev/null 2>&1 || true`,
144
- ` mv ${tmpPath} ${confPath}`,
145
- "}",
146
- `ip netns exec ${ns} wg show ${ifc.ifaceName} >/dev/null 2>&1 || ` +
147
- `ip netns exec ${ns} wg-quick up ${confPath}`,
148
- ];
149
- }
150
-
151
- // Real kernel interface names (veth ends, the OVS bridge below) are
152
- // capped at IFNAMSIZ = 16 bytes including the NUL terminator, i.e. 15
153
- // usable characters — confirmed live, this session: "br-uplink-1280-
154
- // transfer" (23 chars) and "br-uplink-isp-primary-transfer" (30 chars)
155
- // both fail with ofproto "Invalid argument", and "veth-ovn-isp-modem"
156
- // (18 chars) would fail the same way. u.name is human-chosen and can
157
- // be arbitrarily long/descriptive (that's the point of it), so it must
158
- // NEVER be used to build a real interface name. u.slot is a small
159
- // sequential int (0-4095, unique per uplink, assigned by
160
- // NetworkBuilder — see types.ts) and is used here instead. OVN-side
161
- // logical object names (emitUplinkTransfer in generate-ovn.ts) are NOT
162
- // real interfaces — those keep u.name for readability.
163
-
164
- function vethOvn(u: Uplink): string {
165
- return `veth-ovn-${u.slot}`;
166
- }
167
-
168
- function vethNetns(u: Uplink): string {
169
- return `veth-krn-${u.slot}`;
170
- }
171
-
172
- /** OVS bridge that carries this uplink's transfer-link veth — must
173
- * match the bridge emitUplinkTransferInterface() (generate-ovn.ts)
174
- * creates and registers in ovn-bridge-mappings, so the OVN-side veth
175
- * created here has somewhere to attach. Slot-based name, not
176
- * name-based — see comment above. */
177
- export function uplinkTransferBridge(u: Uplink): string {
178
- return `br-up-${u.slot}`;
179
- }
180
-
181
- /** Real kernel name of a "dummy" uplink's placeholder interface (see
182
- * InterfaceKind, types.ts) — slot-based, same IFNAMSIZ-safe convention
183
- * as vethOvn/vethNetns/uplinkTransferBridge above. Unlike those, this
184
- * one the generator actually CREATES (not just names) — see the "real
185
- * interface" block in emitUplinkNetns. */
186
- function dummyIface(u: Uplink): string {
187
- return `dummy-up-${u.slot}`;
188
- }
189
-
190
- // ── ipv4 discovery: pluggable client ────────────────────────────────
191
- // discovery.ipv4 === "dhcp" says WHAT is needed (a DHCPv4 lease);
192
- // discovery.client says WHICH PROGRAM does it. Each client gets its
193
- // own tiny emit function, all with the same idempotent shape
194
- // ("already running for this interface? no-op : start it"), so the
195
- // generated script always has ONE clearly-labelled block per uplink
196
- // for this (see the "# --- ipv4 discovery: ..." comment below) instead
197
- // of the mechanism being buried inline. Adding a third client (or,
198
- // later, folding a WireGuard uplink into this same dispatch shape —
199
- // see WireGuard design discussion, 2026-07-06) means adding one
200
- // function and one switch arm here, not touching the two below.
201
-
202
- function emitDhclient(u: Uplink, ns: string, realIface: string): string[] {
203
- return [
204
- `ip netns exec ${ns} pgrep -f "dhclient.*${realIface}" >/dev/null || ` +
205
- `ip netns exec ${ns} dhclient -nw ` +
206
- `-pf /run/dhclient.${u.name}.pid ` +
207
- `-lf /var/lib/dhcp/dhclient.${u.name}.leases ` +
208
- `${realIface}`,
209
- ];
210
- }
211
-
212
- function emitDhcpcd(u: Uplink, ns: string, realIface: string): string[] {
213
- // NOTE: flags not yet verified against a live host — dhcpcd isn't
214
- // installed on the reference deployment (only dhclient has been
215
- // confirmed live so far). -b backgrounds immediately, -q quiets normal
216
- // output. Idempotency uses the same pgrep guard as the dhclient
217
- // branch above rather than a hardcoded pidfile path, since dhcpcd's
218
- // default pidfile naming varies by distro/version.
219
- return [
220
- `ip netns exec ${ns} pgrep -f "dhcpcd.*${realIface}" >/dev/null || ` +
221
- `ip netns exec ${ns} dhcpcd -b -q ${realIface}`,
222
- ];
223
- }
224
-
225
- /** client === "static": not a program at all, just configure the fixed
226
- * address + default gateway directly (see StaticIpv4, types.ts) —
227
- * what dhclient/dhcpcd would otherwise learn dynamically. Useful for
228
- * an uplink that turns out to want a stable reserved LAN address
229
- * rather than whatever the router's DHCP server hands out. Same
230
- * idempotent shape as the two above, just no client process
231
- * to check for — only the address and the route. */
232
- function emitStaticIpv4(u: Uplink, ns: string, realIface: string): string[] {
233
- const cfg = u.discovery?.static4;
234
- if (cfg === undefined) {
235
- return [
236
- `# WARNING: client "static" requested for ${u.name} but no ` +
237
- `discovery.static4 given — nothing configured, see types.ts`,
238
- ];
239
- }
240
- const addrHost = cfg.address.split("/")[0];
241
- return [
242
- `ip netns exec ${ns} ip addr show ${realIface} | grep -q ${addrHost} || ` +
243
- `ip netns exec ${ns} ip addr add ${cfg.address} dev ${realIface}`,
244
- `ip netns exec ${ns} ip route show | grep -q '^default' || ` +
245
- `ip netns exec ${ns} ip route add default via ${cfg.gateway} dev ${realIface}`,
246
- ];
247
- }
248
-
249
- function emitIpv4Discovery(
250
- u: Uplink,
251
- ns: string,
252
- realIface: string,
253
- ): string[] {
254
- // Explicit client always wins. With none given, fall back to
255
- // "dhclient" — but ONLY when discovery.ipv4 is "dhcp" (the
256
- // historical default for a real WAN uplink). A "static"
257
- // discovery.ipv4 with NO explicit client — e.g. a backdoor's merged
258
- // real interface (see realIfaceFor above), whose address/route are
259
- // already fully handled by emitBackdoorVethAndRoute — deliberately
260
- // does nothing here at all.
261
- const client: DhcpClient | undefined = u.discovery?.client ??
262
- (u.discovery?.ipv4 === "dhcp" ? "dhclient" : undefined);
263
- if (client === undefined) return [];
264
-
265
- const lines = [`# --- ipv4 discovery: ${client} on ${realIface} ---`];
266
- switch (client) {
267
- case "dhclient":
268
- lines.push(...emitDhclient(u, ns, realIface));
269
- break;
270
- case "dhcpcd":
271
- lines.push(...emitDhcpcd(u, ns, realIface));
272
- break;
273
- case "static":
274
- lines.push(...emitStaticIpv4(u, ns, realIface));
275
- break;
276
- }
277
- return lines;
278
- }
279
-
280
- export function emitUplinkNetns(
281
- u: Uplink,
282
- segmentsOnThisUplink: readonly Segment[],
283
- ): string[] {
284
- const ns = netnsName(u);
285
- const vOvn = vethOvn(u);
286
- const vNetns = vethNetns(u);
287
-
288
- const realIface = realIfaceFor(u);
289
- if (realIface === undefined) {
290
- return [
291
- `# unsupported uplink interface kind "${u.if.kind}" for ${u.name} ` +
292
- `— skipped, see generate-netns.ts`,
293
- "",
294
- ];
295
- }
296
-
297
- const ovnSide = u.addresses[0]; // host=1
298
- const netnsSide = u.addresses[1]; // host=2
299
-
300
- const lines: string[] = [`# --- uplink: ${u.name} (netns side) ---`];
301
-
302
- // netns + veth pair (idempotent)
303
- lines.push(
304
- // `ip netns list` prints "<name> (id: N)", not just "<name>" — a
305
- // plain `grep -qx ${ns}` (exact whole-line match) never matches an
306
- // existing namespace, so a re-run always tries `ip netns add`
307
- // again and fails with "File exists" under `set -e`, aborting
308
- // everything after it (confirmed live, this session — the same
309
- // "safe to re-run" idempotency this generator is built around).
310
- // Isolate just the name field first, then match exactly.
311
- `ip netns list | awk '{print $1}' | grep -qx ${ns} || ip netns add ${ns}`,
312
- `ip netns exec ${ns} ip link set lo up`,
313
- `ip link show ${vOvn} >/dev/null 2>&1 || ` +
314
- `ip link add ${vOvn} type veth peer name ${vNetns}`,
315
- // Move the netns-side veth end over. Checking "is ${vNetns}
316
- // visible in the root namespace" (not "is it absent") is the
317
- // correct idempotency test: right after creation both veth ends
318
- // exist in the root ns, so a check for absence never fires and
319
- // the move never happens on first run — confirmed live, this
320
- // session ("Cannot find device" on the subsequent `ip netns exec
321
- // ... set up`, because the interface was still sitting in root).
322
- // If ${vNetns} is NOT visible in root, it's already been moved by
323
- // a prior run — nothing to do, `|| true` makes that a no-op.
324
- `ip link show ${vNetns} >/dev/null 2>&1 && ` +
325
- `ip link set ${vNetns} netns ${ns} || true`,
326
- // Attach the OVN-side veth to its transfer-link OVS bridge —
327
- // without this, ovn-bridge-mappings has nothing plugged into it
328
- // and the localnet port never sees traffic (confirmed live: a
329
- // bridge existing with no port attached errors on ofproto add).
330
- `ovs-vsctl --may-exist add-port ${uplinkTransferBridge(u)} ${vOvn}`,
331
- `ip link set ${vOvn} up`,
332
- `ip netns exec ${ns} ip link set ${vNetns} up`,
333
- `ip addr show ${vOvn} | grep -q ${ovnSide.ipv4.to_s()} || ` +
334
- `ip addr add ${ovnSide.ipv4.to_string()} dev ${vOvn}`,
335
- `ip addr show ${vOvn} | grep -q ${ovnSide.ipv6.to_s()} || ` +
336
- `ip addr add ${ovnSide.ipv6.to_string()} dev ${vOvn}`,
337
- `ip netns exec ${ns} sh -c "ip addr show ${vNetns} | grep -q ` +
338
- `${netnsSide.ipv4.to_s()} || ip addr add ${netnsSide.ipv4.to_string()} ` +
339
- `dev ${vNetns}"`,
340
- `ip netns exec ${ns} sh -c "ip addr show ${vNetns} | grep -q ` +
341
- `${netnsSide.ipv6.to_s()} || ip addr add ${netnsSide.ipv6.to_string()} ` +
342
- `dev ${vNetns}"`,
343
- );
344
-
345
- // real interface: create if missing, move into netns.
346
- //
347
- // The backdoor's own veth+route (if any) is emitted UNCONDITIONALLY
348
- // here, regardless of whether this uplink ALSO has a real interface
349
- // of its own — it's a separate, always-useful bootstrap path (see
350
- // Backdoor, types.ts), not a substitute for one except in the
351
- // "dummy" placeholder case (see realIfaceFor above).
352
- if (u.backdoor !== undefined) {
353
- lines.push(...emitBackdoorVethAndRoute(u, u.backdoor));
354
- }
355
- if (u.if.kind === "wireguard") {
356
- // A real interface of its own — created here alongside (not
357
- // instead of) the backdoor block above.
358
- lines.push(...emitWireguardInterface(u, ns, u.if));
359
- } else if (u.backdoor === undefined) {
360
- // No backdoor AND no wireguard tunnel: fall through to the plain
361
- // vlan/dummy/physical creation. (When a backdoor IS present and
362
- // `u.if.kind === "dummy"`, realIface already resolved to the
363
- // backdoor's own veth above — nothing further to create here; see
364
- // realIfaceFor's doc comment.)
365
- lines.push(
366
- `ip netns exec ${ns} ip link show ${realIface} >/dev/null 2>&1 || {`,
367
- ` ip link show ${realIface} >/dev/null 2>&1 || ` +
368
- (u.if.kind === "vlan"
369
- ? `ip link add link ${u.if.vlanParent} name ${realIface} ` +
370
- `type vlan id ${u.if.vlanId}`
371
- : u.if.kind === "dummy"
372
- ? `ip link add ${realIface} type dummy`
373
- : `true # physical interface ${realIface} must already exist`),
374
- ` ip link set ${realIface} netns ${ns}`,
375
- `}`,
376
- `ip netns exec ${ns} ip link set ${realIface} up`,
377
- );
378
- }
379
-
380
- // forwarding + back-routes (kernel-side, not arithmetic — plain shell)
381
- lines.push(
382
- `ip netns exec ${ns} sysctl -qw net.ipv4.ip_forward=1`,
383
- `ip netns exec ${ns} sysctl -qw net.ipv6.conf.all.forwarding=1`,
384
- `ip netns exec ${ns} ip route show | grep -q '^10.0.0.0/8' || ` +
385
- `ip netns exec ${ns} ip route add 10.0.0.0/8 via ${ovnSide.ipv4.to_s()} dev ${vNetns}`,
386
- `ip netns exec ${ns} ip route show | grep -q '^192.168.0.0/16' || ` +
387
- `ip netns exec ${ns} ip route add 192.168.0.0/16 via ${ovnSide.ipv4.to_s()} dev ${vNetns}`,
388
- `ip netns exec ${ns} ip -6 route show | grep -q '^fd00::/8' || ` +
389
- `ip netns exec ${ns} ip -6 route add fd00::/8 via ${ovnSide.ipv6.to_s()} dev ${vNetns}`,
390
- );
391
-
392
- // discovery: SLAAC needs accept_ra=2 (forwarding suppresses RA
393
- // otherwise — confirmed live, this session). DHCPv4 needs dhclient
394
- // started on the real interface, inside the netns.
395
- if (u.discovery?.ipv6 === "slaac") {
396
- lines.push(
397
- `ip netns exec ${ns} sh -c "echo 2 > ` +
398
- `/proc/sys/net/ipv6/conf/${realIface}/accept_ra"`,
399
- );
400
- }
401
- lines.push(...emitIpv4Discovery(u, ns, realIface));
402
-
403
- // NAT: derived by the caller from which segments currently resolve
404
- // to this uplink — NOT stored on Uplink itself, see header comment.
405
- // segAddr.to_string() below is the segment's GATEWAY host address
406
- // with the segment's prefix length (e.g. 192.168.128.2/24, not
407
- // 192.168.128.0/24) — this is intentional and correct, not a bug:
408
- // iptables/ip6tables (and IPAddress.parse non-strict) both mask a
409
- // host address by its prefix length before matching, so
410
- // "-s 192.168.128.2/24" matches the entire 192.168.128.0/24 network
411
- // exactly the same as writing the bare network address would.
412
- for (const seg of segmentsOnThisUplink) {
413
- const segAddr = seg.addresses[0];
414
- if (u.nat?.ipv4?.some((r) => r.kind === "masq")) {
415
- lines.push(
416
- `ip netns exec ${ns} iptables -t nat -C POSTROUTING -s ` +
417
- `${segAddr.ipv4.to_string()} -o ${realIface} -j MASQUERADE 2>/dev/null || ` +
418
- `ip netns exec ${ns} iptables -t nat -A POSTROUTING -s ` +
419
- `${segAddr.ipv4.to_string()} -o ${realIface} -j MASQUERADE`,
420
- );
421
- }
422
- if (u.nat?.ipv6?.some((r) => r.kind === "masq")) {
423
- lines.push(
424
- `ip netns exec ${ns} ip6tables -t nat -C POSTROUTING -s ` +
425
- `${segAddr.ipv6.to_string()} -o ${realIface} -j MASQUERADE 2>/dev/null || ` +
426
- `ip netns exec ${ns} ip6tables -t nat -A POSTROUTING -s ` +
427
- `${segAddr.ipv6.to_string()} -o ${realIface} -j MASQUERADE`,
428
- );
429
- }
430
- }
431
-
432
- lines.push("");
433
- return lines;
434
- }
435
-
436
- // ── backdoor: borrowed egress for a VPN-like uplink ─────────────────
437
- // See Backdoor (types.ts) for the why. The kernel-side shape here is
438
- // deliberately the SAME veth-pair/address/route dance as a normal
439
- // transfer link (emitUplinkNetns above) — a backdoor IS a transfer
440
- // link, just one whose OVN-side lands on an ALREADY-real uplink's
441
- // router instead of spinning up a dedicated one of its own. The one
442
- // thing that must NOT be reused is the address block: bd.addresses
443
- // comes from its own slot (see factories.ts, uplinkDummy), so it can
444
- // never share a /28 with `u`'s own front-door transfer link — sharing
445
- // one was tried and is broken (see Backdoor's doc comment for why).
446
-
447
- function backdoorVethOvn(bd: Backdoor): string {
448
- return `veth-bdo-${bd.slot}`;
449
- }
450
-
451
- function backdoorVethNetns(bd: Backdoor): string {
452
- return `veth-bdk-${bd.slot}`;
453
- }
454
-
455
- /** OVS bridge carrying a backdoor's own veth — must match the bridge
456
- * emitBackdoorInterface() (generate-ovn.ts) creates and registers in
457
- * ovn-bridge-mappings. Slot-based, same convention as
458
- * uplinkTransferBridge() above. */
459
- export function backdoorBridge(bd: Backdoor): string {
460
- return `br-bd-${bd.slot}`;
461
- }
462
-
463
- /** Creates the backdoor's own veth pair, addresses both ends, and adds
464
- * the default route out through it — this IS u's real interface (see
465
- * realIfaceFor above), not a separate placeholder alongside one, so
466
- * it's called from emitUplinkNetns's "real interface" section, in the
467
- * exact spot a plain vlan/dummy/physical device would otherwise be
468
- * created. NAT is deliberately NOT here — see emitBackdoorNat below,
469
- * called separately since it targets `via`'s netns, not this one. */
470
- export function emitBackdoorVethAndRoute(u: Uplink, bd: Backdoor): string[] {
471
- const ns = netnsName(u);
472
- const vOvn = backdoorVethOvn(bd);
473
- const vNetns = backdoorVethNetns(bd);
474
- const ovnSide = bd.addresses[0]; // host=3, lives on the `via` router
475
- const netnsSide = bd.addresses[1]; // host=4, lives in this uplink's netns
476
-
477
- const lines: string[] = [
478
- `# --- backdoor: ${u.name} -> ${bd.via.name} (borrowed egress, netns side) ---`,
479
- ];
480
-
481
- // veth pair: OVN side stays in root, netns side moves into THIS
482
- // uplink's own netns (not `via`'s) — same idempotency reasoning as
483
- // emitUplinkNetns's own veth pair above.
484
- lines.push(
485
- `ip link show ${vOvn} >/dev/null 2>&1 || ` +
486
- `ip link add ${vOvn} type veth peer name ${vNetns}`,
487
- `ip link show ${vNetns} >/dev/null 2>&1 && ` +
488
- `ip link set ${vNetns} netns ${ns} || true`,
489
- `ovs-vsctl --may-exist add-port ${backdoorBridge(bd)} ${vOvn}`,
490
- `ip link set ${vOvn} up`,
491
- `ip netns exec ${ns} ip link set ${vNetns} up`,
492
- `ip addr show ${vOvn} | grep -q ${ovnSide.ipv4.to_s()} || ` +
493
- `ip addr add ${ovnSide.ipv4.to_string()} dev ${vOvn}`,
494
- `ip addr show ${vOvn} | grep -q ${ovnSide.ipv6.to_s()} || ` +
495
- `ip addr add ${ovnSide.ipv6.to_string()} dev ${vOvn}`,
496
- `ip netns exec ${ns} sh -c "ip addr show ${vNetns} | grep -q ` +
497
- `${netnsSide.ipv4.to_s()} || ip addr add ${netnsSide.ipv4.to_string()} ` +
498
- `dev ${vNetns}"`,
499
- `ip netns exec ${ns} sh -c "ip addr show ${vNetns} | grep -q ` +
500
- `${netnsSide.ipv6.to_s()} || ip addr add ${netnsSide.ipv6.to_string()} ` +
501
- `dev ${vNetns}"`,
502
- );
503
-
504
- // Default route via the backdoor — deliberately just the DEFAULT
505
- // route, not a broad backroute like emitUplinkNetns's 10.0.0.0/8 /
506
- // 192.168.0.0/16: this uplink's netns ALSO has its own front-door
507
- // transfer link (more specific routes there, if any exist), and
508
- // those must keep winning for anything internal. Only genuinely
509
- // internet-bound traffic (not matched by anything more specific)
510
- // should fall through to the backdoor.
511
- lines.push(
512
- `ip netns exec ${ns} ip route show | grep -q '^default' || ` +
513
- `ip netns exec ${ns} ip route add default via ${ovnSide.ipv4.to_s()} dev ${vNetns}`,
514
- `ip netns exec ${ns} ip -6 route show | grep -q '^default' || ` +
515
- `ip netns exec ${ns} ip -6 route add default via ${ovnSide.ipv6.to_s()} dev ${vNetns}`,
516
- );
517
-
518
- lines.push("");
519
- return lines;
520
- }
521
-
522
- /** NAT for traffic borrowing this backdoor's egress — lives in `via`'s
523
- * netns (the uplink with the real interface), NOT this uplink's own
524
- * netns, since masquerading has to happen where the real interface
525
- * actually is. Called separately from emitBackdoorVethAndRoute (from
526
- * generate-ovn.ts, alongside every other uplink's own NAT) rather than
527
- * folded into it, since it targets a different netns entirely.
528
- *
529
- * Scoped to `via`'s OWN declared nat config (bd.via.nat), exactly like
530
- * segment NAT above is scoped to the resolving uplink's nat config —
531
- * NOT unconditional (an earlier version added this rule regardless of
532
- * whether `via` even wants NAT, per feedback 2026-07-06: "we do nat on
533
- * the avm uplink").
534
- *
535
- * WHAT gets matched forks on whether the backdoor is standing in as
536
- * `u`'s own realIface (see realIfaceFor above) or merely bootstrapping
537
- * a SEPARATE real interface (e.g. a wireguard tunnel):
538
- *
539
- * - No real interface of its own (the "dummy" placeholder case): the
540
- * backdoor's netns-side veth IS realIface, so actual segment
541
- * traffic really does flow through it — matches on each SEGMENT's
542
- * own subnet (segAddr below), e.g. 192.168.130.0/24 for
543
- * "neighbor", exactly like emitUplinkNetns's own segment-NAT loop.
544
- * NOT bd.addresses: routed segment traffic keeps its original
545
- * source address all the way through the OVN topology (every hop
546
- * here is routing, not NAT), so that's the address that has to
547
- * appear in the rule. Confirmed live, 2026-07-06 — an earlier
548
- * version matched on bd.addresses here unconditionally and
549
- * silently NAT'd nothing useful (nmap against that /28 only ever
550
- * found the backdoor's own interface answering).
551
- *
552
- * - A real interface of its own (e.g. wireguard): the backdoor
553
- * carries ONLY that interface's own bootstrap/handshake/keepalive
554
- * traffic (once it's up, real segment traffic goes through the
555
- * tunnel instead, matched by u's OWN nat/realIface in
556
- * emitUplinkNetns's segment-NAT loop, same as every non-backdoor
557
- * uplink). That bootstrap traffic is sourced from the backdoor's
558
- * OWN transit address (bd.addresses[1]), never from a segment
559
- * subnet — so THAT'S what gets matched here instead.
560
- *
561
- * Requires `via`'s netns to already exist by the time this runs — true
562
- * as long as `via` is declared (and therefore processed) before this
563
- * uplink, which NetworkBuilder enforces (see define.ts). */
564
- export function emitBackdoorNat(
565
- u: Uplink,
566
- segmentsOnThisUplink: readonly Segment[],
567
- ): string[] {
568
- const bd = u.backdoor;
569
- if (bd === undefined) return [];
570
-
571
- const viaNs = netnsName(bd.via);
572
- const viaRealIface = realIfaceFor(bd.via);
573
- if (viaRealIface === undefined) return [];
574
-
575
- const lines: string[] = [];
576
-
577
- if (realIfaceFor(u) === backdoorVethNetns(bd)) {
578
- // Backdoor IS u's realIface — NAT scoped per resolving segment.
579
- for (const seg of segmentsOnThisUplink) {
580
- const segAddr = seg.addresses[0];
581
- if (bd.via.nat?.ipv4?.some((r) => r.kind === "masq")) {
582
- lines.push(
583
- `ip netns exec ${viaNs} iptables -t nat -C POSTROUTING -s ` +
584
- `${segAddr.ipv4.to_string()} -o ${viaRealIface} -j MASQUERADE 2>/dev/null || ` +
585
- `ip netns exec ${viaNs} iptables -t nat -A POSTROUTING -s ` +
586
- `${segAddr.ipv4.to_string()} -o ${viaRealIface} -j MASQUERADE`,
587
- );
588
- }
589
- if (bd.via.nat?.ipv6?.some((r) => r.kind === "masq")) {
590
- lines.push(
591
- `ip netns exec ${viaNs} ip6tables -t nat -C POSTROUTING -s ` +
592
- `${segAddr.ipv6.to_string()} -o ${viaRealIface} -j MASQUERADE 2>/dev/null || ` +
593
- `ip netns exec ${viaNs} ip6tables -t nat -A POSTROUTING -s ` +
594
- `${segAddr.ipv6.to_string()} -o ${viaRealIface} -j MASQUERADE`,
595
- );
596
- }
597
- }
598
- } else {
599
- // u has its own real interface (e.g. wireguard) — backdoor only
600
- // carries ITS bootstrap traffic, sourced from the backdoor's own
601
- // transit address, not any segment.
602
- const netnsSide = bd.addresses[1];
603
- if (bd.via.nat?.ipv4?.some((r) => r.kind === "masq")) {
604
- lines.push(
605
- `ip netns exec ${viaNs} iptables -t nat -C POSTROUTING -s ` +
606
- `${netnsSide.ipv4.to_string()} -o ${viaRealIface} -j MASQUERADE 2>/dev/null || ` +
607
- `ip netns exec ${viaNs} iptables -t nat -A POSTROUTING -s ` +
608
- `${netnsSide.ipv4.to_string()} -o ${viaRealIface} -j MASQUERADE`,
609
- );
610
- }
611
- if (bd.via.nat?.ipv6?.some((r) => r.kind === "masq")) {
612
- lines.push(
613
- `ip netns exec ${viaNs} ip6tables -t nat -C POSTROUTING -s ` +
614
- `${netnsSide.ipv6.to_string()} -o ${viaRealIface} -j MASQUERADE 2>/dev/null || ` +
615
- `ip netns exec ${viaNs} ip6tables -t nat -A POSTROUTING -s ` +
616
- `${netnsSide.ipv6.to_string()} -o ${viaRealIface} -j MASQUERADE`,
617
- );
618
- }
619
- }
620
-
621
- return lines;
622
- }