@moondeck/station 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- 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/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -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/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -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 +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -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 +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
package/lib/safe-http.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §11: the one SSRF rule for every request to an MCP server, or to an
|
|
3
|
+
* endpoint discovered from it, whoever sends it — the sign-in box (probe,
|
|
4
|
+
* discovery, registration, code exchange), the broker (refresh) and the
|
|
5
|
+
* runner's gateway (every call a task makes).
|
|
6
|
+
*
|
|
7
|
+
* - The URL is `https`, with no userinfo.
|
|
8
|
+
* - The name is resolved immediately before the request is sent, and the
|
|
9
|
+
* request is refused if any A or AAAA answer is not a global unicast
|
|
10
|
+
* address (`isPublicAddress`): one private answer rejects the whole
|
|
11
|
+
* answer, so a rebinding record cannot win by mixing one in.
|
|
12
|
+
* - It connects to the vetted address while keeping the hostname for SNI and
|
|
13
|
+
* certificate checks, so nothing can re-resolve the name between the check
|
|
14
|
+
* and the connect.
|
|
15
|
+
* - It follows no redirect: a 3xx is the answer, for the caller to refuse.
|
|
16
|
+
* - It bounds the connect and header time, the request body, an idle gap in
|
|
17
|
+
* the response and the number of connections in flight — never a stream's
|
|
18
|
+
* total life or bytes, since MCP streams over SSE and streamable HTTP. A
|
|
19
|
+
* caller that buffers a body (discovery, token responses) bounds its size
|
|
20
|
+
* and, through an abort signal, its total time.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { LookupAddress } from "node:dns";
|
|
24
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
25
|
+
import { request as httpsRequest, type RequestOptions } from "node:https";
|
|
26
|
+
import type { IncomingMessage } from "node:http";
|
|
27
|
+
import { isIP, type LookupFunction } from "node:net";
|
|
28
|
+
|
|
29
|
+
import { isPublicAddress } from "./secrets-proxy/address";
|
|
30
|
+
|
|
31
|
+
export type SafeHttpRefusalReason =
|
|
32
|
+
| "url"
|
|
33
|
+
| "resolve_failed"
|
|
34
|
+
| "non_public_address"
|
|
35
|
+
| "too_many_connections"
|
|
36
|
+
| "request_too_large"
|
|
37
|
+
| "response_too_large"
|
|
38
|
+
| "timeout"
|
|
39
|
+
| "connection_failed";
|
|
40
|
+
|
|
41
|
+
/** A request the rule refused, or one that failed before an answer. */
|
|
42
|
+
export class SafeHttpRefusal extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly reason: SafeHttpRefusalReason,
|
|
45
|
+
message: string,
|
|
46
|
+
) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "SafeHttpRefusal";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SafeHttpLimits {
|
|
53
|
+
/** Resolve, TCP and TLS, together. */
|
|
54
|
+
connectMs: number;
|
|
55
|
+
/** From the request sent to the response's headers. */
|
|
56
|
+
headersMs: number;
|
|
57
|
+
/** The longest gap between two pieces of the response body. */
|
|
58
|
+
idleMs: number;
|
|
59
|
+
maxRequestBodyBytes: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_SAFE_HTTP_LIMITS: SafeHttpLimits = {
|
|
63
|
+
connectMs: 10_000,
|
|
64
|
+
headersMs: 60_000,
|
|
65
|
+
idleMs: 5 * 60_000,
|
|
66
|
+
maxRequestBodyBytes: 4 * 1024 * 1024,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The most requests one process has in flight under this rule. */
|
|
70
|
+
export const MAX_SAFE_HTTP_CONNECTIONS = 64;
|
|
71
|
+
|
|
72
|
+
export interface SafeHttpDeps {
|
|
73
|
+
/** Every address the name resolves to. */
|
|
74
|
+
resolveHost?: (hostname: string) => Promise<string[]>;
|
|
75
|
+
/** Tests only: which addresses count as public. */
|
|
76
|
+
isPublic?: (address: string) => boolean;
|
|
77
|
+
/** Tests only: extra trusted roots, PEM. */
|
|
78
|
+
ca?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SafeHttpRequest {
|
|
82
|
+
url: string;
|
|
83
|
+
method?: "GET" | "POST" | "DELETE";
|
|
84
|
+
headers?: Record<string, string>;
|
|
85
|
+
body?: string | Uint8Array;
|
|
86
|
+
/** Ends the exchange, whatever phase it is in (a buffered call's total
|
|
87
|
+
* deadline). A stream's own reader may leave it unset. */
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The response as it arrives: headers now, the body as a stream. */
|
|
92
|
+
export interface SafeHttpResponse {
|
|
93
|
+
status: number;
|
|
94
|
+
headers: IncomingMessage["headers"];
|
|
95
|
+
body: IncomingMessage;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let inFlight = 0;
|
|
99
|
+
|
|
100
|
+
async function defaultResolve(hostname: string): Promise<string[]> {
|
|
101
|
+
const answers: LookupAddress[] = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
102
|
+
return answers.map((answer) => answer.address);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The URL, if the rule accepts its shape: https, a host, no userinfo. */
|
|
106
|
+
export function parseSafeUrl(raw: string): URL | null {
|
|
107
|
+
let url: URL;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hostname === "") {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return url;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tooLong(): SafeHttpRefusal {
|
|
120
|
+
return new SafeHttpRefusal("timeout", "the exchange took too long");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function vet(
|
|
124
|
+
hostname: string,
|
|
125
|
+
limits: SafeHttpLimits,
|
|
126
|
+
deps: SafeHttpDeps,
|
|
127
|
+
signal?: AbortSignal,
|
|
128
|
+
): Promise<{ address: string; family: 4 | 6 }> {
|
|
129
|
+
const isPublic = deps.isPublic ?? isPublicAddress;
|
|
130
|
+
// A bracketed IPv6 literal arrives with its brackets.
|
|
131
|
+
const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
132
|
+
if (signal?.aborted) throw tooLong();
|
|
133
|
+
let answers: string[];
|
|
134
|
+
if (isIP(bare) !== 0) {
|
|
135
|
+
answers = [bare];
|
|
136
|
+
} else {
|
|
137
|
+
let timer: NodeJS.Timeout | undefined;
|
|
138
|
+
let onAbort: (() => void) | undefined;
|
|
139
|
+
try {
|
|
140
|
+
answers = await Promise.race([
|
|
141
|
+
(deps.resolveHost ?? defaultResolve)(bare),
|
|
142
|
+
new Promise<never>((_resolve, reject) => {
|
|
143
|
+
timer = setTimeout(() => reject(new Error("resolve timed out")), limits.connectMs);
|
|
144
|
+
timer.unref();
|
|
145
|
+
}),
|
|
146
|
+
// The caller's deadline ends the lookup too, not only what follows.
|
|
147
|
+
new Promise<never>((_resolve, reject) => {
|
|
148
|
+
if (!signal) return;
|
|
149
|
+
onAbort = () => reject(tooLong());
|
|
150
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
if (signal.aborted) onAbort();
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
154
|
+
} catch {
|
|
155
|
+
if (signal?.aborted) throw tooLong();
|
|
156
|
+
throw new SafeHttpRefusal("resolve_failed", `could not resolve ${bare}`);
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (signal?.aborted) throw tooLong();
|
|
163
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
164
|
+
throw new SafeHttpRefusal("resolve_failed", `${bare} has no address`);
|
|
165
|
+
}
|
|
166
|
+
for (const address of answers) {
|
|
167
|
+
if (typeof address !== "string" || !isPublic(address)) {
|
|
168
|
+
throw new SafeHttpRefusal("non_public_address", `${bare} resolves to an address that is not public`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const address = answers[0]!;
|
|
172
|
+
return { address, family: isIP(address) === 6 ? 6 : 4 };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The rule for a URL nothing here will fetch (an authorization page handed
|
|
177
|
+
* to the browser): https, no userinfo, and every address its name resolves
|
|
178
|
+
* to public. Throws `SafeHttpRefusal` otherwise.
|
|
179
|
+
*/
|
|
180
|
+
export async function vetPublicUrl(
|
|
181
|
+
raw: string,
|
|
182
|
+
limits: SafeHttpLimits = DEFAULT_SAFE_HTTP_LIMITS,
|
|
183
|
+
deps: SafeHttpDeps = {},
|
|
184
|
+
signal?: AbortSignal,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const url = parseSafeUrl(raw);
|
|
187
|
+
if (!url) throw new SafeHttpRefusal("url", "only https URLs without userinfo are allowed");
|
|
188
|
+
await vet(url.hostname, limits, deps, signal);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Send one request under the rule. Resolves with the response once its
|
|
193
|
+
* headers arrive; the body streams, with an idle bound but no total one.
|
|
194
|
+
* Throws `SafeHttpRefusal` for anything the rule refuses or that fails first.
|
|
195
|
+
*/
|
|
196
|
+
export async function safeRequest(
|
|
197
|
+
request: SafeHttpRequest,
|
|
198
|
+
limits: SafeHttpLimits = DEFAULT_SAFE_HTTP_LIMITS,
|
|
199
|
+
deps: SafeHttpDeps = {},
|
|
200
|
+
): Promise<SafeHttpResponse> {
|
|
201
|
+
const url = parseSafeUrl(request.url);
|
|
202
|
+
if (!url) throw new SafeHttpRefusal("url", "only https URLs without userinfo are allowed");
|
|
203
|
+
if (request.signal?.aborted) throw tooLong();
|
|
204
|
+
const body =
|
|
205
|
+
request.body === undefined
|
|
206
|
+
? undefined
|
|
207
|
+
: typeof request.body === "string"
|
|
208
|
+
? Buffer.from(request.body, "utf8")
|
|
209
|
+
: Buffer.from(request.body);
|
|
210
|
+
if (body && body.byteLength > limits.maxRequestBodyBytes) {
|
|
211
|
+
throw new SafeHttpRefusal("request_too_large", "the request body is too large");
|
|
212
|
+
}
|
|
213
|
+
if (inFlight >= MAX_SAFE_HTTP_CONNECTIONS) {
|
|
214
|
+
throw new SafeHttpRefusal("too_many_connections", "too many requests in flight");
|
|
215
|
+
}
|
|
216
|
+
inFlight += 1;
|
|
217
|
+
let released = false;
|
|
218
|
+
const release = (): void => {
|
|
219
|
+
if (released) return;
|
|
220
|
+
released = true;
|
|
221
|
+
inFlight -= 1;
|
|
222
|
+
};
|
|
223
|
+
try {
|
|
224
|
+
const target = await vet(url.hostname, limits, deps, request.signal);
|
|
225
|
+
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
226
|
+
if (options.all === true) callback(null, [{ address: target.address, family: target.family }]);
|
|
227
|
+
else callback(null, target.address, target.family);
|
|
228
|
+
};
|
|
229
|
+
const servername = isIP(url.hostname.replace(/^\[|\]$/g, "")) === 0 ? url.hostname : undefined;
|
|
230
|
+
const options: RequestOptions = {
|
|
231
|
+
host: url.hostname,
|
|
232
|
+
port: url.port === "" ? 443 : Number(url.port),
|
|
233
|
+
path: `${url.pathname}${url.search}`,
|
|
234
|
+
method: request.method ?? (body ? "POST" : "GET"),
|
|
235
|
+
headers: {
|
|
236
|
+
...request.headers,
|
|
237
|
+
...(body ? { "content-length": String(body.byteLength) } : {}),
|
|
238
|
+
},
|
|
239
|
+
lookup,
|
|
240
|
+
...(servername ? { servername } : {}),
|
|
241
|
+
// A connection per request: nothing pooled, nothing re-resolved.
|
|
242
|
+
agent: false,
|
|
243
|
+
rejectUnauthorized: true,
|
|
244
|
+
...(deps.ca ? { ca: deps.ca } : {}),
|
|
245
|
+
};
|
|
246
|
+
return await new Promise<SafeHttpResponse>((resolve, reject) => {
|
|
247
|
+
const req = httpsRequest(options);
|
|
248
|
+
let settled = false;
|
|
249
|
+
const onAbort = (): void => fail(tooLong());
|
|
250
|
+
const fail = (error: SafeHttpRefusal): void => {
|
|
251
|
+
if (settled) return;
|
|
252
|
+
settled = true;
|
|
253
|
+
clearTimeout(connectTimer);
|
|
254
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
255
|
+
req.destroy();
|
|
256
|
+
release();
|
|
257
|
+
reject(error);
|
|
258
|
+
};
|
|
259
|
+
const connectTimer = setTimeout(
|
|
260
|
+
() => fail(new SafeHttpRefusal("timeout", "the connection took too long")),
|
|
261
|
+
limits.connectMs,
|
|
262
|
+
);
|
|
263
|
+
connectTimer.unref();
|
|
264
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
265
|
+
let headersTimer: NodeJS.Timeout | undefined;
|
|
266
|
+
req.once("socket", (socket) => {
|
|
267
|
+
socket.once("secureConnect", () => {
|
|
268
|
+
clearTimeout(connectTimer);
|
|
269
|
+
headersTimer = setTimeout(
|
|
270
|
+
() => fail(new SafeHttpRefusal("timeout", "the server took too long to answer")),
|
|
271
|
+
limits.headersMs,
|
|
272
|
+
);
|
|
273
|
+
headersTimer.unref();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
req.once("error", (error) => {
|
|
277
|
+
clearTimeout(connectTimer);
|
|
278
|
+
clearTimeout(headersTimer);
|
|
279
|
+
fail(new SafeHttpRefusal("connection_failed", error.message));
|
|
280
|
+
});
|
|
281
|
+
req.once("response", (response) => {
|
|
282
|
+
clearTimeout(connectTimer);
|
|
283
|
+
clearTimeout(headersTimer);
|
|
284
|
+
if (settled) {
|
|
285
|
+
response.destroy();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
settled = true;
|
|
289
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
290
|
+
// Idle, never total: a stream may run as long as it keeps talking.
|
|
291
|
+
response.socket.setTimeout(limits.idleMs, () => {
|
|
292
|
+
response.destroy(new SafeHttpRefusal("timeout", "the response went idle"));
|
|
293
|
+
});
|
|
294
|
+
// A caller's deadline still ends the body it is reading.
|
|
295
|
+
request.signal?.addEventListener("abort", () => response.destroy(tooLong()), { once: true });
|
|
296
|
+
response.once("close", release);
|
|
297
|
+
resolve({ status: response.statusCode ?? 0, headers: response.headers, body: response });
|
|
298
|
+
});
|
|
299
|
+
// A listener added after the signal fired never runs: check it too,
|
|
300
|
+
// once every handler is in place (a destroyed request still errors).
|
|
301
|
+
if (request.signal?.aborted) {
|
|
302
|
+
onAbort();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (body) req.end(body);
|
|
306
|
+
else req.end();
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
release();
|
|
310
|
+
if (error instanceof SafeHttpRefusal) throw error;
|
|
311
|
+
throw new SafeHttpRefusal("connection_failed", error instanceof Error ? error.message : String(error));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Read a whole response body, refusing one larger than `maxBytes`. */
|
|
316
|
+
export async function readSafeBody(response: SafeHttpResponse, maxBytes: number): Promise<Buffer> {
|
|
317
|
+
const chunks: Buffer[] = [];
|
|
318
|
+
let size = 0;
|
|
319
|
+
for await (const chunk of response.body) {
|
|
320
|
+
const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
|
321
|
+
size += piece.byteLength;
|
|
322
|
+
if (size > maxBytes) {
|
|
323
|
+
response.body.destroy();
|
|
324
|
+
throw new SafeHttpRefusal("response_too_large", "the response is too large");
|
|
325
|
+
}
|
|
326
|
+
chunks.push(piece);
|
|
327
|
+
}
|
|
328
|
+
return Buffer.concat(chunks);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A buffered JSON exchange (discovery, registration, token endpoints):
|
|
332
|
+
* the status and the parsed body, or null when it is not JSON. */
|
|
333
|
+
export async function safeJson(
|
|
334
|
+
request: SafeHttpRequest,
|
|
335
|
+
options: {
|
|
336
|
+
maxResponseBytes?: number;
|
|
337
|
+
limits?: SafeHttpLimits;
|
|
338
|
+
deps?: SafeHttpDeps;
|
|
339
|
+
/** The whole exchange, connect to last byte. */
|
|
340
|
+
deadlineMs?: number;
|
|
341
|
+
} = {},
|
|
342
|
+
): Promise<{ status: number; json: unknown }> {
|
|
343
|
+
const signal =
|
|
344
|
+
request.signal ?? (options.deadlineMs !== undefined ? AbortSignal.timeout(Math.max(1, options.deadlineMs)) : undefined);
|
|
345
|
+
const response = await safeRequest(
|
|
346
|
+
{ ...request, headers: { accept: "application/json", ...request.headers }, ...(signal ? { signal } : {}) },
|
|
347
|
+
options.limits,
|
|
348
|
+
options.deps,
|
|
349
|
+
);
|
|
350
|
+
let raw: Buffer;
|
|
351
|
+
try {
|
|
352
|
+
raw = await readSafeBody(response, options.maxResponseBytes ?? 256 * 1024);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error instanceof SafeHttpRefusal) throw error;
|
|
355
|
+
throw new SafeHttpRefusal(signal?.aborted ? "timeout" : "connection_failed", "the response did not complete");
|
|
356
|
+
}
|
|
357
|
+
let json: unknown = null;
|
|
358
|
+
try {
|
|
359
|
+
json = JSON.parse(raw.toString("utf8"));
|
|
360
|
+
} catch {
|
|
361
|
+
json = null;
|
|
362
|
+
}
|
|
363
|
+
return { status: response.status, json };
|
|
364
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which upstream addresses the secrets proxy may dial (ADR-153 §3, rule 6).
|
|
3
|
+
*
|
|
4
|
+
* A proxied request carries a real secret, so its destination must be the
|
|
5
|
+
* public API the key is bound to — never something only the runner can
|
|
6
|
+
* reach (its loopback, the Docker bridge, a cloud metadata endpoint, the
|
|
7
|
+
* tailnet). The runner resolves the domain itself, and one non-public
|
|
8
|
+
* address anywhere in the answer rejects the whole answer, so a rebinding
|
|
9
|
+
* record cannot win by mixing a private address in with a public one.
|
|
10
|
+
*
|
|
11
|
+
* IPv6 is an allow-list (global unicast 2000::/3 only) minus special-purpose
|
|
12
|
+
* blocks; everything that embeds an IPv4 address (mapped, compatible,
|
|
13
|
+
* NAT64, 6to4, Teredo) is refused outright rather than unpacked.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { BlockList, isIP } from "node:net";
|
|
17
|
+
|
|
18
|
+
const DENIED_IPV4: ReadonlyArray<readonly [string, number]> = [
|
|
19
|
+
["0.0.0.0", 8], // "this network"
|
|
20
|
+
["10.0.0.0", 8], // RFC 1918
|
|
21
|
+
["100.64.0.0", 10], // CGNAT (RFC 6598) — Tailscale lives here
|
|
22
|
+
["127.0.0.0", 8], // loopback
|
|
23
|
+
["169.254.0.0", 16], // link-local, incl. cloud metadata
|
|
24
|
+
["172.16.0.0", 12], // RFC 1918
|
|
25
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
26
|
+
["192.0.2.0", 24], // TEST-NET-1
|
|
27
|
+
["192.88.99.0", 24], // deprecated 6to4 relay anycast
|
|
28
|
+
["192.168.0.0", 16], // RFC 1918
|
|
29
|
+
["198.18.0.0", 15], // benchmarking
|
|
30
|
+
["198.51.100.0", 24], // TEST-NET-2
|
|
31
|
+
["203.0.113.0", 24], // TEST-NET-3
|
|
32
|
+
["224.0.0.0", 4], // multicast
|
|
33
|
+
["240.0.0.0", 4], // reserved, incl. 255.255.255.255
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const DENIED_IPV6: ReadonlyArray<readonly [string, number]> = [
|
|
37
|
+
["::ffff:0:0", 96], // IPv4-mapped
|
|
38
|
+
["2001::", 23], // IETF protocol assignments, incl. Teredo 2001::/32
|
|
39
|
+
["2001:db8::", 32], // documentation
|
|
40
|
+
["2002::", 16], // 6to4
|
|
41
|
+
["3fff::", 20], // documentation (RFC 9637)
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Two lists, never one: BlockList compares IPv4 against IPv6 rules through
|
|
45
|
+
// the mapped form, so a `::ffff:0:0/96` rule in a shared list denies EVERY
|
|
46
|
+
// IPv4 address (checked live on Node 22.11).
|
|
47
|
+
const deniedIpv4 = new BlockList();
|
|
48
|
+
for (const [network, prefix] of DENIED_IPV4) deniedIpv4.addSubnet(network, prefix, "ipv4");
|
|
49
|
+
const deniedIpv6 = new BlockList();
|
|
50
|
+
for (const [network, prefix] of DENIED_IPV6) deniedIpv6.addSubnet(network, prefix, "ipv6");
|
|
51
|
+
|
|
52
|
+
// Loopback, unspecified, ULA fc00::/7, link-local fe80::/10, multicast
|
|
53
|
+
// ff00::/8, NAT64 64:ff9b::/96 and IPv4-compatible ::/96 all fall outside.
|
|
54
|
+
const globalUnicast = new BlockList();
|
|
55
|
+
globalUnicast.addSubnet("2000::", 3, "ipv6");
|
|
56
|
+
|
|
57
|
+
export function isPublicAddress(ip: string): boolean {
|
|
58
|
+
// isIP accepts zone ids (`fe80::1%eth0`); BlockList would quietly miss them.
|
|
59
|
+
if (typeof ip !== "string" || ip.includes("%")) return false;
|
|
60
|
+
try {
|
|
61
|
+
switch (isIP(ip)) {
|
|
62
|
+
case 4:
|
|
63
|
+
return !deniedIpv4.check(ip, "ipv4");
|
|
64
|
+
case 6:
|
|
65
|
+
return globalUnicast.check(ip, "ipv6") && !deniedIpv6.check(ip, "ipv6");
|
|
66
|
+
default:
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Internal custom connections add only these explicitly approved destinations.
|
|
75
|
+
// Do not change the public proxy's policy or unwrap IPv4 embedded in IPv6.
|
|
76
|
+
const internalIpv4 = new BlockList();
|
|
77
|
+
for (const [network, prefix] of [["10.0.0.0", 8], ["172.16.0.0", 12], ["192.168.0.0", 16], ["100.64.0.0", 10]] as const) internalIpv4.addSubnet(network, prefix, "ipv4");
|
|
78
|
+
const metadataIpv4 = new BlockList();
|
|
79
|
+
metadataIpv4.addAddress("100.100.100.200", "ipv4"); // Alibaba Cloud metadata within CGNAT
|
|
80
|
+
const internalIpv6 = new BlockList();
|
|
81
|
+
internalIpv6.addSubnet("fc00::", 7, "ipv6");
|
|
82
|
+
const metadataIpv6 = new BlockList();
|
|
83
|
+
metadataIpv6.addAddress("fd00:ec2::254", "ipv6");
|
|
84
|
+
export function isInternalConnectionAddress(ip: string): boolean {
|
|
85
|
+
if (typeof ip !== "string" || !/^[0-9a-fA-FxX:.]+$/.test(ip)) return false;
|
|
86
|
+
// WHATWG URL canonicalization normalizes decimal/octal/hex IPv4 spellings.
|
|
87
|
+
let normalized: string;
|
|
88
|
+
try { normalized = new URL(`http://${ip.includes(":") ? `[${ip}]` : ip}`).hostname.replace(/^\[|\]$/g, ""); } catch { return false; }
|
|
89
|
+
if (isPublicAddress(normalized)) return true;
|
|
90
|
+
switch (isIP(normalized)) {
|
|
91
|
+
case 4: return internalIpv4.check(normalized, "ipv4") && !metadataIpv4.check(normalized, "ipv4");
|
|
92
|
+
case 6: return internalIpv6.check(normalized, "ipv6") && !metadataIpv6.check(normalized, "ipv6");
|
|
93
|
+
default: return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets proxy's audit log (ADR-153 §3 rule 8), next to
|
|
3
|
+
* mcp-audit.jsonl. The engine already keeps values, placeholders, paths and
|
|
4
|
+
* queries out of every entry; this only appends.
|
|
5
|
+
*
|
|
6
|
+
* It THROWS when a line cannot be written. An injection is recorded before
|
|
7
|
+
* its value leaves, and the engine refuses the request when that record
|
|
8
|
+
* fails; refusals and skips go through the engine's best-effort wrapper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const SECRETS_PROXY_AUDIT_FILE = "secrets-proxy-audit.jsonl";
|
|
15
|
+
|
|
16
|
+
export function createAuditWriter(logsDir: string): (entry: Record<string, unknown>) => void {
|
|
17
|
+
const file = join(logsDir, SECRETS_PROXY_AUDIT_FILE);
|
|
18
|
+
return (entry) => {
|
|
19
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(file, line, { mode: 0o600 });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
// A logs dir removed under a running runner (a cleanup job) must not
|
|
24
|
+
// refuse every proxied request until a restart: recreate and retry once.
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
26
|
+
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
27
|
+
appendFileSync(file, line, { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner's secrets-proxy CAs, kept across restarts (ADR-153 §3 "The CA").
|
|
3
|
+
*
|
|
4
|
+
* A task installs the CA bundle each time its container or VM starts and
|
|
5
|
+
* trusts exactly that until the next start, so the signer may only ever
|
|
6
|
+
* change to a CA the tasks already trust. Rotation is therefore an overlap:
|
|
7
|
+
* - a CA lives five years and signs every leaf until its last day;
|
|
8
|
+
* - inside its last 90 days a successor is minted, and every task started
|
|
9
|
+
* from then on installs both (`trusted`);
|
|
10
|
+
* - on its last day it retires and the successor signs.
|
|
11
|
+
* A task last started before its successor existed — one running for more
|
|
12
|
+
* than 89 days across the switch — trusts only the retiring CA and must be
|
|
13
|
+
* restarted. That is logged loudly when the successor is minted and again at
|
|
14
|
+
* the switch; the runner also re-installs the bundle into running tasks when
|
|
15
|
+
* it changes, which covers every process started after that.
|
|
16
|
+
*
|
|
17
|
+
* The certificates are stored in the clear (every task gets a copy anyway);
|
|
18
|
+
* the private keys are sealed with host-master.key and never leave this
|
|
19
|
+
* process in any other form.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { writeDurableFileAtomic } from "../env-file";
|
|
26
|
+
import { createCertificateAuthority, loadCertificateAuthority, type CertificateAuthority } from "./ca";
|
|
27
|
+
|
|
28
|
+
const STORE_VERSION = 1;
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
30
|
+
const CA_VALIDITY_DAYS = 5 * 365;
|
|
31
|
+
/** Inside its last 90 days a CA gets a successor, installed alongside it. */
|
|
32
|
+
export const CA_SUCCESSOR_WITHIN_MS = 90 * DAY_MS;
|
|
33
|
+
/** On its last day a CA stops signing and its successor takes over. */
|
|
34
|
+
export const CA_RETIRE_WITHIN_MS = DAY_MS;
|
|
35
|
+
/** Signer and successor: the windows above never leave more alive at once. */
|
|
36
|
+
const MAX_AUTHORITIES = 2;
|
|
37
|
+
|
|
38
|
+
export interface SealedText {
|
|
39
|
+
ct: Buffer;
|
|
40
|
+
nonce: Buffer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CaStoreDeps {
|
|
44
|
+
/** Directory for `ca.json` (created 0700). */
|
|
45
|
+
dir: string;
|
|
46
|
+
seal(plaintext: string): SealedText;
|
|
47
|
+
open(ct: Buffer, nonce: Buffer): string;
|
|
48
|
+
now?(): number;
|
|
49
|
+
log?: Pick<Console, "warn">;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CertificateAuthorities {
|
|
53
|
+
/** Signs every leaf: the oldest CA not yet retired. */
|
|
54
|
+
readonly signer: CertificateAuthority;
|
|
55
|
+
/** What a starting task installs: the signer and, inside its last 90 days,
|
|
56
|
+
* its successor. */
|
|
57
|
+
readonly trusted: readonly CertificateAuthority[];
|
|
58
|
+
/** When this set next changes: a successor is due, or the signer retires. */
|
|
59
|
+
readonly reviewAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface StoredAuthority {
|
|
63
|
+
commonName: string;
|
|
64
|
+
certificatePem: string;
|
|
65
|
+
key: { ct: string; nonce: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface StoredCa {
|
|
69
|
+
v: number;
|
|
70
|
+
authorities: StoredAuthority[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isStoredAuthority(value: unknown): value is StoredAuthority {
|
|
74
|
+
if (value === null || typeof value !== "object") return false;
|
|
75
|
+
const entry = value as Record<string, unknown>;
|
|
76
|
+
const key = entry.key as Record<string, unknown> | undefined;
|
|
77
|
+
return (
|
|
78
|
+
typeof entry.commonName === "string" &&
|
|
79
|
+
typeof entry.certificatePem === "string" &&
|
|
80
|
+
key !== undefined &&
|
|
81
|
+
key !== null &&
|
|
82
|
+
typeof key.ct === "string" &&
|
|
83
|
+
typeof key.nonce === "string"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isStoredCa(value: unknown): value is StoredCa {
|
|
88
|
+
if (value === null || typeof value !== "object") return false;
|
|
89
|
+
const stored = value as Record<string, unknown>;
|
|
90
|
+
return (
|
|
91
|
+
stored.v === STORE_VERSION &&
|
|
92
|
+
Array.isArray(stored.authorities) &&
|
|
93
|
+
stored.authorities.length >= 1 &&
|
|
94
|
+
stored.authorities.length <= MAX_AUTHORITIES &&
|
|
95
|
+
stored.authorities.every(isStoredAuthority)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const dateOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns the runner's CAs, minting the first one, a successor or (after a
|
|
103
|
+
* runner was down through a whole overlap) a replacement as the dates call
|
|
104
|
+
* for, and persisting any change. Throws when a stored CA exists but cannot
|
|
105
|
+
* be opened or fails validation: that is a broken store, not a reason to
|
|
106
|
+
* mint a new CA behind every running task's back.
|
|
107
|
+
*/
|
|
108
|
+
export function loadOrCreateCertificateAuthorities(deps: CaStoreDeps): CertificateAuthorities {
|
|
109
|
+
const now = deps.now ?? Date.now;
|
|
110
|
+
const log = deps.log ?? console;
|
|
111
|
+
mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
|
|
112
|
+
const path = join(deps.dir, "ca.json");
|
|
113
|
+
const at = now();
|
|
114
|
+
|
|
115
|
+
let stored: { authority: CertificateAuthority; record: StoredAuthority }[] = [];
|
|
116
|
+
if (existsSync(path)) {
|
|
117
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
118
|
+
if (!isStoredCa(parsed)) throw new Error("the secrets-proxy CA store is not in a format this station reads");
|
|
119
|
+
stored = parsed.authorities.map((record) => ({
|
|
120
|
+
authority: loadCertificateAuthority({
|
|
121
|
+
certificatePem: record.certificatePem,
|
|
122
|
+
privateKeyPem: deps.open(Buffer.from(record.key.ct, "base64"), Buffer.from(record.key.nonce, "base64")),
|
|
123
|
+
commonName: record.commonName,
|
|
124
|
+
}),
|
|
125
|
+
record,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const mint = (): { authority: CertificateAuthority; record: StoredAuthority } => {
|
|
130
|
+
// Dated, so the trust store tells a signer and its successor apart.
|
|
131
|
+
const commonName = `Uai secrets proxy ${dateOf(at)}`;
|
|
132
|
+
let privateKeyPem = "";
|
|
133
|
+
const authority = createCertificateAuthority({
|
|
134
|
+
now: at,
|
|
135
|
+
commonName,
|
|
136
|
+
validityDays: CA_VALIDITY_DAYS,
|
|
137
|
+
onPrivateKey: (privateKey) => {
|
|
138
|
+
privateKeyPem = String(privateKey.export({ type: "pkcs8", format: "pem" }));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const sealed = deps.seal(privateKeyPem);
|
|
142
|
+
return {
|
|
143
|
+
authority,
|
|
144
|
+
record: {
|
|
145
|
+
commonName,
|
|
146
|
+
certificatePem: authority.certificatePem,
|
|
147
|
+
key: { ct: sealed.ct.toString("base64"), nonce: sealed.nonce.toString("base64") },
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const live = stored
|
|
153
|
+
.filter(({ authority }) => at < authority.notAfter - CA_RETIRE_WITHIN_MS)
|
|
154
|
+
.sort((a, b) => a.authority.notAfter - b.authority.notAfter);
|
|
155
|
+
let changed = live.length !== stored.length;
|
|
156
|
+
let newest = live[live.length - 1];
|
|
157
|
+
if (newest === undefined) {
|
|
158
|
+
if (stored.length > 0) {
|
|
159
|
+
log.warn(
|
|
160
|
+
"[secrets-proxy] the proxy CA reached its last day with no successor (the station was not running through " +
|
|
161
|
+
"the overlap); a new CA was created, and every task started before now must be restarted to trust it",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
newest = mint();
|
|
165
|
+
live.push(newest);
|
|
166
|
+
changed = true;
|
|
167
|
+
} else if (changed) {
|
|
168
|
+
// At most two are stored, so a retirement leaves the successor alone.
|
|
169
|
+
log.warn(
|
|
170
|
+
`[secrets-proxy] the proxy CA retired and its successor now signs; tasks last started before ` +
|
|
171
|
+
`${dateOf(newest.authority.notBefore)} trust only the retired CA and must be restarted`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (at >= newest.authority.notAfter - CA_SUCCESSOR_WITHIN_MS) {
|
|
175
|
+
const ending = newest.authority.notAfter;
|
|
176
|
+
live.push(mint());
|
|
177
|
+
changed = true;
|
|
178
|
+
log.warn(
|
|
179
|
+
`[secrets-proxy] the proxy CA expires on ${dateOf(ending)}; a successor was created and tasks started ` +
|
|
180
|
+
`from now trust both. It takes over on ${dateOf(ending - CA_RETIRE_WITHIN_MS)}: tasks started before ` +
|
|
181
|
+
"now must be restarted by then",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (changed) {
|
|
185
|
+
const next: StoredCa = { v: STORE_VERSION, authorities: live.map(({ record }) => record) };
|
|
186
|
+
// Durable: a CA lost to a power cut after tasks installed it would leave
|
|
187
|
+
// every one of them distrusting the next.
|
|
188
|
+
writeDurableFileAtomic(path, `${JSON.stringify(next)}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const trusted = live.map(({ authority }) => authority);
|
|
192
|
+
const signer = trusted[0];
|
|
193
|
+
const last = trusted[trusted.length - 1];
|
|
194
|
+
if (signer === undefined || last === undefined) throw new Error("no live secrets-proxy CA");
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
signer,
|
|
197
|
+
trusted: Object.freeze(trusted),
|
|
198
|
+
reviewAt: Math.min(signer.notAfter - CA_RETIRE_WITHIN_MS, last.notAfter - CA_SUCCESSOR_WITHIN_MS),
|
|
199
|
+
});
|
|
200
|
+
}
|