@adviser/ovn-fabric 0.1.2 → 0.1.3

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/src/types.ts DELETED
@@ -1,408 +0,0 @@
1
- // types.ts — distinct identity types and the switchable-uplink mechanism.
2
- // No topology data lives here. This file defines the SHAPE; model.ts
3
- // declares the FACTS.
4
-
5
- import { IPAddress } from "npm:ipaddress@0.2.6";
6
-
7
- // ── distinct identity types ──────────────────────────────────────
8
- // Branded types: structurally still numbers at runtime, but the type
9
- // checker will not let a SegmentId be passed where an UplinkId is
10
- // expected, or vice versa. Every bug from tonight's session was
11
- // "correct math, applied to the wrong identifier" — a checker that
12
- // treats number as number cannot catch that; two distinct branded
13
- // types can.
14
-
15
- type Brand<T, B extends string> = T & { readonly __brand: B };
16
-
17
- export type SegmentId = Brand<number, "SegmentId">;
18
- export type UplinkId = Brand<number, "UplinkId">;
19
-
20
- export function segmentId(n: number): SegmentId {
21
- if (n < 0 || n > 255) {
22
- throw new RangeError(`SegmentId out of range (0-255): ${n}`);
23
- }
24
- return n as SegmentId;
25
- }
26
-
27
- export function uplinkId(n: number): UplinkId {
28
- if (n < 0 || n > 65535) {
29
- throw new RangeError(`UplinkId out of range: ${n}`);
30
- }
31
- return n as UplinkId;
32
- }
33
-
34
- // ── host / chassis ─────────────────────────────────────────────────
35
- // Where a given segment's or uplink's OVN/OVS configuration is actually
36
- // applied. A given deployment may run everything on one chassis, but the
37
- // model does not assume that — each Segment/Uplink declares which Host
38
- // it runs on, so a future topology with multiple chassis is a config
39
- // change, not a redesign.
40
-
41
- export type AccessMethod =
42
- | { method: "ssh"; user: string }
43
- | { method: "local" }; // the generator's own host — no SSH needed
44
-
45
- export interface Host {
46
- readonly name: string;
47
- readonly address: string; // hostname or IP the generator connects to
48
- readonly access: AccessMethod;
49
- }
50
-
51
- export function sshHost(name: string, address: string, user: string): Host {
52
- return { name, address, access: { method: "ssh", user } };
53
- }
54
-
55
- export function localHost(name: string): Host {
56
- return { name, address: "127.0.0.1", access: { method: "local" } };
57
- }
58
-
59
- // ── NetId: the identity every segment/uplink/transfer-link carries ──
60
- // Backed by the `ipaddress` library (not std — see ADR discussion: std
61
- // libraries lack reliable prefix-notation parsing and the 128-bit
62
- // arithmetic IPv6 fold rules need). id() returns the raw numeric
63
- // identifier this NetId was derived from (a segment or uplink number);
64
- // vlan() returns the physical VLAN tag if one applies, or undefined —
65
- // note this is a DERIVED convenience, distinct from whether the thing
66
- // holding this NetId actually has an `if: { kind: "vlan", ... }` — a
67
- // NetId can report a vlan() number purely because of its fold rule
68
- // while the real physical attachment (see InterfaceKind below) is
69
- // something else entirely (a WireGuard interface, a plain port). Don't
70
- // use vlan() to decide physical wiring; use the owning Uplink/Segment's
71
- // `if` field for that.
72
- //
73
- // NetId instances are produced by factory functions in addressing.ts
74
- // (segmentNet(), uplinkNet(), transferNet()), not constructed directly
75
- // here — this interface only defines the shape every factory must
76
- // satisfy. The fold operation itself is string construction (see
77
- // addressing.ts header comment) — IPAddress.parse() is called only
78
- // after the address string is fully built.
79
-
80
- export interface NetId {
81
- readonly ipv4: IPAddress;
82
- readonly ipv6: IPAddress;
83
- id(): number;
84
- vlan(): number | undefined;
85
- }
86
-
87
- /**
88
- * An address PAIR — the thing config/topology.ts actually declares per
89
- * Uplink/Segment via `addresses: [...]`. Most things have exactly one
90
- * NetId; the array form exists for cases like a transfer link, which
91
- * conceptually carries both its OVN-side and netns-side identity.
92
- */
93
- export type Addresses = readonly NetId[];
94
-
95
- // ── physical realization ─────────────────────────────────────────
96
- // HOW a Segment/Uplink actually attaches to a real wire. Deliberately
97
- // separate from addressing: a NetId's vlan() can return a number purely
98
- // from its fold rule while the real interface here is something else
99
- // entirely (WireGuard, a bridge port with no VLAN at all). This is the
100
- // split that was missing before tonight's correction — conflating
101
- // "has an address" with "is a VLAN" broke as soon as WireGuard needed
102
- // modelling, since a WireGuard tunnel has addresses but is not a VLAN.
103
-
104
- /** The [Peer] stanza of a wg-quick conf — see InterfaceKind's
105
- * "wireguard" variant below. */
106
- export interface WireguardPeer {
107
- readonly publicKey: string;
108
- /** "host:port" */
109
- readonly endpoint: string;
110
- /** wg-quick's own comma-joined syntax, e.g. "0.0.0.0/0,::0/0" —
111
- * stored as one string (not parsed here) since this generator never
112
- * needs to reason about individual prefixes, only reproduce the
113
- * conf file verbatim. */
114
- readonly allowedIps: string;
115
- readonly persistentKeepalive?: number;
116
- }
117
-
118
- /** The [Interface] stanza plus its one [Peer] — everything
119
- * emitWireguardInterface (generate-netns.ts) needs to reconstruct a
120
- * wg-quick conf byte-for-byte. See InterfaceKind's "wireguard" variant
121
- * for why the PrivateKey lives here, in a git-tracked file, rather
122
- * than behind an env var/secret manager as this project's credentials
123
- * normally would. */
124
- export interface WireguardInterfaceConfig {
125
- readonly privateKey: string;
126
- /** wg-quick's own comma-joined syntax, e.g.
127
- * "10.64.56.207/32,fc00:bbbb:bbbb:bb01::1:38ce/128" */
128
- readonly address: string;
129
- readonly listenPort?: number;
130
- readonly dns?: string;
131
- readonly peer: WireguardPeer;
132
- }
133
-
134
- export type InterfaceKind =
135
- | {
136
- kind: "vlan";
137
- vlanParent: string;
138
- vlanId: number;
139
- /** Override the kernel subinterface name — defaults to
140
- * `${vlanParent}.${vlanId}` when omitted. Needed whenever that
141
- * default name is already in use for something else on the same
142
- * VLAN tag (e.g. VLAN 129 already carries this host's own
143
- * management IP on `ens18.129` via netplan/networkd — a second,
144
- * differently-named vlan subinterface for the same tag can safely
145
- * coexist and feed an OVS bridge without touching the existing
146
- * one; Linux delivers matching-tagged frames to every registered
147
- * vlan netdevice for a given (parent, vlanId) pair, not just one). */
148
- ifaceName?: string;
149
- }
150
- | { kind: "physical"; name: string } // a real, untagged NIC/port
151
- | { kind: "bridge-port"; bridge: string; port: string }
152
- /** A real WireGuard tunnel, managed via `wg-quick` rather than
153
- * hand-rolled `wg setconf`/`ip link` calls — wg-quick's own fwmark +
154
- * policy-routing dance is exactly what's needed here (the tunnel's
155
- * own handshake/keepalive UDP packets must keep leaving via whatever
156
- * route already existed — the Backdoor below, typically — while
157
- * every OTHER packet gets diverted into the tunnel), and re-deriving
158
- * that by hand would just duplicate a battle-tested implementation.
159
- * Confirmed live, 2026-07-06: this is genuinely how it behaves when
160
- * a default route already exists in the netns before `wg-quick up`
161
- * runs.
162
- *
163
- * `config` (see WireguardInterfaceConfig below) is declared directly
164
- * in config/topology.ts and written verbatim to
165
- * /etc/wireguard/<ifaceName>.conf on the target host by the
166
- * generator (see emitWireguardInterface, generate-netns.ts) — INCLUDING
167
- * the PrivateKey. This is a DELIBERATE, EXPLICIT exception to this
168
- * project's usual "never hard-code credentials, use env vars/secret
169
- * managers" policy, made 2026-07-06 after being asked to confirm:
170
- * the tradeoff (a real credential living in a git-tracked source
171
- * file) was accepted on purpose, not overlooked. Treat topology.ts
172
- * — and any generated script built from it — with the same care as
173
- * any other credential-bearing file (this repo's git-safety rules
174
- * around scanning for secrets before any commit/push still apply in
175
- * full).
176
- *
177
- * `ifaceName` is the real kernel interface name AND the .conf's
178
- * basename on disk — defaults to this uplink's OWN name (see
179
- * uplinkWireguard, factories.ts, which threads UplinkBuilder's
180
- * `name` parameter through), overridable when that default doesn't
181
- * fit: it exceeds IFNAMSIZ (15 usable characters), or would rename
182
- * an already-running real interface unnecessarily. */
183
- | {
184
- kind: "wireguard";
185
- ifaceName: string;
186
- config: WireguardInterfaceConfig;
187
- }
188
- /** A placeholder Linux dummy interface — no real backing device, no
189
- * real-world connectivity. Stands in for an uplink whose real
190
- * mechanism (e.g. a WireGuard tunnel) isn't built yet, so the rest of
191
- * the chain (OVN router, transfer link, backbone join, back-routes)
192
- * can be wired up and tested end-to-end first. No `name` field, on
193
- * purpose: unlike "vlan"/"physical", a dummy interface has no
194
- * pre-existing real-world name to preserve — the generator derives
195
- * and creates it itself, at a slot-based name (see generate-netns.ts,
196
- * dummyIface()), the same IFNAMSIZ-safe convention already used for
197
- * every other uplink-owned kernel interface (veth-ovn-N, veth-krn-N,
198
- * br-up-N). See WireGuard design discussion, 2026-07-06. */
199
- | { kind: "dummy" };
200
-
201
- // ── NAT ────────────────────────────────────────────────────────────
202
- // Per-stack, since a segment/uplink might need v4 masquerade but not
203
- // v6 (the common case once real delegated IPv6 prefixes exist — see
204
- // ADR 0001 consequence notes on DHCPv6-PD vs NAT66).
205
-
206
- export type NatRule = { readonly kind: "masq" };
207
-
208
- export interface Nat {
209
- readonly ipv4?: readonly NatRule[];
210
- readonly ipv6?: readonly NatRule[];
211
- }
212
-
213
- // ── discovery ──────────────────────────────────────────────────────
214
- // HOW this Uplink/Segment's real-world address is learned, per stack.
215
- // "static" means the NetId's address IS the real address, nothing to
216
- // discover. This is what determines which mechanism runs inside an
217
- // uplink's netns (see ADR 0001 — dhclient supervision, SLAAC accept_ra
218
- // handling, etc.) — addressing.ts and define.ts do not need to know
219
- // about discovery; it's read by the (not yet built) generation layer.
220
-
221
- /** Which real userspace program acquires this uplink's IPv4 lease when
222
- * discovery.ipv4 is "dhcp". Defaults to "dhclient" (the only client
223
- * used so far, and the one confirmed live). "dhcpcd" is the same
224
- * "dhcp" discovery KIND, a different PROGRAM doing it — same
225
- * idempotent "already running? no-op : start" shape, different
226
- * command line (see generate-netns.ts, emitIpv4Discovery). "static"
227
- * is different again: not a program at all, just "configure this
228
- * fixed address and gateway directly" — added 2026-07-06 for a real
229
- * uplink whose real-world address is known and stable (e.g. a
230
- * reserved LAN IP on the ISP router) rather than DHCP-leased. Kept as
231
- * its own field rather than folded into the ipv4 union so a future
232
- * WireGuard uplink — not "dhcp" at all, its own InterfaceKind branch
233
- * entirely (see WireGuard design discussion, 2026-07-06) — never has
234
- * to touch this dance. */
235
- export type DhcpClient = "dhclient" | "dhcpcd" | "static";
236
-
237
- /** The fixed address+prefix and default gateway to configure directly
238
- * on a real interface when Discovery.client is "static" — see
239
- * emitStaticIpv4 (generate-netns.ts). Only consulted then; every other
240
- * client ignores it. */
241
- export interface StaticIpv4 {
242
- /** e.g. "192.0.2.93/24" */
243
- readonly address: string;
244
- /** e.g. "192.0.2.1" */
245
- readonly gateway: string;
246
- }
247
-
248
- export interface Discovery {
249
- readonly ipv4?: "static" | "dhcp";
250
- readonly ipv6?: "static" | "slaac";
251
- /** Defaults to "dhclient" when ipv4 is "dhcp" and no client is
252
- * given; otherwise (ipv4 "static" with no explicit client — e.g. a
253
- * backdoor's merged dummy interface, see Backdoor below) nothing
254
- * runs here at all. */
255
- readonly client?: DhcpClient;
256
- /** Only consulted when client === "static". See StaticIpv4. */
257
- readonly static4?: StaticIpv4;
258
- }
259
-
260
- // ── backdoor: borrowed egress for a VPN-like uplink ─────────────────
261
- // Any uplink with no real interface of its own (dummy today; WireGuard,
262
- // ZeroTier, Tailscale, ... tomorrow — anything tunnel-shaped) still
263
- // needs a mundane, unencrypted path to the real internet: something has
264
- // to carry the tunnel's own setup/keepalive traffic, separate from
265
- // whatever the tunnel itself eventually carries. A backdoor is exactly
266
- // that: a second, dedicated transfer-link-shaped connection from this
267
- // uplink's OWN netns into an ALREADY-real uplink's router (`via`),
268
- // borrowing its egress instead of duplicating one.
269
- //
270
- // Deliberately generic — this is not a WireGuard-specific concept, it's
271
- // what ANY VPN-shaped uplink needs (originally built by hand for one
272
- // specific VPN uplink borrowing a plain uplink's egress, then
273
- // generalized here).
274
- //
275
- // `addresses`/`slot` here are the backdoor's OWN dedicated /28 — NOT
276
- // the owning uplink's own `addresses` (that's its front-door transfer
277
- // link to ITS OWN router). Drawing them from a genuinely separate slot
278
- // is required, not optional: sharing the front-door's /28 (both links'
279
- // addresses inside the SAME subnet, on two different netns interfaces)
280
- // was tried and is broken — Linux ends up with two equally-specific
281
- // connected routes for the one prefix, on two different devices, and
282
- // which one actually wins is unreliable, not a real design. Confirmed
283
- // live, this session — a ping "worked" against the shared-subnet
284
- // version, but for the wrong reason, not because the intended path
285
- // (through `via`'s router) was actually the one carrying it.
286
- export interface Backdoor {
287
- /** The real, already-working uplink this borrows egress from (e.g.
288
- * isp-primary). Must already be declared — see NetworkBuilder.uplink(). */
289
- readonly via: Uplink;
290
- /** This backdoor's own transfer-link addresses (OVN-side, netns-side)
291
- * — drawn from the same global slot sequence as every other transfer
292
- * link (see NetworkBuilder), so it can never collide with one. */
293
- readonly addresses: Addresses;
294
- /** The slot this backdoor consumed — used to derive its own
295
- * IFNAMSIZ-safe kernel interface/bridge names, same convention as
296
- * uplinkTransferBridge()/vethOvn()/vethNetns() (generate-netns.ts). */
297
- readonly slot: number;
298
- }
299
-
300
- // ── Uplink ───────────────────────────────────────────────────────────
301
-
302
- export interface Uplink {
303
- /** Unique per network — also used directly as the prefix for every
304
- * generated OVN object name (sw-<name>, router-<name>, lrp-<name>,
305
- * ...). Uniqueness is enforced by NetworkBuilder (see define.ts). */
306
- readonly name: string;
307
- /** The small sequential index NetworkBuilder assigned this uplink
308
- * (0-4095), used for BOTH the transfer-link IPv4 block (transferNet)
309
- * and the backbone-leg IPv4 block (uplinkBackboneNet) — kept on the
310
- * resolved object so tier-2 generation can recover it without
311
- * re-deriving it from an already-computed address. */
312
- readonly slot: number;
313
- readonly addresses: Addresses;
314
- readonly if: InterfaceKind;
315
- readonly nat?: Nat;
316
- readonly discovery?: Discovery;
317
- /** Borrowed egress for a VPN-like uplink with no real interface of
318
- * its own — see Backdoor above. Undefined for every uplink that has
319
- * real connectivity itself (a VLAN uplink, a physical NIC, a working
320
- * VPN tunnel, ...). */
321
- readonly backdoor?: Backdoor;
322
- readonly host: Host;
323
- }
324
-
325
- // ── switchable uplink selection ───────────────────────────────────
326
- // A segment does not hold a fixed Uplink reference. It holds an
327
- // UplinkSelector — something that can be asked "which uplink right
328
- // now" — so the generator can support failover/manual-switch later
329
- // without changing the Segment type or any derivation logic that
330
- // consumes it. Three selector strategies are provided; all of them
331
- // satisfy the same interface, so emit-time code only ever calls
332
- // `.resolve()` and never needs to know which strategy is in play.
333
-
334
- export interface UplinkSelector {
335
- resolve(): Uplink;
336
- }
337
-
338
- /** Always the same uplink. The common case, and tonight's actual need. */
339
- export class FixedUplink implements UplinkSelector {
340
- constructor(private readonly uplink: Uplink) {}
341
- resolve(): Uplink {
342
- return this.uplink;
343
- }
344
- }
345
-
346
- /**
347
- * Picks the first uplink in priority order whose `isAvailable` callback
348
- * returns true. `isAvailable` is injected, not hardcoded — at generation
349
- * time it might always return true (no live-state check, "as designed"
350
- * output); at a future runtime-aware stage it could call a data-source
351
- * plugin (see ADR 0001 §5) to check a real lease/handshake state.
352
- */
353
- export class PriorityUplink implements UplinkSelector {
354
- constructor(
355
- private readonly candidates: readonly Uplink[],
356
- private readonly isAvailable: (u: Uplink) => boolean = () => true,
357
- ) {
358
- if (candidates.length === 0) {
359
- throw new Error("PriorityUplink requires at least one candidate");
360
- }
361
- }
362
- resolve(): Uplink {
363
- const found = this.candidates.find((u) => this.isAvailable(u));
364
- return found ?? this.candidates[0];
365
- }
366
- }
367
-
368
- /** Explicit manual override — for an operator-driven "switch to X now". */
369
- export class ManualUplink implements UplinkSelector {
370
- private current: Uplink;
371
- constructor(initial: Uplink) {
372
- this.current = initial;
373
- }
374
- resolve(): Uplink {
375
- return this.current;
376
- }
377
- switchTo(uplink: Uplink): void {
378
- this.current = uplink;
379
- }
380
- }
381
-
382
- // ── Segment ──────────────────────────────────────────────────────────
383
-
384
- export interface Segment {
385
- /** Unique per network — also used directly as the prefix for every
386
- * generated OVN object name (sw-<name>, router-<name>, lrp-<name>,
387
- * ...). Uniqueness is enforced by NetworkBuilder (see define.ts). */
388
- readonly name: string;
389
- readonly addresses: Addresses;
390
- readonly if: InterfaceKind;
391
- /** Undefined means "no egress yet" — deliberately, not a bug: a
392
- * segment meant to eventually exit via an uplink that doesn't exist
393
- * yet (e.g. a VPN WireGuard tunnel not built out) should have NO
394
- * backbone join, NO route, and NO NAT generated for it at all, not
395
- * be silently routed out whichever uplink happens to be declared —
396
- * confirmed live: two VPN-bound segments were provisionally pointed
397
- * at the general default uplink and got MASQUERADEd out alongside
398
- * another segment, defeating the whole point of routing them
399
- * through a separate VPN egress later. See emitSegmentBackboneJoin
400
- * (generate-ovn.ts), which returns no lines at all when this is
401
- * undefined. */
402
- readonly uplink?: UplinkSelector;
403
- readonly nat?: Nat;
404
- /** Whether OVN advertises RA/SLAAC for this segment's IPv6 prefix so
405
- * clients self-configure a global address (see generate-ovn.ts). */
406
- readonly slaac: boolean;
407
- readonly host: Host;
408
- }