@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.
- package/dist/cold-store/cold-store.js +4 -4
- package/dist/cold-store/index.d.ts +1 -1
- package/dist/cold-store/index.js +2 -2
- package/dist/cold-store/metrics.d.ts +17 -1
- package/dist/cold-store/metrics.js +17 -2
- package/dist/env/define-env.d.ts +14 -1
- package/dist/env/define-env.js +12 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/net/nftables.d.ts +356 -0
- package/dist/net/nftables.js +587 -0
- package/dist/net/nftables.test.d.ts +2 -0
- package/dist/net.d.ts +10 -0
- package/dist/net.js +3 -0
- package/package.json +7 -3
- package/sbom.spdx.json +25 -25
|
@@ -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
|
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.
|
|
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.
|
|
121
|
-
"@kici-dev/engine": "0.
|
|
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",
|