@runuai/host 0.9.148 → 0.9.149
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/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +391 -69
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +939 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2219 -15
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets proxy as the runner runs it (ADR-153 §3): the persistent CA,
|
|
3
|
+
* the leaf cache, per-task placeholders, the engine and its audit log,
|
|
4
|
+
* composed once per runner process.
|
|
5
|
+
*
|
|
6
|
+
* What a task may use comes from outside, through TaskProxyPolicySource:
|
|
7
|
+
* the declarations work resolves each task's effective policy (binding ∩
|
|
8
|
+
* schema observation) and the current real values. This module only turns
|
|
9
|
+
* that into what the engine consults per request, so a policy change (a key
|
|
10
|
+
* withheld, a domain dropped) applies to the next request with nothing to
|
|
11
|
+
* invalidate here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
|
+
import { promises as dns } from "node:dns";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { BlockList, isIP } from "node:net";
|
|
18
|
+
import { networkInterfaces } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { createAuditWriter } from "./audit";
|
|
22
|
+
import { createLeafCertificateCache, type LeafCertificateCache } from "./ca";
|
|
23
|
+
import { loadOrCreateCertificateAuthorities, type CertificateAuthorities, type SealedText } from "./ca-store";
|
|
24
|
+
import { createSecretsProxy, type ProxyKeyRef, type ProxyTaskState, type SecretsProxy } from "./engine";
|
|
25
|
+
import { isProxiableDomain } from "./hostname";
|
|
26
|
+
import type { TaskEnvHead } from "../project-env-task-policy";
|
|
27
|
+
import { createPlaceholderStore, type PlaceholderShape } from "./placeholders";
|
|
28
|
+
|
|
29
|
+
const HOUR_MS = 60 * 60 * 1_000;
|
|
30
|
+
/** How often the runner reviews its CA dates and retries a failed bundle
|
|
31
|
+
* delivery, so an idle runner mints the successor (and gets it to running
|
|
32
|
+
* tasks) when its window opens, not at the next handshake or task start,
|
|
33
|
+
* which it may not see for months. */
|
|
34
|
+
const TRUST_REVIEW_EVERY_MS = HOUR_MS;
|
|
35
|
+
|
|
36
|
+
/** One key a task currently has in `proxy` mode (effective policy). */
|
|
37
|
+
export interface ProxiedKeyPolicy {
|
|
38
|
+
projectId: string;
|
|
39
|
+
key: string;
|
|
40
|
+
domains: readonly string[];
|
|
41
|
+
/** The repo's `@placeholder` shape, if it declared one. */
|
|
42
|
+
placeholder?: PlaceholderShape | null;
|
|
43
|
+
/** The head the key's value must have (ADR-153 §4). A source that knows
|
|
44
|
+
* heads gives the value only at it, so a value read after the head moved
|
|
45
|
+
* is withheld until the engine learns the new one. */
|
|
46
|
+
head?: TaskEnvHead;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TaskProxyPolicySource {
|
|
50
|
+
/** The task's keys in proxy mode right now, or null when the task is not
|
|
51
|
+
* (or no longer) served — an unknown, stopped or revoked generation. */
|
|
52
|
+
proxiedKeys(taskId: string): readonly ProxiedKeyPolicy[] | null;
|
|
53
|
+
/** The key's current real value, read per request; null withholds. */
|
|
54
|
+
realValue(taskId: string, ref: { projectId: string; key: string; head?: TaskEnvHead }): string | null;
|
|
55
|
+
/** Every name the task does not get as its value (proxied or withheld),
|
|
56
|
+
* when the source knows more than its proxied keys. */
|
|
57
|
+
notDelivered?(taskId: string): readonly string[] | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** ADR-153's kill switch: `UAI_SECRETS_PROXY=off` stops the proxy, and the
|
|
61
|
+
* policy then withholds proxied keys (never delivers them). */
|
|
62
|
+
export function secretsProxyEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
63
|
+
return env.UAI_SECRETS_PROXY?.trim().toLowerCase() !== "off";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Upstream resolution for a runner whose /etc/hosts the task does NOT share
|
|
67
|
+
* (Docker and Apple hosts): the OS resolver, which also honours the
|
|
68
|
+
* platform's scoped and VPN resolvers. */
|
|
69
|
+
export async function resolveWithSystem(hostname: string): Promise<string[]> {
|
|
70
|
+
const answers = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
71
|
+
return answers.map((answer) => answer.address);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where a machine's real (uplink) DNS servers are listed: systemd-resolved's
|
|
75
|
+
* own upstream list first, then the classic file. */
|
|
76
|
+
export const UPLINK_RESOLV_FILES: readonly string[] = ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"];
|
|
77
|
+
|
|
78
|
+
/** This host's own interface addresses: a resolver listening on one of them
|
|
79
|
+
* (dnsmasq on the private address) is as local as one on loopback. */
|
|
80
|
+
function interfaceAddresses(): string[] {
|
|
81
|
+
return Object.values(networkInterfaces())
|
|
82
|
+
.flat()
|
|
83
|
+
.flatMap((entry) => (entry === undefined ? [] : [entry.address]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Matches a server on this host in any textual form — `0:0:0:0:0:0:0:1`,
|
|
87
|
+
* `::ffff:7f00:1` — since BlockList compares parsed addresses. IPv4 and
|
|
88
|
+
* IPv6 rules stay in separate lists (see docs/agent-notes.md). */
|
|
89
|
+
function localServerMatcher(localAddresses: readonly string[]): (address: string) => boolean {
|
|
90
|
+
const v4 = new BlockList();
|
|
91
|
+
v4.addSubnet("127.0.0.0", 8, "ipv4");
|
|
92
|
+
v4.addSubnet("0.0.0.0", 8, "ipv4");
|
|
93
|
+
const v6 = new BlockList();
|
|
94
|
+
// ::, ::1 and the IPv4-compatible forms of the above.
|
|
95
|
+
v6.addSubnet("::", 96, "ipv6");
|
|
96
|
+
v6.addSubnet("::ffff:127.0.0.0", 104, "ipv6");
|
|
97
|
+
v6.addSubnet("::ffff:0.0.0.0", 104, "ipv6");
|
|
98
|
+
for (const raw of localAddresses) {
|
|
99
|
+
const address = raw.split("%")[0] ?? "";
|
|
100
|
+
const family = isIP(address);
|
|
101
|
+
if (family === 4) {
|
|
102
|
+
v4.addAddress(address, "ipv4");
|
|
103
|
+
v6.addAddress(`::ffff:${address}`, "ipv6");
|
|
104
|
+
} else if (family === 6) {
|
|
105
|
+
v6.addAddress(address, "ipv6");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return (raw) => {
|
|
109
|
+
const address = raw.split("%")[0] ?? "";
|
|
110
|
+
const family = isIP(address);
|
|
111
|
+
// Anything unparsable counts as local: it is never queried.
|
|
112
|
+
if (family === 4) return v4.check(address, "ipv4");
|
|
113
|
+
if (family === 6) return v6.check(address, "ipv6");
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The machine's uplink DNS servers: the `nameserver`s of the first file in
|
|
120
|
+
* `files` that lists any server not on this host. A server on this host never
|
|
121
|
+
* counts — systemd-resolved's 127.0.0.53 stub, or dnsmasq, answers from
|
|
122
|
+
* /etc/hosts, which on a machine carries the task's pins. Empty when there is
|
|
123
|
+
* no uplink server at all.
|
|
124
|
+
*/
|
|
125
|
+
export function uplinkDnsServers(
|
|
126
|
+
files: readonly string[] = UPLINK_RESOLV_FILES,
|
|
127
|
+
localAddresses: readonly string[] = interfaceAddresses(),
|
|
128
|
+
): string[] {
|
|
129
|
+
const isLocalServer = localServerMatcher(localAddresses);
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
let text: string;
|
|
132
|
+
try {
|
|
133
|
+
text = readFileSync(file, "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const servers: string[] = [];
|
|
138
|
+
for (const line of text.split("\n")) {
|
|
139
|
+
const [keyword, address] = line.trim().split(/\s+/);
|
|
140
|
+
if (keyword !== "nameserver" || address === undefined || isIP(address) === 0) continue;
|
|
141
|
+
if (!isLocalServer(address) && !servers.includes(address)) servers.push(address);
|
|
142
|
+
}
|
|
143
|
+
if (servers.length > 0) return servers;
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Upstream resolution for a runner that shares /etc/hosts with the task (a
|
|
149
|
+
* machine's sidecar): DNS queried directly, so the task's pins — which
|
|
150
|
+
* point proxied names at loopback — can never answer for the upstream.
|
|
151
|
+
* The servers are re-read per lookup (DHCP can change them); none, which
|
|
152
|
+
* would leave only a stub that reads /etc/hosts, fails the lookup closed. */
|
|
153
|
+
export function resolverIgnoringHostsFile(
|
|
154
|
+
servers: () => readonly string[] = () => uplinkDnsServers(),
|
|
155
|
+
): (hostname: string) => Promise<string[]> {
|
|
156
|
+
return async (hostname) => {
|
|
157
|
+
const list = servers();
|
|
158
|
+
if (list.length === 0) throw new Error("no uplink DNS server: refusing to resolve through a local stub");
|
|
159
|
+
const resolver = new dns.Resolver();
|
|
160
|
+
resolver.setServers([...list]);
|
|
161
|
+
const [v4, v6] = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]);
|
|
162
|
+
const addresses = [
|
|
163
|
+
...(v4.status === "fulfilled" ? v4.value : []),
|
|
164
|
+
...(v6.status === "fulfilled" ? v6.value : []),
|
|
165
|
+
];
|
|
166
|
+
if (addresses.length === 0) throw new Error("no A or AAAA records");
|
|
167
|
+
return addresses;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface SecretsProxyRuntimeDeps {
|
|
172
|
+
/** The runner's data dir: the CA and placeholders live under
|
|
173
|
+
* `secrets-proxy/`, the audit log under `logs/`. */
|
|
174
|
+
dataDir: string;
|
|
175
|
+
policy: TaskProxyPolicySource;
|
|
176
|
+
seal(plaintext: string): SealedText;
|
|
177
|
+
open(ct: Buffer, nonce: Buffer): string;
|
|
178
|
+
/** Required, because the right answer depends on where the runner sits:
|
|
179
|
+
* `resolveWithSystem` on a Docker or Apple host, `resolverIgnoringHostsFile()`
|
|
180
|
+
* in a machine's sidecar, whose /etc/hosts carries the task's pins. */
|
|
181
|
+
resolveHost: (hostname: string) => Promise<string[]>;
|
|
182
|
+
now?: () => number;
|
|
183
|
+
log?: Pick<Console, "warn">;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface SecretsProxyRuntime {
|
|
187
|
+
readonly proxy: SecretsProxy;
|
|
188
|
+
/** What every container or VM start installs into the task's trust store:
|
|
189
|
+
* the signing CA and, inside its last 90 days, its successor (PEM). */
|
|
190
|
+
caBundlePem(): string;
|
|
191
|
+
/** The runner's hook for keeping running tasks' trust current, level-
|
|
192
|
+
* triggered: the listener installs the bundle it is given into every
|
|
193
|
+
* running task and resolves once they all have it. It is called at
|
|
194
|
+
* subscription — so a successor minted while the runner was down, during
|
|
195
|
+
* its start, or just before a crash still reaches running tasks — and
|
|
196
|
+
* whenever the bundle changes; a delivery that throws or rejects is
|
|
197
|
+
* logged and retried at the next hourly review until one succeeds.
|
|
198
|
+
*
|
|
199
|
+
* Each delivery gets its own signal, aborted if the watcher is
|
|
200
|
+
* unsubscribed or the runtime closes while that delivery runs. A finished
|
|
201
|
+
* delivery's signal never aborts: nothing of it is left to stop, and a
|
|
202
|
+
* handler left on it must not run later, outside the delivery that
|
|
203
|
+
* registered it.
|
|
204
|
+
*
|
|
205
|
+
* Teardown is quiescent: the returned unsubscribe, and close(), abort the
|
|
206
|
+
* delivery under way and resolve only once it has ended, so an old
|
|
207
|
+
* runtime can never install its bundle over a replacement's. A listener
|
|
208
|
+
* must stop promptly when its signal aborts.
|
|
209
|
+
* Once close() has begun, watchTrust delivers nothing. Unsubscribe and
|
|
210
|
+
* close() are always quiescent, so neither may be called from inside a
|
|
211
|
+
* listener (or from work it started while its delivery lasts): there it
|
|
212
|
+
* would wait for itself, or for a delivery waiting on it. Such a call is
|
|
213
|
+
* rejected and changes nothing. */
|
|
214
|
+
watchTrust(listener: (bundlePem: string, signal: AbortSignal) => void | Promise<void>): () => Promise<void>;
|
|
215
|
+
/** Delivers the current bundle to every watcher again, though they all
|
|
216
|
+
* acknowledged it: a task's own install failed, or it was still starting,
|
|
217
|
+
* so the bundle is not everywhere after all. */
|
|
218
|
+
requestTrustDelivery(): void;
|
|
219
|
+
/** The placeholder the /env renderer hands a task for a key the policy
|
|
220
|
+
* serves it in proxy mode; throws for any other key or task. */
|
|
221
|
+
placeholderFor(taskId: string, key: ProxiedKeyPolicy): string;
|
|
222
|
+
/** The canonical domains a task's proxied keys may reach — what its
|
|
223
|
+
* /etc/hosts block and redirect chain are reconciled to. */
|
|
224
|
+
domainsFor(taskId: string): string[];
|
|
225
|
+
/** The task's proxy-mode keys, each with the canonical, proxiable domains
|
|
226
|
+
* it may reach (what its agents are told). */
|
|
227
|
+
proxiedKeysFor(taskId: string): { projectId: string; key: string; domains: string[] }[];
|
|
228
|
+
/** Stop (or any revocation): end the task's connections. Its placeholders
|
|
229
|
+
* stay — they are the task's for its whole life, across stops, resumes and
|
|
230
|
+
* runner restarts — and while the policy source does not serve the task,
|
|
231
|
+
* nothing is served or minted for it. */
|
|
232
|
+
stopTask(taskId: string): void;
|
|
233
|
+
/** Deletion: stopTask, then forget the task's placeholders. Call it once
|
|
234
|
+
* the policy source no longer serves the task, so nothing mints them
|
|
235
|
+
* again. */
|
|
236
|
+
dropTask(taskId: string): void;
|
|
237
|
+
close(): Promise<void>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function proxiableDomains(keys: readonly ProxiedKeyPolicy[]): Set<string> {
|
|
241
|
+
const domains = new Set<string>();
|
|
242
|
+
for (const key of keys) {
|
|
243
|
+
// isProxiableDomain also requires the name to be canonical.
|
|
244
|
+
for (const domain of key.domains) if (isProxiableDomain(domain)) domains.add(domain);
|
|
245
|
+
}
|
|
246
|
+
return domains;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One string per policy snapshot, so derived state is rebuilt only when the
|
|
250
|
+
* policy actually changes. */
|
|
251
|
+
function policySignature(keys: readonly ProxiedKeyPolicy[]): string {
|
|
252
|
+
return JSON.stringify(keys.map((key) => [key.projectId, key.key, [...key.domains], key.placeholder ?? null, key.head ?? null]));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** One trust listener call, while its delivery lasts, linked to the
|
|
256
|
+
* delivery it started inside, if any: a delivery begun from within another
|
|
257
|
+
* replaces the async context, and its end must not make its children look
|
|
258
|
+
* outside the one still running around it. */
|
|
259
|
+
interface DeliveryScope {
|
|
260
|
+
active: boolean;
|
|
261
|
+
readonly parent: DeliveryScope | null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The async context of trust listener calls — of ANY runtime, so a listener
|
|
265
|
+
* of one runtime cannot tear down another whose listener is tearing down
|
|
266
|
+
* the first (during a replacement two are alive). It grants nothing: it only
|
|
267
|
+
* lets such a call be refused instead of deadlocking. Work a listener leaves
|
|
268
|
+
* behind that runs after every enclosing delivery ended is an ordinary
|
|
269
|
+
* caller again. */
|
|
270
|
+
const insideDelivery = new AsyncLocalStorage<DeliveryScope>();
|
|
271
|
+
|
|
272
|
+
function refuseInsideDelivery(call: string): void {
|
|
273
|
+
for (let scope = insideDelivery.getStore() ?? null; scope !== null; scope = scope.parent) {
|
|
274
|
+
if (scope.active) {
|
|
275
|
+
throw new Error(`${call} cannot be called from inside a trust listener: it would wait for a delivery`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function createSecretsProxyRuntime(deps: SecretsProxyRuntimeDeps): SecretsProxyRuntime {
|
|
281
|
+
const dir = join(deps.dataDir, "secrets-proxy");
|
|
282
|
+
const now = deps.now ?? Date.now;
|
|
283
|
+
const log = deps.log ?? console;
|
|
284
|
+
/** A logger that throws, or a reason that cannot be formatted, must never
|
|
285
|
+
* break a handshake or skip a delivery's cleanup. */
|
|
286
|
+
const warn = (message: () => string): void => {
|
|
287
|
+
try {
|
|
288
|
+
log.warn(message());
|
|
289
|
+
} catch {
|
|
290
|
+
// Nothing more can be done with a report that cannot be made.
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const reasonOf = (error: unknown): string => {
|
|
294
|
+
try {
|
|
295
|
+
return error instanceof Error ? error.message : String(error);
|
|
296
|
+
} catch {
|
|
297
|
+
return "an error that could not be formatted";
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const loadAuthorities = (): CertificateAuthorities =>
|
|
301
|
+
loadOrCreateCertificateAuthorities({
|
|
302
|
+
dir,
|
|
303
|
+
seal: deps.seal,
|
|
304
|
+
open: deps.open,
|
|
305
|
+
now,
|
|
306
|
+
log: { warn: (message: string) => warn(() => message) },
|
|
307
|
+
});
|
|
308
|
+
let authorities = loadAuthorities();
|
|
309
|
+
let leaves = createLeafCertificateCache({ ca: authorities.signer, now });
|
|
310
|
+
const bundleOf = (set: CertificateAuthorities): string =>
|
|
311
|
+
set.trusted.map(({ certificatePem }) => (certificatePem.endsWith("\n") ? certificatePem : `${certificatePem}\n`)).join("");
|
|
312
|
+
|
|
313
|
+
interface TrustWatcher {
|
|
314
|
+
readonly listener: (bundlePem: string, signal: AbortSignal) => void | Promise<void>;
|
|
315
|
+
/** Aborted at unsubscribe or close(); nothing starts after it. Internal:
|
|
316
|
+
* the listener never sees it, so nothing is registered on it. */
|
|
317
|
+
readonly stop: AbortController;
|
|
318
|
+
/** The last bundle the listener acknowledged. */
|
|
319
|
+
delivered: string | null;
|
|
320
|
+
/** A delivery was asked for (requestTrustDelivery) after the one under
|
|
321
|
+
* way began: that one may already be past the task that needs it, so
|
|
322
|
+
* another follows even if it is acknowledged. */
|
|
323
|
+
redeliver: boolean;
|
|
324
|
+
/** The delivery under way, if any. Never rejects. */
|
|
325
|
+
inFlight: Promise<void> | null;
|
|
326
|
+
/** That delivery's own signal and scope, so its abort is dispatched
|
|
327
|
+
* inside it. Cleared when it ends: its signal then never aborts. */
|
|
328
|
+
current: { readonly controller: AbortController; readonly scope: DeliveryScope } | null;
|
|
329
|
+
}
|
|
330
|
+
const watchers = new Set<TrustWatcher>();
|
|
331
|
+
/** Every delivery under way, including an unsubscribed watcher's, so
|
|
332
|
+
* close() can wait for all of them. */
|
|
333
|
+
const deliveries = new Set<Promise<void>>();
|
|
334
|
+
/** Set as close() begins: no delivery starts after it. */
|
|
335
|
+
let closed = false;
|
|
336
|
+
|
|
337
|
+
/** The engine's close, shared by every close() call. */
|
|
338
|
+
let engineClosed: Promise<void> | null = null;
|
|
339
|
+
/** Hands the current bundle to a watcher that does not have it yet, never
|
|
340
|
+
* inside a TLS callback and never twice at once. A success that turns out
|
|
341
|
+
* stale (the bundle changed meanwhile) delivers again at once; a failure
|
|
342
|
+
* waits for the next review. */
|
|
343
|
+
const deliver = (watcher: TrustWatcher): void => {
|
|
344
|
+
const bundlePem = bundleOf(authorities);
|
|
345
|
+
const { signal } = watcher.stop;
|
|
346
|
+
if (closed || signal.aborted || watcher.inFlight !== null) return;
|
|
347
|
+
if (watcher.delivered === bundlePem && !watcher.redeliver) return;
|
|
348
|
+
watcher.redeliver = false;
|
|
349
|
+
let acknowledged = false;
|
|
350
|
+
const scope: DeliveryScope = { active: true, parent: insideDelivery.getStore() ?? null };
|
|
351
|
+
const controller = new AbortController();
|
|
352
|
+
const delivery: Promise<void> = Promise.resolve()
|
|
353
|
+
// Checked again here: an unsubscribe or close() may have come between.
|
|
354
|
+
.then(() =>
|
|
355
|
+
signal.aborted ? undefined : insideDelivery.run(scope, () => watcher.listener(bundlePem, controller.signal)),
|
|
356
|
+
)
|
|
357
|
+
.then(
|
|
358
|
+
() => {
|
|
359
|
+
acknowledged = !signal.aborted;
|
|
360
|
+
},
|
|
361
|
+
(error: unknown) => {
|
|
362
|
+
if (!signal.aborted) {
|
|
363
|
+
warn(
|
|
364
|
+
() =>
|
|
365
|
+
"[secrets-proxy] the CA bundle did not reach every running task; retrying at the next review: " +
|
|
366
|
+
reasonOf(error),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
// Unconditional, and the delivery never rejects: close() waits on it.
|
|
372
|
+
.finally(() => {
|
|
373
|
+
scope.active = false;
|
|
374
|
+
watcher.current = null;
|
|
375
|
+
watcher.inFlight = null;
|
|
376
|
+
deliveries.delete(delivery);
|
|
377
|
+
})
|
|
378
|
+
.then(() => {
|
|
379
|
+
if (!acknowledged) return;
|
|
380
|
+
watcher.delivered = bundlePem;
|
|
381
|
+
deliver(watcher);
|
|
382
|
+
})
|
|
383
|
+
.catch(() => undefined);
|
|
384
|
+
watcher.inFlight = delivery;
|
|
385
|
+
watcher.current = { controller, scope };
|
|
386
|
+
deliveries.add(delivery);
|
|
387
|
+
};
|
|
388
|
+
/** Stops a watcher, and aborts its delivery under way, if any. AbortSignal
|
|
389
|
+
* runs its handlers in the context of whoever calls abort(), not the one
|
|
390
|
+
* that registered them: the dispatch happens inside that delivery's scope
|
|
391
|
+
* (active, with its ancestry), so a handler is fenced like the listener
|
|
392
|
+
* that registered it. */
|
|
393
|
+
const abortWatcher = (watcher: TrustWatcher): void => {
|
|
394
|
+
watcher.stop.abort();
|
|
395
|
+
const current = watcher.current;
|
|
396
|
+
if (current !== null) insideDelivery.run(current.scope, () => current.controller.abort());
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** The CA set, reviewed when its dates call for it: a successor joins the
|
|
400
|
+
* bundle inside the signer's last 90 days, and the signer changes only on
|
|
401
|
+
* its last day, to a CA every task started since has installed. */
|
|
402
|
+
const currentAuthorities = (): CertificateAuthorities => {
|
|
403
|
+
// A closed runtime never writes the store again: its replacement, on the
|
|
404
|
+
// same data dir, may already be reviewing it.
|
|
405
|
+
if (closed || now() < authorities.reviewAt) return authorities;
|
|
406
|
+
let next: CertificateAuthorities;
|
|
407
|
+
try {
|
|
408
|
+
next = loadAuthorities();
|
|
409
|
+
} catch (error) {
|
|
410
|
+
// The signer has at least its last day left: keep it, and retry in an
|
|
411
|
+
// hour rather than failing every handshake on a store error.
|
|
412
|
+
warn(() => `[secrets-proxy] could not review the proxy CA: ${reasonOf(error)}`);
|
|
413
|
+
authorities = { ...authorities, reviewAt: now() + HOUR_MS };
|
|
414
|
+
return authorities;
|
|
415
|
+
}
|
|
416
|
+
const before = bundleOf(authorities);
|
|
417
|
+
if (next.signer.certificatePem !== authorities.signer.certificatePem) {
|
|
418
|
+
leaves = createLeafCertificateCache({ ca: next.signer, now });
|
|
419
|
+
}
|
|
420
|
+
authorities = next;
|
|
421
|
+
const after = bundleOf(next);
|
|
422
|
+
if (after !== before) for (const watcher of watchers) deliver(watcher);
|
|
423
|
+
return authorities;
|
|
424
|
+
};
|
|
425
|
+
const reviewTimer = setInterval(() => {
|
|
426
|
+
currentAuthorities();
|
|
427
|
+
// Also retries any delivery that failed.
|
|
428
|
+
for (const watcher of watchers) deliver(watcher);
|
|
429
|
+
}, TRUST_REVIEW_EVERY_MS);
|
|
430
|
+
reviewTimer.unref();
|
|
431
|
+
const signerLeaves: LeafCertificateCache = {
|
|
432
|
+
get authority() {
|
|
433
|
+
return currentAuthorities().signer;
|
|
434
|
+
},
|
|
435
|
+
leafFor(hostname) {
|
|
436
|
+
currentAuthorities();
|
|
437
|
+
return leaves.leafFor(hostname);
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
const placeholders = createPlaceholderStore({ dir });
|
|
441
|
+
|
|
442
|
+
const mint = (taskId: string, key: ProxiedKeyPolicy): string => {
|
|
443
|
+
// The placeholder store belongs to the replacement once close() began.
|
|
444
|
+
if (closed) throw new Error("the secrets proxy runtime is closed");
|
|
445
|
+
return placeholders.placeholderFor({
|
|
446
|
+
taskId,
|
|
447
|
+
projectId: key.projectId,
|
|
448
|
+
key: key.key,
|
|
449
|
+
shape: key.placeholder ?? null,
|
|
450
|
+
});
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/** The engine asks for a task's state on every accept, server name and
|
|
454
|
+
* request; it is derived once per policy snapshot. */
|
|
455
|
+
const derived = new Map<string, { signature: string; state: ProxyTaskState }>();
|
|
456
|
+
const taskState = (taskId: string): ProxyTaskState | null => {
|
|
457
|
+
const keys = deps.policy.proxiedKeys(taskId);
|
|
458
|
+
if (keys === null) {
|
|
459
|
+
derived.delete(taskId);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
const signature = policySignature(keys);
|
|
463
|
+
const cached = derived.get(taskId);
|
|
464
|
+
if (cached !== undefined && cached.signature === signature) return cached.state;
|
|
465
|
+
const byPlaceholder = new Map<string, ProxyKeyRef>();
|
|
466
|
+
for (const key of keys) {
|
|
467
|
+
byPlaceholder.set(mint(taskId, key), {
|
|
468
|
+
projectId: key.projectId,
|
|
469
|
+
key: key.key,
|
|
470
|
+
domains: [...key.domains],
|
|
471
|
+
...(key.head !== undefined ? { head: key.head } : {}),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const domains = proxiableDomains(keys);
|
|
475
|
+
const placeholderList = [...byPlaceholder.keys()];
|
|
476
|
+
const state: ProxyTaskState = {
|
|
477
|
+
domains: () => domains,
|
|
478
|
+
lookup: (placeholder) => byPlaceholder.get(placeholder) ?? null,
|
|
479
|
+
placeholders: () => placeholderList,
|
|
480
|
+
// Values are never cached: a rotation applies to the next request.
|
|
481
|
+
realValue: (ref) =>
|
|
482
|
+
deps.policy.realValue(taskId, {
|
|
483
|
+
projectId: ref.projectId,
|
|
484
|
+
key: ref.key,
|
|
485
|
+
...(ref.head !== undefined ? { head: ref.head } : {}),
|
|
486
|
+
}),
|
|
487
|
+
};
|
|
488
|
+
derived.set(taskId, { signature, state });
|
|
489
|
+
return state;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const proxy = createSecretsProxy({
|
|
493
|
+
getTaskState: taskState,
|
|
494
|
+
ca: signerLeaves,
|
|
495
|
+
resolveHost: deps.resolveHost,
|
|
496
|
+
audit: createAuditWriter(join(deps.dataDir, "logs")),
|
|
497
|
+
now,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const stopTask = (taskId: string): void => {
|
|
501
|
+
if (closed) return;
|
|
502
|
+
derived.delete(taskId);
|
|
503
|
+
proxy.closeTask(taskId);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
proxy,
|
|
508
|
+
caBundlePem: () => bundleOf(currentAuthorities()),
|
|
509
|
+
watchTrust(listener) {
|
|
510
|
+
// A closing or closed runtime has nothing left to deliver, and a
|
|
511
|
+
// delivery started now would outlive the close() that is waiting.
|
|
512
|
+
if (closed) return async () => undefined;
|
|
513
|
+
// Reviewed first, so a change this makes is delivered once, not twice.
|
|
514
|
+
currentAuthorities();
|
|
515
|
+
const watcher: TrustWatcher = {
|
|
516
|
+
listener,
|
|
517
|
+
stop: new AbortController(),
|
|
518
|
+
delivered: null,
|
|
519
|
+
redeliver: false,
|
|
520
|
+
inFlight: null,
|
|
521
|
+
current: null,
|
|
522
|
+
};
|
|
523
|
+
watchers.add(watcher);
|
|
524
|
+
deliver(watcher);
|
|
525
|
+
return async () => {
|
|
526
|
+
refuseInsideDelivery("unsubscribe");
|
|
527
|
+
abortWatcher(watcher);
|
|
528
|
+
watchers.delete(watcher);
|
|
529
|
+
if (watcher.inFlight !== null) await watcher.inFlight;
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
requestTrustDelivery() {
|
|
533
|
+
if (closed) return;
|
|
534
|
+
for (const watcher of watchers) {
|
|
535
|
+
// Starts one now, or right after the one under way.
|
|
536
|
+
watcher.redeliver = true;
|
|
537
|
+
deliver(watcher);
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
placeholderFor(taskId, key) {
|
|
541
|
+
// Only for a key the policy serves the task right now, checked in the
|
|
542
|
+
// same synchronous step as the mint: a request racing a stop or a
|
|
543
|
+
// deletion cannot persist placeholders for a task that is gone.
|
|
544
|
+
const served = deps.policy
|
|
545
|
+
.proxiedKeys(taskId)
|
|
546
|
+
?.find((candidate) => candidate.projectId === key.projectId && candidate.key === key.key);
|
|
547
|
+
if (served === undefined) throw new Error("this key is not proxied for this task");
|
|
548
|
+
return mint(taskId, served);
|
|
549
|
+
},
|
|
550
|
+
domainsFor: (taskId) => [...proxiableDomains(deps.policy.proxiedKeys(taskId) ?? [])].sort(),
|
|
551
|
+
proxiedKeysFor: (taskId) =>
|
|
552
|
+
(deps.policy.proxiedKeys(taskId) ?? [])
|
|
553
|
+
.map((key) => ({
|
|
554
|
+
projectId: key.projectId,
|
|
555
|
+
key: key.key,
|
|
556
|
+
domains: [...proxiableDomains([key])].sort(),
|
|
557
|
+
}))
|
|
558
|
+
.filter((key) => key.domains.length > 0),
|
|
559
|
+
stopTask,
|
|
560
|
+
dropTask(taskId) {
|
|
561
|
+
// A closed runtime never deletes what its replacement may have written.
|
|
562
|
+
if (closed) return;
|
|
563
|
+
stopTask(taskId);
|
|
564
|
+
placeholders.dropTask(taskId);
|
|
565
|
+
},
|
|
566
|
+
close: async () => {
|
|
567
|
+
refuseInsideDelivery("close()");
|
|
568
|
+
// Synchronous, before any await: nothing is delivered, queued, retried
|
|
569
|
+
// or written from here on.
|
|
570
|
+
closed = true;
|
|
571
|
+
clearInterval(reviewTimer);
|
|
572
|
+
for (const watcher of watchers) abortWatcher(watcher);
|
|
573
|
+
watchers.clear();
|
|
574
|
+
engineClosed ??= proxy.close();
|
|
575
|
+
// Returns only once the deliveries under way have ended, so the caller
|
|
576
|
+
// can start a replacement safely. No delivery can start after the
|
|
577
|
+
// fence, so this snapshot is all of them.
|
|
578
|
+
await Promise.all([...deliveries]);
|
|
579
|
+
await engineClosed;
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|