@kici-dev/shared 0.6.1 → 0.8.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.
@@ -0,0 +1,587 @@
1
+ import "../rolldown-runtime-ClRpJifh.js";
2
+ import { createLogger, toErrorMessage } from "@kici-dev/core";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ //#region src/net/nftables.ts
6
+ /**
7
+ * Programmatic nftables rule management for agent network isolation.
8
+ *
9
+ * Manages RFC1918 + cloud metadata blocking rules per-interface (Firecracker/container)
10
+ * or per-UID (bare-metal). All operations use `nft` CLI via child_process.execFile.
11
+ *
12
+ * Table layout:
13
+ * table ip kici {
14
+ * chain forward { type filter hook forward priority 0; policy accept; }
15
+ * chain input { type filter hook input priority 0; policy accept; }
16
+ * chain output { type filter hook output priority 0; policy accept; }
17
+ * }
18
+ *
19
+ * `forward` and `input` answer different questions and both are needed. A
20
+ * packet a sandbox sends to one of the host's OWN addresses — a bridge gateway,
21
+ * the host's LAN address — is delivered on the input hook and never traverses
22
+ * forward, so a forward rule cannot see it. `forward` governs what a sandbox
23
+ * reaches THROUGH the host; `input` governs what it reaches ON the host.
24
+ */
25
+ /** Transport protocols a port-scoped host-access accept is emitted for. */
26
+ const HOST_ACCESS_PROTOCOLS = ["tcp", "udp"];
27
+ /** An IPv4 dotted quad, optionally with a CIDR prefix length. */
28
+ const IPV4_OR_CIDR_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/;
29
+ /** True when every octet is in range and the prefix length, if any, is 0-32. */
30
+ function isIpv4OrCidr(value) {
31
+ const match = IPV4_OR_CIDR_RE.exec(value);
32
+ if (!match) return false;
33
+ for (let i = 1; i <= 4; i++) {
34
+ const octet = Number(match[i]);
35
+ if (!Number.isInteger(octet) || octet > 255) return false;
36
+ }
37
+ if (match[5] !== void 0) {
38
+ const prefix = Number(match[5]);
39
+ if (!Number.isInteger(prefix) || prefix > 32) return false;
40
+ }
41
+ return true;
42
+ }
43
+ /**
44
+ * Parse one host-access entry.
45
+ *
46
+ * Grammar: `<cidr|address|*>` optionally followed by `:<port|*>`. A bare number
47
+ * is a port on any host address, which is the common case — an operator naming
48
+ * a host-local registry mirror knows its port, not the host's dynamic
49
+ * addresses.
50
+ *
51
+ * Hostnames are rejected on purpose: nftables matches addresses, so resolving a
52
+ * name at rule-build time produces a rule that goes stale silently the next
53
+ * time the name moves.
54
+ *
55
+ * @throws Error naming the entry and what is wrong with it
56
+ */
57
+ function parseHostAccess(entry) {
58
+ const trimmed = entry.trim();
59
+ if (trimmed.length === 0) throw new Error("hostAccess entry is empty");
60
+ if (/^\d+$/.test(trimmed)) return {
61
+ daddr: null,
62
+ port: parseHostAccessPort(trimmed, entry)
63
+ };
64
+ const colon = trimmed.lastIndexOf(":");
65
+ const addressPart = colon === -1 ? trimmed : trimmed.slice(0, colon);
66
+ const portPart = colon === -1 ? null : trimmed.slice(colon + 1);
67
+ if (addressPart !== "*" && !isIpv4OrCidr(addressPart)) throw new Error(`hostAccess entry "${entry}" names "${addressPart}", which is not an IPv4 address, an IPv4 CIDR, or "*". Hostnames are not accepted — nftables matches addresses, and a name resolved at rule-build time goes stale without warning.`);
68
+ const port = portPart === null || portPart === "*" ? null : parseHostAccessPort(portPart, entry);
69
+ return {
70
+ daddr: addressPart === "*" ? null : addressPart,
71
+ port
72
+ };
73
+ }
74
+ /** Parse and range-check the port half of a host-access entry. */
75
+ function parseHostAccessPort(value, entry) {
76
+ if (!/^\d+$/.test(value)) throw new Error(`hostAccess entry "${entry}" has a non-numeric port "${value}"`);
77
+ const port = Number(value);
78
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`hostAccess entry "${entry}" has port ${value}, outside 1-65535`);
79
+ return port;
80
+ }
81
+ /**
82
+ * Build the host-access rules for one identifier, in final head-to-tail order:
83
+ * the conntrack exception, every accept, then one terminal drop.
84
+ *
85
+ * The conntrack rule leads because the chain is keyed on the sandbox as the
86
+ * SOURCE, so it also sees the reply leg of a connection the HOST opened toward
87
+ * the sandbox — a readiness probe, a metrics scrape. Without the exception
88
+ * those replies fall through to the terminal drop and the host's own connection
89
+ * dies as an opaque timeout. It widens nothing a sandbox can initiate:
90
+ * `established` is reached only by a flow whose first packet was already
91
+ * accepted, so a sandbox connecting to a port with no accept still has its SYN
92
+ * dropped and never reaches that state.
93
+ *
94
+ * A port-scoped entry emits an accept per protocol in
95
+ * {@link HOST_ACCESS_PROTOCOLS}. UDP is not optional: a container whose
96
+ * resolver is the bridge gateway — which is what rootful podman with
97
+ * aardvark-dns gives it — resolves over UDP, so a tcp-only accept on port 53
98
+ * leaves it unable to resolve any name.
99
+ *
100
+ * @returns one token list per rule, in final head-to-tail order
101
+ */
102
+ function buildHostAccessRuleOps(matchClause, hostAccess) {
103
+ const rules = [[
104
+ ...matchClause,
105
+ "ct",
106
+ "state",
107
+ "established,related",
108
+ "accept"
109
+ ]];
110
+ for (const entry of hostAccess) {
111
+ const { daddr, port } = parseHostAccess(entry);
112
+ const scoped = daddr === null ? matchClause : [
113
+ ...matchClause,
114
+ "ip",
115
+ "daddr",
116
+ daddr
117
+ ];
118
+ if (port === null) {
119
+ rules.push([...scoped, "accept"]);
120
+ continue;
121
+ }
122
+ for (const protocol of HOST_ACCESS_PROTOCOLS) rules.push([
123
+ ...scoped,
124
+ protocol,
125
+ "dport",
126
+ String(port),
127
+ "accept"
128
+ ]);
129
+ }
130
+ rules.push([...matchClause, "drop"]);
131
+ return rules;
132
+ }
133
+ const execFile$1 = promisify(execFile);
134
+ const logger = createLogger({ prefix: "nftables" });
135
+ /** Timeout for nft commands in milliseconds. */
136
+ const NFT_TIMEOUT_MS = 1e4;
137
+ /** RFC1918 private address ranges. */
138
+ const RFC1918_RANGES = [
139
+ "10.0.0.0/8",
140
+ "172.16.0.0/12",
141
+ "192.168.0.0/16"
142
+ ];
143
+ /** Cloud metadata service range (AWS/GCP/Azure link-local). */
144
+ const METADATA_RANGE = "169.254.0.0/16";
145
+ /**
146
+ * Subnet of the agent's `kici-jobs` bridge, on which the agent keys ONE drop set
147
+ * covering every nested job container — including one that does not exist yet.
148
+ *
149
+ * It lives here rather than beside the agent's own network constants because
150
+ * the Firecracker host provisioner has to recognise those rules to leave them
151
+ * alone, and the orchestrator carries `@kici-dev/agent` only as a devDependency.
152
+ * The agent module re-exports it, so its own call sites are unchanged.
153
+ */
154
+ const JOB_NETWORK_SUBNET = "172.31.0.0/16";
155
+ /** Table used when {@link NftOptions.table} is not set. */
156
+ const DEFAULT_NFT_TABLE = "kici";
157
+ /** The configured table, or the default. */
158
+ function tableOf(opts) {
159
+ return opts.table ?? "kici";
160
+ }
161
+ /**
162
+ * Execute an nft command with timeout.
163
+ * @returns stdout from the command
164
+ * @throws Error on non-zero exit or timeout
165
+ */
166
+ async function nft(opts, ...args) {
167
+ const { stdout } = opts.requireSudo === true ? await execFile$1("sudo", [
168
+ "-n",
169
+ "nft",
170
+ ...args
171
+ ], { timeout: NFT_TIMEOUT_MS }) : await execFile$1("nft", args, { timeout: NFT_TIMEOUT_MS });
172
+ return stdout;
173
+ }
174
+ /**
175
+ * Validate that nftables is available and the process has NET_ADMIN capability.
176
+ * Attempts `nft list tables` -- if it fails:
177
+ * - ENOENT: nft binary not installed
178
+ * - EPERM: nft binary present but NET_ADMIN capability missing
179
+ * Throws with a clear error message in both cases.
180
+ */
181
+ async function validateNftablesAvailability(opts = {}) {
182
+ try {
183
+ await nft(opts, "list", "tables");
184
+ } catch (err) {
185
+ const message = toErrorMessage(err);
186
+ if (message.includes("ENOENT") || message.includes("not found")) throw new Error("nftables binary not found at /usr/sbin/nft. The orchestrator container image must include nftables (apk add nftables). Network isolation for agents cannot be established without nftables.");
187
+ if (message.includes("EPERM") || message.includes("Operation not permitted")) throw new Error("nftables operation denied -- missing NET_ADMIN capability. Start the orchestrator container with --cap-add=NET_ADMIN. Network isolation for agents requires this capability.");
188
+ throw new Error(`nftables validation failed: ${message}`);
189
+ }
190
+ }
191
+ /**
192
+ * Ensure the nftables table and the chains this module writes to exist.
193
+ * Idempotent -- safe to call multiple times.
194
+ *
195
+ * Every chain is verified individually. A bare "does the table exist?" check is
196
+ * not enough: the Firecracker host provisioner creates the table before this
197
+ * module ever runs, so a table with no `forward` chain satisfied the old early
198
+ * return — and then every `addIsolationRules` failed with nft's "No such file
199
+ * or directory", leaving every VM on that host with no isolation rules at all.
200
+ *
201
+ * @param opts - `table` selects the table; `requireBaselineChain` additionally
202
+ * ensures the regular {@link BASELINE_CHAIN} exists (Firecracker hosts, whose
203
+ * `forward` chain ends in a jump to it).
204
+ */
205
+ async function ensureKiciTable(opts = {}) {
206
+ const table = tableOf(opts);
207
+ await nft(opts, "add", "table", "ip", table);
208
+ await nft(opts, "add", "chain", "ip", table, "forward", "{ type filter hook forward priority 0; policy accept; }");
209
+ await ensureKiciInputChain(opts);
210
+ await nft(opts, "add", "chain", "ip", table, "output", "{ type filter hook output priority 0; policy accept; }");
211
+ if (opts.requireBaselineChain === true) await nft(opts, "add", "chain", "ip", table, BASELINE_CHAIN);
212
+ logger.debug(`nftables table ip ${table} and chains ready`);
213
+ }
214
+ /** Chain in the kici table that filters host-destined sandbox traffic. */
215
+ const INPUT_CHAIN = "input";
216
+ /**
217
+ * Ensure the kici table and its `input` chain exist. Idempotent — `nft add` is
218
+ * a create-or-noop for both, so there is no check-then-create window.
219
+ *
220
+ * The chain's policy is `accept` because it is a base chain on the host's own
221
+ * input hook: everything the host itself receives passes through it, and a
222
+ * default-deny there would take the machine off the network. The deny lives in
223
+ * the per-identifier terminal drop {@link buildHostAccessRuleOps} emits.
224
+ */
225
+ async function ensureKiciInputChain(opts = {}) {
226
+ const table = tableOf(opts);
227
+ await nft(opts, "add", "table", "ip", table);
228
+ await nft(opts, "add", "chain", "ip", table, INPUT_CHAIN, "{ type filter hook input priority 0; policy accept; }");
229
+ }
230
+ /**
231
+ * Apply one identifier's host-access rules to the `input` chain, replacing
232
+ * whatever it had.
233
+ *
234
+ * The pre-clean is not an optimisation. Bridge networks recycle addresses, so a
235
+ * crash or a `kill -9` leaves the previous holder's accepts behind for the next
236
+ * container on that IP to inherit — which is the boundary this chain exists to
237
+ * hold. Applying without removing first would also stack a second terminal drop
238
+ * above the first run's accepts, shadowing every one of them.
239
+ *
240
+ * Rules are `insert`ed in reverse so the block lands at the chain head in the
241
+ * order {@link buildHostAccessRuleOps} returns: accepts first, terminal drop
242
+ * last. Inserting forwards would put the drop above the accepts and deny
243
+ * everything.
244
+ */
245
+ async function addHostIsolationRules(identifier, hostAccess, matchMode = "saddr", opts = {}) {
246
+ await ensureKiciInputChain(opts);
247
+ await removeHostIsolationRules(identifier, opts);
248
+ const rules = buildHostAccessRuleOps(matchMode === "iifname" ? ["iifname", identifier] : [
249
+ "ip",
250
+ "saddr",
251
+ identifier
252
+ ], hostAccess);
253
+ for (const tokens of [...rules].reverse()) await nft(opts, "insert", "rule", "ip", tableOf(opts), INPUT_CHAIN, ...tokens);
254
+ logger.info(`Host-access rules applied for ${identifier}: ${hostAccess.length > 0 ? hostAccess.join(", ") : "nothing (host fully denied)"}`);
255
+ }
256
+ /**
257
+ * A table's `input` chain, as `nft -a list chain` prints it, or `null` when the
258
+ * table or the chain does not exist yet.
259
+ *
260
+ * Returning `null` rather than throwing keeps "the chain is not there" distinct
261
+ * from "the chain is there and holds nothing", which a caller deciding whether
262
+ * a rule set is already installed has to be able to tell apart.
263
+ */
264
+ async function readInputChain(opts = {}) {
265
+ try {
266
+ return await nft(opts, "-a", "list", "chain", "ip", tableOf(opts), INPUT_CHAIN);
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+ /**
272
+ * Remove one identifier's rules from the `input` chain.
273
+ *
274
+ * Best-effort: a missing chain, or a rule another path already deleted, must
275
+ * not abort a teardown.
276
+ */
277
+ async function removeHostIsolationRules(identifier, opts = {}) {
278
+ const table = tableOf(opts);
279
+ const output = await readInputChain(opts);
280
+ if (output === null) return;
281
+ const handles = parseRuleHandles(output, identifier);
282
+ if (handles.length === 0) return;
283
+ for (const handle of handles.sort((a, b) => b - a)) try {
284
+ await nft(opts, "delete", "rule", "ip", table, INPUT_CHAIN, "handle", String(handle));
285
+ } catch (err) {
286
+ logger.warn(`Failed to delete input rule handle ${handle}: ${toErrorMessage(err)}`);
287
+ }
288
+ logger.info(`Removed ${handles.length} host-access rules for ${identifier}`);
289
+ }
290
+ /**
291
+ * Name of the regular (non-hooked) chain holding the host baseline rules.
292
+ *
293
+ * The Firecracker host provisioner puts its six source-scoped baseline rules
294
+ * here and reaches them with a `jump` appended as the `forward` chain's LAST
295
+ * rule. Two properties follow, and both are load-bearing:
296
+ *
297
+ * - **Per-VM rules always win.** They are inserted at the `forward` head, so
298
+ * every one of them is evaluated before the jump. A per-VM `accept`
299
+ * terminates the hook before the baseline can re-drop an allowlisted
300
+ * destination, and a per-VM `denyAll` drop is terminal before the
301
+ * baseline's blanket internet `accept` can let the packet out.
302
+ * - **A self-heal can rebuild the baseline without touching live VMs.** The
303
+ * provisioner flushes and refills only this chain, so it never has to
304
+ * `delete table` — which used to drop every running VM's isolation rules
305
+ * fail-open.
306
+ *
307
+ * A regular chain reached by `jump` rather than a second base chain at a lower
308
+ * priority: in netfilter an `accept` ends only the current base chain, so an
309
+ * allowlist accept in an earlier base chain would still be re-evaluated — and
310
+ * dropped — by the baseline's 10.0.0.0/8 rule in the later one.
311
+ */
312
+ const BASELINE_CHAIN = "baseline";
313
+ /**
314
+ * Build the per-identifier isolation rules in their final head-to-tail order.
315
+ *
316
+ * nftables is first-match-wins within a chain and `accept` is terminal, so the
317
+ * order below is the whole security property:
318
+ *
319
+ * 1. gateway accept
320
+ * 2. allowlisted CIDR accepts — ahead of the drops, so an allowlisted
321
+ * destination inside a dropped range (a 10.x registry endpoint behind the
322
+ * 10.0.0.0/8 drop) is accepted before the drop is evaluated
323
+ * 3. RFC1918 drops
324
+ * 4. cloud-metadata drop
325
+ * 5. `denyAll` drop
326
+ *
327
+ * {@link addIsolationRules} lands them in exactly this order by applying the
328
+ * list in REVERSE with `insert`, which puts the whole block at the chain head —
329
+ * ahead of the tail `jump` to {@link BASELINE_CHAIN}.
330
+ *
331
+ * @returns one token list per rule, in final head-to-tail order
332
+ */
333
+ function buildIsolationRuleOps(matchClause, gatewayIp, networkPolicy) {
334
+ const rules = [];
335
+ rules.push([
336
+ ...matchClause,
337
+ "ip",
338
+ "daddr",
339
+ gatewayIp,
340
+ "accept"
341
+ ]);
342
+ for (const cidr of networkPolicy?.allowlist ?? []) rules.push([
343
+ ...matchClause,
344
+ "ip",
345
+ "daddr",
346
+ cidr,
347
+ "accept"
348
+ ]);
349
+ for (const range of RFC1918_RANGES) rules.push([
350
+ ...matchClause,
351
+ "ip",
352
+ "daddr",
353
+ range,
354
+ "drop"
355
+ ]);
356
+ rules.push([
357
+ ...matchClause,
358
+ "ip",
359
+ "daddr",
360
+ METADATA_RANGE,
361
+ "drop"
362
+ ]);
363
+ if (networkPolicy?.denyAll) rules.push([...matchClause, "drop"]);
364
+ return rules;
365
+ }
366
+ /**
367
+ * Add network isolation rules for one identifier (a TAP interface name, or a
368
+ * container's source IP).
369
+ *
370
+ * Every rule is `insert`ed, applying {@link buildIsolationRuleOps} in reverse,
371
+ * so the block lands at the chain head in its documented order — ahead of any
372
+ * host baseline reached by a tail `jump`.
373
+ *
374
+ * @param identifier - Network interface name or source IP to match (e.g., "veth-abc123" or "172.30.0.5")
375
+ * @param gatewayIp - Gateway IP that must remain accessible (e.g., "10.0.0.1")
376
+ * @param networkPolicy - Optional policy with allowlist and denyAll settings
377
+ * @param matchMode - How to match traffic: 'iifname' for interface name (default), 'saddr' for source IP
378
+ */
379
+ async function addIsolationRules(identifier, gatewayIp, networkPolicy, matchMode = "iifname", opts = {}) {
380
+ const matchLabel = matchMode === "iifname" ? `interface ${identifier}` : `source IP ${identifier}`;
381
+ logger.info(`Adding isolation rules for ${matchLabel} (gateway: ${gatewayIp})`);
382
+ const matchClause = matchMode === "iifname" ? ["iifname", identifier] : [
383
+ "ip",
384
+ "saddr",
385
+ identifier
386
+ ];
387
+ if (networkPolicy?.allowlist) for (const cidr of networkPolicy.allowlist) logger.debug(`Allowlisting ${cidr} for ${matchLabel}`);
388
+ if (networkPolicy?.denyAll) logger.info(`Blocking all outbound traffic for ${matchLabel} (denyAll)`);
389
+ const rules = buildIsolationRuleOps(matchClause, gatewayIp, networkPolicy);
390
+ for (const tokens of [...rules].reverse()) await nft(opts, "insert", "rule", "ip", tableOf(opts), "forward", ...tokens);
391
+ logger.info(`Isolation rules applied for ${matchLabel}`);
392
+ }
393
+ /**
394
+ * A table's `forward` chain, as `nft -a list chain` prints it, or `null` when
395
+ * the table or the chain does not exist yet.
396
+ *
397
+ * The text form is what {@link parseRuleHandles} reads, and it is also what a
398
+ * caller asking whether a rule set is ALREADY installed — rather than deleting
399
+ * it — compares against the rule text {@link buildIsolationRuleOps} emits.
400
+ * {@link listForwardRules} cannot answer for a subnet identifier: nft reports a
401
+ * subnet source as a prefix object, which its classifier treats as "not a
402
+ * per-agent rule" on purpose.
403
+ *
404
+ * Returning `null` rather than throwing keeps "the chain is not there" distinct
405
+ * from "the chain is there and holds nothing", exactly as {@link readInputChain}
406
+ * does for the sibling chain. The distinction is load-bearing in the install
407
+ * direction: a caller checking whether a rule set is present must read a missing
408
+ * chain as NOT installed and go install it, and a throw instead aborts the whole
409
+ * ensure path — which for the agent's job network degrades to a container on an
410
+ * unfiltered bridge.
411
+ */
412
+ async function readForwardChain(opts = {}) {
413
+ try {
414
+ return await nft(opts, "-a", "list", "chain", "ip", tableOf(opts), "forward");
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+ /**
420
+ * Remove every isolation rule this identifier owns, in both the `forward` and
421
+ * the `input` chain. Called during agent cleanup, and as the pre-clean before a
422
+ * re-add.
423
+ *
424
+ * Both chains are swept because they are one boundary: an identifier is a
425
+ * container IP or a TAP name, addresses are recycled, and leaving the input
426
+ * chain's accepts behind hands the next tenant on that address the previous
427
+ * one's host reachability.
428
+ *
429
+ * Best-effort: logs errors but does not throw (cleanup must not block
430
+ * destruction).
431
+ *
432
+ * @param interfaceName - Network interface name or source IP whose rules should be removed
433
+ */
434
+ async function removeIsolationRules(interfaceName, opts = {}) {
435
+ logger.info(`Removing isolation rules for interface ${interfaceName}`);
436
+ await removeHostIsolationRules(interfaceName, opts);
437
+ try {
438
+ const table = tableOf(opts);
439
+ const output = await readForwardChain(opts);
440
+ if (output === null) return;
441
+ const handles = parseRuleHandles(output, interfaceName);
442
+ if (handles.length === 0) {
443
+ logger.debug(`No rules found for interface ${interfaceName}`);
444
+ return;
445
+ }
446
+ for (const handle of handles.sort((a, b) => b - a)) try {
447
+ await nft(opts, "delete", "rule", "ip", table, "forward", "handle", String(handle));
448
+ } catch (err) {
449
+ logger.warn(`Failed to delete rule handle ${handle}: ${err}`);
450
+ }
451
+ logger.info(`Removed ${handles.length} rules for interface ${interfaceName}`);
452
+ } catch (err) {
453
+ logger.warn(`Failed to list/remove rules for ${interfaceName}: ${err}`);
454
+ }
455
+ }
456
+ /**
457
+ * Read every rule in a table's forward chain, with its handle and the per-agent
458
+ * identifier it matches on.
459
+ *
460
+ * This is the enumerate-what-is-there half of rule management; the
461
+ * identifier-known delete path is {@link parseRuleHandles}. Reconciliation
462
+ * needs this one: rules are removed only on the synchronous teardown paths this
463
+ * process drives, so an orchestrator crash, a `kill -9`, or a VM that dies
464
+ * while the orchestrator is down strands `ip saddr <ip> …` rules in the shared
465
+ * chain forever. The allocator then hands that IP to another tenant, who
466
+ * inherits the dead job's allowlist.
467
+ *
468
+ * Returns an empty list rather than throwing when the chain cannot be read —
469
+ * the caller is a best-effort sweep, and a failed read must not be mistaken
470
+ * for "nothing is there".
471
+ */
472
+ async function listForwardRules(opts = {}) {
473
+ const table = tableOf(opts);
474
+ let parsed;
475
+ try {
476
+ const output = await nft(opts, "-j", "-a", "list", "chain", "ip", table, "forward");
477
+ parsed = JSON.parse(output);
478
+ } catch (err) {
479
+ logger.warn(`Failed to list forward rules in ip ${table}: ${toErrorMessage(err)}`);
480
+ return [];
481
+ }
482
+ const entries = parsed?.nftables;
483
+ if (!Array.isArray(entries)) return [];
484
+ const rules = [];
485
+ for (const entry of entries) {
486
+ const rule = entry.rule;
487
+ if (!rule || typeof rule.handle !== "number") continue;
488
+ rules.push({
489
+ handle: rule.handle,
490
+ identifier: identifierOf(rule.expr)
491
+ });
492
+ }
493
+ return rules;
494
+ }
495
+ /**
496
+ * The concrete per-agent identifier a rule's expression list matches on.
497
+ *
498
+ * A per-agent rule matches a single host — `ip saddr 10.0.0.5` — or one exact
499
+ * interface — `iifname "kici-a1b2c3d4"`. A host baseline rule matches a whole
500
+ * subnet, which nft reports as a `{ prefix: … }` object, and every one of them
501
+ * also names the `kici-*` interface wildcard. That difference is the only thing
502
+ * separating "a live agent owns this" from "the host installed this".
503
+ *
504
+ * The whole rule is scanned before answering, and a wildcard anywhere in it
505
+ * settles the question. Position is why: the host's inbound established/related
506
+ * rule LEADS with `iifname "<egress iface>"` — a concrete name carrying no `*`
507
+ * — and names the wildcard only later, in `oifname`. Returning on the first
508
+ * concrete interface therefore claimed that rule for a nonexistent agent named
509
+ * after the host NIC, so the provisioning sweep spared it and every re-provision
510
+ * left another stale copy behind.
511
+ */
512
+ function identifierOf(expr) {
513
+ if (!Array.isArray(expr)) return null;
514
+ let candidate = null;
515
+ for (const node of expr) {
516
+ const match = node.match;
517
+ if (!match) continue;
518
+ const left = match.left;
519
+ const right = match.right;
520
+ if (typeof right !== "string") {
521
+ if (left?.payload?.field === "saddr") return null;
522
+ continue;
523
+ }
524
+ if (right.includes("*")) return null;
525
+ if (candidate !== null) continue;
526
+ if (left?.payload?.field === "saddr") candidate = right;
527
+ else if (left?.meta?.key === "iifname") candidate = right;
528
+ }
529
+ return candidate;
530
+ }
531
+ /**
532
+ * Every per-agent identifier currently present in the forward chain, mapped to
533
+ * the handles of the rules that carry it.
534
+ */
535
+ async function listIsolationRules(opts = {}) {
536
+ const byIdentifier = /* @__PURE__ */ new Map();
537
+ for (const rule of await listForwardRules(opts)) {
538
+ if (rule.identifier === null) continue;
539
+ const handles = byIdentifier.get(rule.identifier);
540
+ if (handles) handles.push(rule.handle);
541
+ else byIdentifier.set(rule.identifier, [rule.handle]);
542
+ }
543
+ return byIdentifier;
544
+ }
545
+ /**
546
+ * Delete rules from a table's forward chain by handle, highest first so earlier
547
+ * deletions cannot shift the handles still to come.
548
+ *
549
+ * Best-effort per handle: a rule another path already removed must not abort
550
+ * the rest of the sweep.
551
+ */
552
+ async function deleteForwardRules(handles, opts = {}) {
553
+ const table = tableOf(opts);
554
+ let deleted = 0;
555
+ for (const handle of [...handles].sort((a, b) => b - a)) try {
556
+ await nft(opts, "delete", "rule", "ip", table, "forward", "handle", String(handle));
557
+ deleted++;
558
+ } catch (err) {
559
+ logger.warn(`Failed to delete rule handle ${handle} in ip ${table}: ${toErrorMessage(err)}`);
560
+ }
561
+ return deleted;
562
+ }
563
+ /**
564
+ * Parse nft rule listing output and extract handles for rules matching a given identifier.
565
+ * Handles lines like: ` iifname "veth-abc" ip daddr 10.0.0.0/8 drop # handle 42`
566
+ *
567
+ * @param nftOutput - Raw output from `nft -a list chain ip kici <chain>`
568
+ * @param identifier - String to search for in each rule line (interface name or UID)
569
+ * @returns Array of numeric rule handles
570
+ */
571
+ function parseRuleHandles(nftOutput, identifier) {
572
+ const handles = [];
573
+ const lines = nftOutput.split("\n");
574
+ const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
575
+ const boundaryRe = new RegExp(`(^|[\\s"])${escaped}([\\s"]|$)`);
576
+ for (const line of lines) {
577
+ if (!line.includes("# handle")) continue;
578
+ if (!boundaryRe.test(line)) continue;
579
+ const match = line.match(/# handle (\d+)/);
580
+ if (match) handles.push(parseInt(match[1], 10));
581
+ }
582
+ return handles;
583
+ }
584
+ //#endregion
585
+ export { BASELINE_CHAIN, DEFAULT_NFT_TABLE, INPUT_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, deleteForwardRules, ensureKiciInputChain, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, readInputChain, removeHostIsolationRules, removeIsolationRules, validateNftablesAvailability };
586
+
587
+ //# sourceMappingURL=nftables.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=nftables.test.d.ts.map
package/dist/net.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Node-only network primitives, kept off the main barrel.
3
+ *
4
+ * `nftables.ts` promisifies `child_process.execFile` at module scope, so
5
+ * exporting it from `index.ts` would pull `node:child_process` into the module
6
+ * graph of every `@kici-dev/shared` consumer — including ones that only wanted
7
+ * a logger. Importers ask for it explicitly: `@kici-dev/shared/net`.
8
+ */
9
+ export * from './net/nftables.js';
10
+ //# sourceMappingURL=net.d.ts.map
package/dist/net.js ADDED
@@ -0,0 +1,3 @@
1
+ import "./rolldown-runtime-ClRpJifh.js";
2
+ import { BASELINE_CHAIN, DEFAULT_NFT_TABLE, INPUT_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, deleteForwardRules, ensureKiciInputChain, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, readInputChain, removeHostIsolationRules, removeIsolationRules, validateNftablesAvailability } from "./net/nftables.js";
3
+ export { BASELINE_CHAIN, DEFAULT_NFT_TABLE, INPUT_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, buildHostAccessRuleOps, buildIsolationRuleOps, deleteForwardRules, ensureKiciInputChain, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, readInputChain, removeHostIsolationRules, removeIsolationRules, validateNftablesAvailability };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kici-dev/shared",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Shared utilities for the KiCI CI/CD stack — logging, zx setup, crypto, telemetry, health and metrics routes. No business logic.",
5
5
  "keywords": [
6
6
  "ci",
@@ -93,6 +93,10 @@
93
93
  "./container-runtime": {
94
94
  "import": "./dist/container-runtime.js",
95
95
  "types": "./dist/container-runtime.d.ts"
96
+ },
97
+ "./net": {
98
+ "import": "./dist/net.js",
99
+ "types": "./dist/net.d.ts"
96
100
  }
97
101
  },
98
102
  "dependencies": {
@@ -117,8 +121,8 @@
117
121
  "yaml": "^2.9.0",
118
122
  "zod": "^4.4.3",
119
123
  "zx": "^8.8.5",
120
- "@kici-dev/core": "0.6.1",
121
- "@kici-dev/engine": "0.6.1"
124
+ "@kici-dev/core": "0.8.0",
125
+ "@kici-dev/engine": "0.8.0"
122
126
  },
123
127
  "devDependencies": {
124
128
  "@opentelemetry/sdk-trace-base": "^2.10.0",