@adviser/ovn-fabric 0.1.0
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.
- package/LICENSE +201 -0
- package/README.md +131 -0
- package/bin/ovn-fabric.js +43 -0
- package/package.json +38 -0
- package/src/addressing.ts +222 -0
- package/src/cli.ts +163 -0
- package/src/define.ts +187 -0
- package/src/factories.ts +444 -0
- package/src/generate-netns.ts +622 -0
- package/src/generate-ovn.ts +625 -0
- package/src/types.ts +408 -0
|
@@ -0,0 +1,622 @@
|
|
|
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
|
+
}
|