@telorun/kernel 0.44.1 → 0.46.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/controller-loaders/npm-loader.d.ts +32 -0
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +76 -54
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +16 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/transports/egress-guard.d.ts +26 -0
- package/dist/transports/egress-guard.d.ts.map +1 -0
- package/dist/transports/egress-guard.js +99 -0
- package/dist/transports/egress-guard.js.map +1 -0
- package/dist/transports/oci/oci-client.d.ts +8 -0
- package/dist/transports/oci/oci-client.d.ts.map +1 -1
- package/dist/transports/oci/oci-client.js +62 -6
- package/dist/transports/oci/oci-client.js.map +1 -1
- package/dist/transports/oci/oci-ref.d.ts +1 -20
- package/dist/transports/oci/oci-ref.d.ts.map +1 -1
- package/dist/transports/oci/oci-ref.js +4 -35
- package/dist/transports/oci/oci-ref.js.map +1 -1
- package/dist/transports/oci/oci-transport.d.ts +1 -0
- package/dist/transports/oci/oci-transport.d.ts.map +1 -1
- package/dist/transports/oci/oci-transport.js +4 -0
- package/dist/transports/oci/oci-transport.js.map +1 -1
- package/dist/transports/registry-transport.d.ts +1 -0
- package/dist/transports/registry-transport.d.ts.map +1 -1
- package/dist/transports/registry-transport.js +36 -2
- package/dist/transports/registry-transport.js.map +1 -1
- package/dist/transports/transport-registry.d.ts +3 -0
- package/dist/transports/transport-registry.d.ts.map +1 -1
- package/dist/transports/transport-registry.js +5 -0
- package/dist/transports/transport-registry.js.map +1 -1
- package/dist/transports/transport.d.ts +9 -0
- package/dist/transports/transport.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/controller-loaders/npm-loader.ts +79 -50
- package/src/controllers/resource-definition/resource-definition-controller.ts +23 -0
- package/src/transports/egress-guard.ts +103 -0
- package/src/transports/oci/oci-client.ts +67 -6
- package/src/transports/oci/oci-ref.ts +4 -53
- package/src/transports/oci/oci-transport.ts +5 -0
- package/src/transports/registry-transport.ts +34 -1
- package/src/transports/transport-registry.ts +6 -0
- package/src/transports/transport.ts +10 -0
|
@@ -44,22 +44,39 @@ const PEER_INSTALL_FLAGS: ReadonlyArray<string> =
|
|
|
44
44
|
: [];
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* A held lock is refreshed (its mtime bumped) every {@link LOCK_HEARTBEAT_MS}
|
|
48
|
+
* by the holder. Staleness is judged purely by mtime age — a lock older than
|
|
49
|
+
* this means the holder stopped heartbeating (crashed, was killed, or its
|
|
50
|
+
* container vanished), so it is safe to reclaim. This deliberately does NOT
|
|
51
|
+
* probe the recorded PID for liveness: PID identity is meaningless across
|
|
52
|
+
* container restarts and PID namespaces (deterministic PID reuse makes an
|
|
53
|
+
* unrelated process look like the dead holder on the same hostname), which is
|
|
54
|
+
* exactly what deadlocked container boots. The `{pid, host}` in the lock body
|
|
55
|
+
* is diagnostics for humans, never a reclaim signal. Must be comfortably
|
|
56
|
+
* larger than the heartbeat interval so a briefly-descheduled holder (GC
|
|
57
|
+
* pause, busy event loop) is not reclaimed out from under itself.
|
|
50
58
|
*/
|
|
51
|
-
const LOCK_STALE_MS =
|
|
59
|
+
const LOCK_STALE_MS = 30_000;
|
|
60
|
+
|
|
61
|
+
/** How often the holder refreshes the lock mtime while `fn` runs. Well under
|
|
62
|
+
* {@link LOCK_STALE_MS} so several heartbeats are missed before a live holder
|
|
63
|
+
* is ever judged stale. */
|
|
64
|
+
const LOCK_HEARTBEAT_MS = 5_000;
|
|
52
65
|
|
|
53
66
|
/**
|
|
54
67
|
* Total wall-clock cap for waiting on the install lock — enough for a slow
|
|
55
|
-
* first install on a peer process to finish, short enough that a
|
|
56
|
-
* CI job fails loudly rather than hanging for hours. The retry
|
|
57
|
-
* trades wakeup latency vs. wasted polls; 500ms is well below the
|
|
58
|
-
* holder's typical hold time.
|
|
68
|
+
* first install on a peer process to finish, short enough that a genuinely
|
|
69
|
+
* deadlocked CI job fails loudly rather than hanging for hours. The retry
|
|
70
|
+
* interval trades wakeup latency vs. wasted polls; 500ms is well below the
|
|
71
|
+
* lock holder's typical hold time.
|
|
59
72
|
*/
|
|
60
73
|
const LOCK_WAIT_MAX_MS = 5 * 60_000;
|
|
61
74
|
const LOCK_RETRY_MS = 500;
|
|
62
75
|
|
|
76
|
+
/** After this long waiting on a lock, emit one stderr line so a slow wait is
|
|
77
|
+
* visible instead of looking like a silent hang. */
|
|
78
|
+
const LOCK_WAIT_NOTICE_MS = 2_000;
|
|
79
|
+
|
|
63
80
|
/**
|
|
64
81
|
* Tells the dispatcher (and any UI consumer downstream) which branch the
|
|
65
82
|
* resolver actually took. `npm-install` is the only one that hits the network;
|
|
@@ -532,9 +549,17 @@ async function resolveKernelPackageRoot(name: string): Promise<string | null> {
|
|
|
532
549
|
|
|
533
550
|
/**
|
|
534
551
|
* Acquire a process-portable lock on `<root>/.lock` and execute fn while
|
|
535
|
-
* holding it.
|
|
536
|
-
*
|
|
537
|
-
*
|
|
552
|
+
* holding it. `fs.open(path, 'wx')` is atomic on POSIX and Windows, so
|
|
553
|
+
* concurrent processes serialize naturally.
|
|
554
|
+
*
|
|
555
|
+
* Liveness is a heartbeat: the holder bumps the lock's mtime every
|
|
556
|
+
* {@link LOCK_HEARTBEAT_MS} while `fn` runs, and a waiter reclaims a lock whose
|
|
557
|
+
* mtime is older than {@link LOCK_STALE_MS} (holder crashed/killed/vanished).
|
|
558
|
+
* mtime age is the *only* reclaim signal — the recorded `{pid, host}` is
|
|
559
|
+
* diagnostics, never probed for liveness, because PID identity is unreliable
|
|
560
|
+
* across container restarts and PID namespaces (the failure that deadlocked
|
|
561
|
+
* container boots). Reclaim is via atomic rename to a unique tombstone so two
|
|
562
|
+
* waiters that both see the lock stale can't both win.
|
|
538
563
|
*
|
|
539
564
|
* The lock guards the install-root manifest write, the package-manager
|
|
540
565
|
* invocation, and any state-file writes. It does NOT serialize *reads* of
|
|
@@ -548,6 +573,7 @@ async function withInstallLock<T>(installRoot: string, fn: () => Promise<T>): Pr
|
|
|
548
573
|
const lockBody = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() });
|
|
549
574
|
let handle: import("fs/promises").FileHandle | null = null;
|
|
550
575
|
const waitedSince = Date.now();
|
|
576
|
+
let noticed = false;
|
|
551
577
|
while (true) {
|
|
552
578
|
try {
|
|
553
579
|
handle = await fs.open(lockPath, "wx");
|
|
@@ -555,31 +581,41 @@ async function withInstallLock<T>(installRoot: string, fn: () => Promise<T>): Pr
|
|
|
555
581
|
break;
|
|
556
582
|
} catch (err: any) {
|
|
557
583
|
if (err?.code !== "EEXIST") throw err;
|
|
558
|
-
// Lock exists.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
continue;
|
|
563
|
-
}
|
|
564
|
-
if (Date.now() - waitedSince > LOCK_WAIT_MAX_MS) {
|
|
584
|
+
// Lock exists. Reclaim it only if its heartbeat has gone silent.
|
|
585
|
+
if (await reclaimIfStale(lockPath)) continue;
|
|
586
|
+
const waited = Date.now() - waitedSince;
|
|
587
|
+
if (waited > LOCK_WAIT_MAX_MS) {
|
|
565
588
|
throw new Error(
|
|
566
589
|
`[telo] timed out waiting for install lock at ${lockPath} ` +
|
|
567
|
-
`(held >${LOCK_WAIT_MAX_MS / 60_000} min). ` +
|
|
590
|
+
`(held >${LOCK_WAIT_MAX_MS / 60_000} min with a live heartbeat). ` +
|
|
568
591
|
`Inspect the lock file or remove it manually if no other Telo process is running.`,
|
|
569
592
|
);
|
|
570
593
|
}
|
|
594
|
+
if (!noticed && waited > LOCK_WAIT_NOTICE_MS) {
|
|
595
|
+
noticed = true;
|
|
596
|
+
process.stderr.write(`[telo] waiting for controller install lock at ${lockPath}…\n`);
|
|
597
|
+
}
|
|
571
598
|
await sleep(LOCK_RETRY_MS);
|
|
572
599
|
}
|
|
573
600
|
}
|
|
574
601
|
|
|
602
|
+
// Keep the lock fresh while `fn` runs so a slow-but-live install is never
|
|
603
|
+
// reclaimed. `unref` so the heartbeat can't by itself keep the process alive.
|
|
604
|
+
const heartbeat = setInterval(() => {
|
|
605
|
+
const now = new Date();
|
|
606
|
+
fs.utimes(lockPath, now, now).catch(() => {});
|
|
607
|
+
}, LOCK_HEARTBEAT_MS);
|
|
608
|
+
heartbeat.unref?.();
|
|
609
|
+
|
|
575
610
|
try {
|
|
576
611
|
return await fn();
|
|
577
612
|
} finally {
|
|
613
|
+
clearInterval(heartbeat);
|
|
578
614
|
// The fd close races nothing important: if it fails, the FD is reaped on
|
|
579
615
|
// process exit. The unlink is the dangerous one — a non-ENOENT failure
|
|
580
616
|
// (permissions, read-only mount) means every subsequent kernel waits
|
|
581
617
|
// LOCK_STALE_MS before reclaiming. Surface it so the cause is visible
|
|
582
|
-
// rather than hiding behind a silent
|
|
618
|
+
// rather than hiding behind a silent hang.
|
|
583
619
|
await handle!.close().catch(() => {});
|
|
584
620
|
try {
|
|
585
621
|
await fs.rm(lockPath, { force: true });
|
|
@@ -593,47 +629,36 @@ async function withInstallLock<T>(installRoot: string, fn: () => Promise<T>): Pr
|
|
|
593
629
|
}
|
|
594
630
|
}
|
|
595
631
|
|
|
596
|
-
|
|
632
|
+
/**
|
|
633
|
+
* If the lock at `lockPath` is stale (mtime older than {@link LOCK_STALE_MS}, so
|
|
634
|
+
* its holder stopped heartbeating), atomically claim and remove it and return
|
|
635
|
+
* true; otherwise return false. The claim is a `rename` to a unique tombstone:
|
|
636
|
+
* `rename` is atomic and fails for all but one racer, so two processes that
|
|
637
|
+
* both observe the same stale lock cannot both reclaim it — the loser's rename
|
|
638
|
+
* throws ENOENT (the file is already gone) and it simply retries the open.
|
|
639
|
+
*/
|
|
640
|
+
async function reclaimIfStale(lockPath: string): Promise<boolean> {
|
|
597
641
|
let stat: import("fs").Stats;
|
|
598
642
|
try {
|
|
599
643
|
stat = await fs.stat(lockPath);
|
|
600
644
|
} catch (err: any) {
|
|
601
|
-
// Race: lock vanished while we inspected it.
|
|
602
|
-
if (err?.code === "ENOENT") return
|
|
645
|
+
// Race: lock vanished while we inspected it. Retry the open immediately.
|
|
646
|
+
if (err?.code === "ENOENT") return true;
|
|
603
647
|
throw err;
|
|
604
648
|
}
|
|
605
|
-
|
|
606
|
-
if (age < LOCK_STALE_MS) return false;
|
|
649
|
+
if (Date.now() - stat.mtimeMs < LOCK_STALE_MS) return false;
|
|
607
650
|
|
|
608
|
-
|
|
651
|
+
// Stale — the holder's heartbeat is silent. Claim via atomic rename; only one
|
|
652
|
+
// racer wins, the rest get ENOENT and fall back to retrying the open.
|
|
653
|
+
const tombstone = `${lockPath}.stale.${process.pid}.${stat.mtimeMs}`;
|
|
609
654
|
try {
|
|
610
|
-
|
|
655
|
+
await fs.rename(lockPath, tombstone);
|
|
611
656
|
} catch (err: any) {
|
|
612
|
-
if (err?.code === "ENOENT") return
|
|
657
|
+
if (err?.code === "ENOENT") return true; // another waiter reclaimed it first
|
|
613
658
|
throw err;
|
|
614
659
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// JSON). Treating either as held would deadlock; throwing here would block
|
|
618
|
-
// every controller load behind a broken file the operator may not even
|
|
619
|
-
// notice exists.
|
|
620
|
-
if (!body) return true;
|
|
621
|
-
let parsed: { pid?: number; host?: string };
|
|
622
|
-
try {
|
|
623
|
-
parsed = JSON.parse(body);
|
|
624
|
-
} catch {
|
|
625
|
-
return true;
|
|
626
|
-
}
|
|
627
|
-
if (!parsed?.pid) return true;
|
|
628
|
-
if (parsed.host && parsed.host !== os.hostname()) return false; // different host: assume held
|
|
629
|
-
try {
|
|
630
|
-
// signal 0 throws if the PID isn't alive (or is owned by a different user)
|
|
631
|
-
process.kill(parsed.pid, 0);
|
|
632
|
-
return false;
|
|
633
|
-
} catch (err: any) {
|
|
634
|
-
// ESRCH = no such process. EPERM = exists but we can't signal it; treat as alive.
|
|
635
|
-
return err?.code === "ESRCH";
|
|
636
|
-
}
|
|
660
|
+
await fs.rm(tombstone, { force: true });
|
|
661
|
+
return true;
|
|
637
662
|
}
|
|
638
663
|
|
|
639
664
|
async function runPackageManager(cwd: string, args: string[]): Promise<void> {
|
|
@@ -1043,6 +1068,10 @@ export const __testing__ = {
|
|
|
1043
1068
|
resolveExportTargetValue,
|
|
1044
1069
|
tryResolveFile,
|
|
1045
1070
|
computeInstallRoot,
|
|
1071
|
+
withInstallLock,
|
|
1072
|
+
reclaimIfStale,
|
|
1073
|
+
LOCK_STALE_MS,
|
|
1074
|
+
LOCK_HEARTBEAT_MS,
|
|
1046
1075
|
EXPORTS_MAX_DEPTH,
|
|
1047
1076
|
DEFAULT_RESOLVER_CONDITIONS,
|
|
1048
1077
|
REALM_COLLAPSE_NAMES,
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import { RuntimeError } from "@telorun/sdk";
|
|
9
9
|
import {
|
|
10
10
|
controllerBearingAncestor,
|
|
11
|
+
effectiveAuthorSchema,
|
|
11
12
|
hasOwnControllerOrTemplate,
|
|
12
13
|
inheritedCapability,
|
|
13
14
|
type DefResolver,
|
|
@@ -56,6 +57,28 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
56
57
|
return definingCtx.getDefinition?.(canonical) ?? definingCtx.getDefinition?.(kind);
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
// Stamp the inheritance-resolved author schema, mirroring the capability
|
|
61
|
+
// stamping below: without `base:`, an `extends` child is authored against
|
|
62
|
+
// merge(parent, own), so a field the parent declares is legal on the child.
|
|
63
|
+
// Every consumer validating a resource against `definition.schema` must see
|
|
64
|
+
// that merged view — otherwise the analyzer accepts an inherited field and
|
|
65
|
+
// the kernel rejects it at create(). Shares `effectiveAuthorSchema` with the
|
|
66
|
+
// analyzer so `telo check` and the runtime cannot drift.
|
|
67
|
+
if (this.resource.extends) {
|
|
68
|
+
if (!resolveDef(this.resource.extends)) {
|
|
69
|
+
// Parent not loaded yet — defer rather than silently merging nothing,
|
|
70
|
+
// which would cache a schema missing every inherited field.
|
|
71
|
+
throw new RuntimeError(
|
|
72
|
+
"ERR_LOCAL_REF_PENDING",
|
|
73
|
+
`Telo.Definition '${this.resource.metadata.name}': 'extends' target '${this.resource.extends}' is not loaded yet.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
this.resource.schema = effectiveAuthorSchema(
|
|
77
|
+
this.resource as ResourceDefinitionManifest,
|
|
78
|
+
resolveDef,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
// Inherited-controller delegation: a definition that `extends` a concrete
|
|
60
83
|
// kind, declares no own `controllers:` / template body, inherits the parent
|
|
61
84
|
// controller by delegation and maps its config via `base:`.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { hostEnv } from "../host-env.js";
|
|
5
|
+
|
|
6
|
+
/** Egress policy for transport fetches, from `TELO_EGRESS`:
|
|
7
|
+
* - unset / `open` — no restriction (the default; a developer machine).
|
|
8
|
+
* - `public-only` — refuse any host that is, or resolves to, a private,
|
|
9
|
+
* loopback, link-local, or carrier-grade-NAT address.
|
|
10
|
+
*
|
|
11
|
+
* Built for deployments whose transports fetch attacker-suppliable refs — the
|
|
12
|
+
* discovery hub's tracker is the first: a registered `oci://10.0.0.5/…` or
|
|
13
|
+
* `https://169.254.169.254/…` ref must not become a request into the hub's
|
|
14
|
+
* own network. A guardrail, not isolation: it checks the name a fetch starts
|
|
15
|
+
* at, so redirect hops are not re-checked, and the check-then-fetch gap is
|
|
16
|
+
* open to DNS rebinding (a hostile resolver can answer public for the check
|
|
17
|
+
* and private for the fetch — the classic bypass for this guard shape).
|
|
18
|
+
* Network-level egress policy on the deployment is the actual boundary;
|
|
19
|
+
* this guard is defense-in-depth, not a substitute. */
|
|
20
|
+
export class EgressDeniedError extends Error {
|
|
21
|
+
constructor(host: string, address: string) {
|
|
22
|
+
super(
|
|
23
|
+
`Egress to '${host}' denied: it resolves to the non-public address ${address} ` +
|
|
24
|
+
`(TELO_EGRESS=public-only). Refusing to fetch from private, loopback, ` +
|
|
25
|
+
`link-local, or CGNAT ranges.`,
|
|
26
|
+
);
|
|
27
|
+
this.name = "EgressDeniedError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPrivateIpv4(address: string): boolean {
|
|
32
|
+
const octets = address.split(".").map(Number);
|
|
33
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o))) return true; // malformed → deny
|
|
34
|
+
const [a, b] = octets;
|
|
35
|
+
return (
|
|
36
|
+
a === 0 || // "this network"
|
|
37
|
+
a === 10 ||
|
|
38
|
+
a === 127 || // loopback
|
|
39
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
|
|
40
|
+
(a === 169 && b === 254) || // link-local (incl. cloud metadata)
|
|
41
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
42
|
+
(a === 192 && b === 168)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPrivateIpv6(address: string): boolean {
|
|
47
|
+
const lower = address.toLowerCase();
|
|
48
|
+
// IPv4-mapped (::ffff:a.b.c.d) — judge the embedded IPv4.
|
|
49
|
+
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
50
|
+
if (mapped) return isPrivateIpv4(mapped[1]);
|
|
51
|
+
return (
|
|
52
|
+
lower === "::" ||
|
|
53
|
+
lower === "::1" || // loopback
|
|
54
|
+
lower.startsWith("fc") || // unique-local fc00::/7
|
|
55
|
+
lower.startsWith("fd") ||
|
|
56
|
+
lower.startsWith("fe8") || // link-local fe80::/10
|
|
57
|
+
lower.startsWith("fe9") ||
|
|
58
|
+
lower.startsWith("fea") ||
|
|
59
|
+
lower.startsWith("feb")
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when `address` (an IP literal) is not publicly routable. */
|
|
64
|
+
export function isPrivateAddress(address: string): boolean {
|
|
65
|
+
const family = isIP(address);
|
|
66
|
+
if (family === 4) return isPrivateIpv4(address);
|
|
67
|
+
if (family === 6) return isPrivateIpv6(address);
|
|
68
|
+
return true; // not an IP literal → caller passed garbage; deny
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function policyActive(): boolean {
|
|
72
|
+
// Read through `hostEnv()` like every kernel TELO_* read: `boot()` replaces
|
|
73
|
+
// `process.env` with the guardrail proxy that hides manifest-declared keys,
|
|
74
|
+
// so a manifest binding `env: TELO_EGRESS` must not be able to silently
|
|
75
|
+
// switch this security control off.
|
|
76
|
+
return (hostEnv().TELO_EGRESS ?? "").toLowerCase() === "public-only";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Assert `hostOrUrl` (a `host[:port]` or a full URL) may be fetched under the
|
|
80
|
+
* active egress policy. No-op unless `TELO_EGRESS=public-only`. An IP-literal
|
|
81
|
+
* host is judged directly; a hostname is resolved and every returned address
|
|
82
|
+
* must be public. Throws {@link EgressDeniedError}; DNS failure surfaces as
|
|
83
|
+
* the underlying error (never a silent pass). */
|
|
84
|
+
export async function assertPublicEgress(hostOrUrl: string): Promise<void> {
|
|
85
|
+
if (!policyActive()) return;
|
|
86
|
+
let hostname = hostOrUrl;
|
|
87
|
+
if (hostOrUrl.includes("://")) {
|
|
88
|
+
hostname = new URL(hostOrUrl).hostname;
|
|
89
|
+
} else {
|
|
90
|
+
// Bare host[:port] — URL parsing handles IPv6 brackets and ports.
|
|
91
|
+
hostname = new URL(`https://${hostOrUrl}`).hostname;
|
|
92
|
+
}
|
|
93
|
+
// URL wraps IPv6 literals in brackets; strip for isIP/lookup.
|
|
94
|
+
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
95
|
+
if (isIP(bare)) {
|
|
96
|
+
if (isPrivateAddress(bare)) throw new EgressDeniedError(bare, bare);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const addresses = await lookup(bare, { all: true, verbatim: true });
|
|
100
|
+
for (const { address } of addresses) {
|
|
101
|
+
if (isPrivateAddress(address)) throw new EgressDeniedError(bare, address);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import { assertPublicEgress } from "../egress-guard.js";
|
|
3
4
|
import { resolveDockerCredential } from "./docker-credentials.js";
|
|
4
5
|
|
|
5
6
|
export const TELO_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
|
|
@@ -37,6 +38,17 @@ function sha256Hex(bytes: Uint8Array): string {
|
|
|
37
38
|
return createHash("sha256").update(bytes).digest("hex");
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** Resolve the `rel="next"` target of a `Link` header against the registry
|
|
42
|
+
* origin, or `null` when there is no next page. */
|
|
43
|
+
function nextPageUrl(linkHeader: string | null, origin: string): string | null {
|
|
44
|
+
if (!linkHeader) return null;
|
|
45
|
+
for (const part of linkHeader.split(",")) {
|
|
46
|
+
const m = part.match(/<([^>]+)>\s*;[^,]*rel="?next"?/i);
|
|
47
|
+
if (m) return new URL(m[1], origin).href;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
/** Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."` header. */
|
|
41
53
|
function parseBearerChallenge(header: string): Record<string, string> {
|
|
42
54
|
const out: Record<string, string> = {};
|
|
@@ -82,6 +94,9 @@ export class OciClient {
|
|
|
82
94
|
init: RequestInit,
|
|
83
95
|
scope: string,
|
|
84
96
|
): Promise<Response> {
|
|
97
|
+
// Registry refs are attacker-suppliable once public registration exists —
|
|
98
|
+
// refuse non-public hosts under TELO_EGRESS=public-only (no-op otherwise).
|
|
99
|
+
await assertPublicEgress(url);
|
|
85
100
|
const withToken = (token?: string): RequestInit => {
|
|
86
101
|
const headers = new Headers(init.headers);
|
|
87
102
|
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
@@ -107,6 +122,10 @@ export class OciClient {
|
|
|
107
122
|
private async fetchToken(challenge: string, scope: string): Promise<string | null> {
|
|
108
123
|
const params = parseBearerChallenge(challenge);
|
|
109
124
|
if (!params.realm) return null;
|
|
125
|
+
// The token realm comes from the registry's own WWW-Authenticate header —
|
|
126
|
+
// an attacker-controlled registry could point it anywhere, so it is
|
|
127
|
+
// egress-checked like any other host.
|
|
128
|
+
await assertPublicEgress(params.realm);
|
|
110
129
|
const tokenUrl = new URL(params.realm);
|
|
111
130
|
if (params.service) tokenUrl.searchParams.set("service", params.service);
|
|
112
131
|
tokenUrl.searchParams.set("scope", params.scope || scope);
|
|
@@ -147,16 +166,58 @@ export class OciClient {
|
|
|
147
166
|
return Buffer.from(await res.arrayBuffer());
|
|
148
167
|
}
|
|
149
168
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
/** Content-identity digest of the manifest a reference resolves to, via a
|
|
170
|
+
* HEAD request — no blob download. Falls back to hashing the manifest body
|
|
171
|
+
* when the registry omits `Docker-Content-Digest`. `null` when the
|
|
172
|
+
* reference does not exist. */
|
|
173
|
+
async headManifest(reference: string): Promise<string | null> {
|
|
174
|
+
const url = `${this.base()}/manifests/${reference}`;
|
|
175
|
+
const head = await this.authedFetch(
|
|
176
|
+
url,
|
|
177
|
+
{ method: "HEAD", headers: { accept: MANIFEST_ACCEPT } },
|
|
178
|
+
this.pullScope(),
|
|
179
|
+
);
|
|
180
|
+
await head.text().catch(() => {});
|
|
181
|
+
if (head.status === 404) return null;
|
|
182
|
+
if (!head.ok) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`OCI head manifest ${this.repo}:${reference} on ${this.host} failed: ${head.status} ${head.statusText}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const digest = head.headers.get("docker-content-digest");
|
|
188
|
+
if (digest) return digest;
|
|
189
|
+
|
|
190
|
+
const res = await this.authedFetch(url, { headers: { accept: MANIFEST_ACCEPT } }, this.pullScope());
|
|
191
|
+
if (res.status === 404) return null;
|
|
153
192
|
if (!res.ok) {
|
|
154
193
|
throw new Error(
|
|
155
|
-
`OCI
|
|
194
|
+
`OCI pull manifest ${this.repo}:${reference} on ${this.host} failed: ${res.status} ${res.statusText}`,
|
|
156
195
|
);
|
|
157
196
|
}
|
|
158
|
-
|
|
159
|
-
|
|
197
|
+
return `sha256:${sha256Hex(new Uint8Array(await res.arrayBuffer()))}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** All tags, following the distribution spec's pagination (`Link: …;
|
|
201
|
+
* rel="next"` with `last=` cursors) so a many-versioned repo enumerates
|
|
202
|
+
* fully — registries cap a single page (Docker Hub at 100). */
|
|
203
|
+
async listTags(): Promise<string[]> {
|
|
204
|
+
const tags: string[] = [];
|
|
205
|
+
let url: string | null = `${this.base()}/tags/list?n=1000`;
|
|
206
|
+
const seen = new Set<string>();
|
|
207
|
+
while (url && !seen.has(url)) {
|
|
208
|
+
seen.add(url);
|
|
209
|
+
const res: Response = await this.authedFetch(url, {}, this.pullScope());
|
|
210
|
+
if (res.status === 404) return tags;
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`OCI list tags for ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const body = (await res.json()) as { tags?: string[] | null };
|
|
217
|
+
if (Array.isArray(body.tags)) tags.push(...body.tags);
|
|
218
|
+
url = nextPageUrl(res.headers.get("link"), `https://${this.host}`);
|
|
219
|
+
}
|
|
220
|
+
return tags;
|
|
160
221
|
}
|
|
161
222
|
|
|
162
223
|
/** Upload `bytes` as a blob (skipped when already present), returning its
|
|
@@ -1,53 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/** A parsed `oci://host/repo@reference` module ref.
|
|
6
|
-
*
|
|
7
|
-
* - `host` — the registry host (`ghcr.io`, `123.dkr.ecr.us-east-1.amazonaws.com`).
|
|
8
|
-
* - `repo` — the repository path (`aws/telo-s3`), possibly multi-segment.
|
|
9
|
-
* - `reference` — a tag (`1.2.0`) or a digest (`sha256:...`); the OCI address.
|
|
10
|
-
* - `integrity` — Telo's inline `sha256-<base64url>` hash when the ref is pinned
|
|
11
|
-
* (authoritative across transports; the OCI digest is only corroborating). */
|
|
12
|
-
export interface ParsedOciRef {
|
|
13
|
-
host: string;
|
|
14
|
-
repo: string;
|
|
15
|
-
reference: string;
|
|
16
|
-
integrity?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** True when `ref` uses the `oci://` scheme (integrity fragment tolerated). */
|
|
20
|
-
export function isOciRef(ref: string): boolean {
|
|
21
|
-
return splitIntegrity(ref).base.startsWith(OCI_SCHEME);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Parse `oci://host/repo@reference[#sha256-...]`. Throws on a malformed ref.
|
|
25
|
-
* A `reference` may be a tag or a `sha256:` digest; when absent (`@` omitted)
|
|
26
|
-
* it defaults to `latest`, matching OCI tooling. */
|
|
27
|
-
export function parseOciRef(ref: string): ParsedOciRef {
|
|
28
|
-
const { base, integrity } = splitIntegrity(ref);
|
|
29
|
-
if (!base.startsWith(OCI_SCHEME)) {
|
|
30
|
-
throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
|
|
31
|
-
}
|
|
32
|
-
const rest = base.slice(OCI_SCHEME.length);
|
|
33
|
-
const slash = rest.indexOf("/");
|
|
34
|
-
if (slash <= 0) {
|
|
35
|
-
throw new Error(`Invalid OCI reference '${ref}', missing repository path after host`);
|
|
36
|
-
}
|
|
37
|
-
const host = rest.slice(0, slash);
|
|
38
|
-
let repoAndRef = rest.slice(slash + 1);
|
|
39
|
-
|
|
40
|
-
let reference = "latest";
|
|
41
|
-
const at = repoAndRef.lastIndexOf("@");
|
|
42
|
-
// A digest reference is `repo@sha256:...` — the `@` before `sha256:` is the
|
|
43
|
-
// separator, not part of the repo. A tag reference has no `@`.
|
|
44
|
-
if (at > 0) {
|
|
45
|
-
reference = repoAndRef.slice(at + 1);
|
|
46
|
-
repoAndRef = repoAndRef.slice(0, at);
|
|
47
|
-
}
|
|
48
|
-
const repo = repoAndRef;
|
|
49
|
-
if (!host || !repo || !reference) {
|
|
50
|
-
throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
|
|
51
|
-
}
|
|
52
|
-
return { host, repo, reference, integrity };
|
|
53
|
-
}
|
|
1
|
+
// The OCI ref grammar is pure string parsing shared with the browser-safe
|
|
2
|
+
// manifest-cache key helper, so it lives in `@telorun/analyzer`; this shim
|
|
3
|
+
// keeps the kernel-internal import sites stable.
|
|
4
|
+
export { OCI_SCHEME, isOciRef, parseOciRef, type ParsedOciRef } from "@telorun/analyzer";
|
|
@@ -125,6 +125,11 @@ export class OciTransport implements Transport {
|
|
|
125
125
|
return tags;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
async digest(ref: string): Promise<string | null> {
|
|
129
|
+
const { host, repo, reference } = parseOciRef(ref);
|
|
130
|
+
return new OciClient(host, repo).headManifest(reference);
|
|
131
|
+
}
|
|
132
|
+
|
|
128
133
|
async fetchArtifact(ref: string): Promise<FetchedArtifact> {
|
|
129
134
|
return pullVerified(ref);
|
|
130
135
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
RegistrySource,
|
|
6
6
|
isRegistryRef,
|
|
7
7
|
parseModuleRef,
|
|
8
|
+
sha256Base64Url,
|
|
8
9
|
splitIntegrity,
|
|
9
10
|
type ManifestSource,
|
|
10
11
|
} from "@telorun/analyzer";
|
|
@@ -13,6 +14,7 @@ import { createHash } from "crypto";
|
|
|
13
14
|
import { computeFilesIntegrity, injectFilesIntegrity } from "../bundle/files-integrity.js";
|
|
14
15
|
import { readOwnerManifest } from "../bundle/module-manifest.js";
|
|
15
16
|
import { makeTarGz, readTarGz, toPayloadFiles } from "../bundle/tar.js";
|
|
17
|
+
import { assertPublicEgress } from "./egress-guard.js";
|
|
16
18
|
import type {
|
|
17
19
|
FetchedArtifact,
|
|
18
20
|
PublishBundle,
|
|
@@ -88,7 +90,12 @@ export class RegistryTransport implements Transport {
|
|
|
88
90
|
this.httpSource.supports(ref) ? this.httpSource : this.registrySource;
|
|
89
91
|
this.source = {
|
|
90
92
|
supports: (url) => this.supports(url),
|
|
91
|
-
read: (url) =>
|
|
93
|
+
read: async (url) => {
|
|
94
|
+
// The browser-safe sources do the fetch; the Node-side egress policy
|
|
95
|
+
// is enforced here, on the host the read will actually hit.
|
|
96
|
+
await assertPublicEgress(this.httpSource.supports(url) ? url : this.registryUrl);
|
|
97
|
+
return pick(url).read(url);
|
|
98
|
+
},
|
|
92
99
|
resolveRelative: (base, relative) => pick(base).resolveRelative(base, relative),
|
|
93
100
|
};
|
|
94
101
|
}
|
|
@@ -151,6 +158,7 @@ export class RegistryTransport implements Transport {
|
|
|
151
158
|
if (!isRegistryRef(ref)) return null;
|
|
152
159
|
const { modulePath } = parseModuleRef(ref);
|
|
153
160
|
const url = `${this.registryUrl.replace(/\/+$/, "")}/${modulePath}`;
|
|
161
|
+
await assertPublicEgress(url);
|
|
154
162
|
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
155
163
|
if (res.status === 404) return null;
|
|
156
164
|
if (!res.ok) {
|
|
@@ -160,6 +168,30 @@ export class RegistryTransport implements Transport {
|
|
|
160
168
|
return Array.isArray(body.versions) ? body.versions : [];
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
async digest(ref: string): Promise<string | null> {
|
|
172
|
+
// Mirrors the sources' fetch-URL derivation: a direct URL points at (or
|
|
173
|
+
// contains) the YAML file; a bare registry ref folds into the registry
|
|
174
|
+
// layout. The digest is Telo's canonical hash over the `telo.yaml` bytes.
|
|
175
|
+
const { base } = splitIntegrity(ref);
|
|
176
|
+
let fetchUrl: string;
|
|
177
|
+
if (base.startsWith("http://") || base.startsWith("https://")) {
|
|
178
|
+
fetchUrl = base.includes(".yaml") ? base : `${base}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
179
|
+
} else if (isRegistryRef(ref)) {
|
|
180
|
+
const { modulePath, version } = parseModuleRef(ref);
|
|
181
|
+
fetchUrl = `${this.registryUrl.replace(/\/+$/, "")}/${modulePath}/${version}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
182
|
+
} else {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
await assertPublicEgress(fetchUrl);
|
|
186
|
+
const res = await fetch(fetchUrl);
|
|
187
|
+
if (res.status === 404) return null;
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
throw new Error(`Registry returned ${res.status} ${res.statusText} for ${fetchUrl}`);
|
|
190
|
+
}
|
|
191
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
192
|
+
return `sha256-${await sha256Base64Url(bytes)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
163
195
|
async fetchArtifact(ref: string): Promise<FetchedArtifact> {
|
|
164
196
|
// `read` verifies the manifest bytes against the inline `#sha256-...` hash.
|
|
165
197
|
const { text: manifest, source } = await this.source.read(ref);
|
|
@@ -168,6 +200,7 @@ export class RegistryTransport implements Transport {
|
|
|
168
200
|
|
|
169
201
|
// The payload rides beside the manifest as `module.tar.gz`.
|
|
170
202
|
const tarUrl = source.replace(/\/telo\.yaml$/, "/module.tar.gz");
|
|
203
|
+
await assertPublicEgress(tarUrl);
|
|
171
204
|
const res = await fetch(tarUrl);
|
|
172
205
|
if (!res.ok) {
|
|
173
206
|
throw new Error(`could not fetch bundle ${tarUrl}: ${res.status} ${res.statusText}`);
|
|
@@ -47,6 +47,12 @@ export class TransportRegistry {
|
|
|
47
47
|
return this.require(ref).fetchArtifact(ref);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Cheap content-identity digest for `ref` via its owning transport; `null`
|
|
51
|
+
* when the version does not exist. Throws when no transport owns the ref. */
|
|
52
|
+
digest(ref: string): Promise<string | null> {
|
|
53
|
+
return this.require(ref).digest(ref);
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
/** Publish `bundle` to `destination` via the transport its scheme selects.
|
|
51
57
|
* Throws when no transport owns the destination. */
|
|
52
58
|
publish(
|
|
@@ -89,6 +89,16 @@ export interface Transport {
|
|
|
89
89
|
* the out-of-band bundle fetch that used to sit outside the source chain. */
|
|
90
90
|
fetchArtifact(ref: string): Promise<FetchedArtifact>;
|
|
91
91
|
|
|
92
|
+
/** Cheap content-identity digest of what `ref` currently resolves to — no
|
|
93
|
+
* payload download. Opaque and transport-specific (OCI: the image manifest's
|
|
94
|
+
* `sha256:<hex>` content digest; HTTP: `sha256-<base64url>` over the
|
|
95
|
+
* `telo.yaml` bytes), so compare for equality only, never across transports.
|
|
96
|
+
* Returns `null` when the version does not exist. Version content
|
|
97
|
+
* immutability is a convention no transport enforces — a tag can be
|
|
98
|
+
* re-pushed to different bytes — so the discovery tracker records this
|
|
99
|
+
* digest per version and re-checks it on every track. */
|
|
100
|
+
digest(ref: string): Promise<string | null>;
|
|
101
|
+
|
|
92
102
|
/** Push `bundle` to `destination` (a base ref / repo whose scheme this
|
|
93
103
|
* transport owns), pinning the payload and writing the transport-native
|
|
94
104
|
* artifact shape. Throws on failure. Used by `telo publish`. */
|