@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
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets proxy's certificate authority (ADR-153 §3 "The CA").
|
|
3
|
+
*
|
|
4
|
+
* One ECDSA P-256 CA per runner signs short-lived per-domain leaves that the
|
|
5
|
+
* proxy presents to a task on the domains it declares. Tasks trust this CA;
|
|
6
|
+
* the proxy's own upstream verification uses public roots, which can never
|
|
7
|
+
* include it.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep a bug here from turning into a leak:
|
|
10
|
+
* - The CA private key is a KeyObject closed over by a signing callback. The
|
|
11
|
+
* certificate builder hands it finished TBS bytes and embeds the signature
|
|
12
|
+
* it gets back; it never receives key material.
|
|
13
|
+
* - Every certificate is parsed back with node:crypto's X509Certificate and
|
|
14
|
+
* checked straight after minting (CA-ness, SAN, EKU, issuer, signature,
|
|
15
|
+
* key match, validity, serial). Any failure throws, and callers treat a
|
|
16
|
+
* throw as "withhold": no handshake is ever answered with a certificate we
|
|
17
|
+
* could not prove correct.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
X509Certificate,
|
|
22
|
+
createHash,
|
|
23
|
+
createPrivateKey,
|
|
24
|
+
generateKeyPairSync,
|
|
25
|
+
randomBytes,
|
|
26
|
+
sign,
|
|
27
|
+
type KeyObject,
|
|
28
|
+
} from "node:crypto";
|
|
29
|
+
|
|
30
|
+
import * as der from "./der";
|
|
31
|
+
import { canonicalHostname } from "./hostname";
|
|
32
|
+
|
|
33
|
+
const OID = {
|
|
34
|
+
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
|
35
|
+
commonName: "2.5.4.3",
|
|
36
|
+
subjectKeyIdentifier: "2.5.29.14",
|
|
37
|
+
keyUsage: "2.5.29.15",
|
|
38
|
+
subjectAltName: "2.5.29.17",
|
|
39
|
+
basicConstraints: "2.5.29.19",
|
|
40
|
+
authorityKeyIdentifier: "2.5.29.35",
|
|
41
|
+
extKeyUsage: "2.5.29.37",
|
|
42
|
+
serverAuth: "1.3.6.1.5.5.7.3.1",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/** KeyUsage named bits (RFC 5280 §4.2.1.3). */
|
|
46
|
+
const KEY_USAGE = { digitalSignature: 0, keyCertSign: 5, cRLSign: 6 } as const;
|
|
47
|
+
|
|
48
|
+
const MINUTE_MS = 60_000;
|
|
49
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
50
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
51
|
+
/** Certificates start five minutes early so a task whose clock runs a little
|
|
52
|
+
* behind the runner's (a VM after resume) still accepts a fresh leaf. */
|
|
53
|
+
const BACKDATE_MS = 5 * MINUTE_MS;
|
|
54
|
+
const MAX_CA_VALIDITY_DAYS = 3_650;
|
|
55
|
+
/** The ADR's leaves live 24 h; this is only the ceiling a caller may ask for. */
|
|
56
|
+
const MAX_LEAF_VALIDITY_HOURS = 7 * 24;
|
|
57
|
+
const DEFAULT_LEAF_VALIDITY_HOURS = 24;
|
|
58
|
+
/** Re-mint an hour before expiry, so a leaf is never served in its last
|
|
59
|
+
* hour and a long-lived connection never straddles its notAfter — or a
|
|
60
|
+
* quarter of the leaf's life when that is shorter (a short validity, or a
|
|
61
|
+
* leaf capped by a CA near its own end), so a cached leaf always has a
|
|
62
|
+
* usable window rather than being re-minted on every handshake. */
|
|
63
|
+
const LEAF_RENEW_BEFORE_MS = HOUR_MS;
|
|
64
|
+
const DEFAULT_LEAF_CACHE_ENTRIES = 256;
|
|
65
|
+
|
|
66
|
+
/** Signs a finished TBSCertificate; the only door to a CA key. */
|
|
67
|
+
export type SignTbs = (tbs: Buffer) => Buffer;
|
|
68
|
+
|
|
69
|
+
export interface CertificateAuthority {
|
|
70
|
+
readonly certificatePem: string;
|
|
71
|
+
readonly certificate: X509Certificate;
|
|
72
|
+
/** The encoded subject Name, reused byte for byte as every leaf's issuer so
|
|
73
|
+
* name matching never depends on a re-encoding. */
|
|
74
|
+
readonly subjectName: Buffer;
|
|
75
|
+
readonly keyIdentifier: Buffer;
|
|
76
|
+
readonly notBefore: number;
|
|
77
|
+
readonly notAfter: number;
|
|
78
|
+
readonly sign: SignTbs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface LeafCertificate {
|
|
82
|
+
readonly hostname: string;
|
|
83
|
+
readonly certificatePem: string;
|
|
84
|
+
/** PKCS#8 PEM, because tls.createSecureContext does not accept a KeyObject. */
|
|
85
|
+
readonly privateKeyPem: string;
|
|
86
|
+
readonly certificate: X509Certificate;
|
|
87
|
+
readonly notBefore: number;
|
|
88
|
+
readonly notAfter: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface CertificateFields {
|
|
92
|
+
serial: Buffer;
|
|
93
|
+
issuer: Buffer;
|
|
94
|
+
subject: Buffer;
|
|
95
|
+
notBefore: number;
|
|
96
|
+
notAfter: number;
|
|
97
|
+
publicKey: KeyObject;
|
|
98
|
+
extensions: readonly Buffer[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function floorToSecond(ms: number): number {
|
|
102
|
+
return Math.floor(ms / 1_000) * 1_000;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 16 random octets with the top bit cleared: positive without a sign-guard
|
|
106
|
+
* octet, under RFC 5280's 20-octet cap, and never zero. */
|
|
107
|
+
function randomSerial(): Buffer {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const serial = randomBytes(16);
|
|
110
|
+
serial[0] = (serial[0] ?? 0) & 0x7f;
|
|
111
|
+
if (serial.some((octet) => octet !== 0)) return serial;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** X.520's ub-common-name: strict X.509 stacks refuse a longer CN. */
|
|
116
|
+
const MAX_COMMON_NAME = 64;
|
|
117
|
+
|
|
118
|
+
function distinguishedName(commonName: string): Buffer {
|
|
119
|
+
return der.sequence(
|
|
120
|
+
der.set(der.sequence(der.objectIdentifier(OID.commonName), der.utf8String(commonName))),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A leaf's subject: CN = hostname when it fits X.520's bound, otherwise an
|
|
125
|
+
* empty Name — RFC 5280 §4.2.1.6 then requires a critical SAN, which is
|
|
126
|
+
* where every client looks for the name anyway. */
|
|
127
|
+
function leafSubject(hostname: string): { subject: Buffer; sanCritical: boolean } {
|
|
128
|
+
return hostname.length <= MAX_COMMON_NAME
|
|
129
|
+
? { subject: distinguishedName(hostname), sanCritical: false }
|
|
130
|
+
: { subject: der.sequence(), sanCritical: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** RFC 7093 §2 method 1: the leftmost 160 bits of SHA-256 over the
|
|
134
|
+
* subjectPublicKey bits (the uncompressed EC point). */
|
|
135
|
+
function subjectKeyIdentifier(publicKey: KeyObject): Buffer {
|
|
136
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
137
|
+
if (typeof jwk.x !== "string" || typeof jwk.y !== "string") {
|
|
138
|
+
throw new Error("expected an EC public key");
|
|
139
|
+
}
|
|
140
|
+
const point = Buffer.concat([
|
|
141
|
+
Buffer.from([0x04]),
|
|
142
|
+
Buffer.from(jwk.x, "base64url"),
|
|
143
|
+
Buffer.from(jwk.y, "base64url"),
|
|
144
|
+
]);
|
|
145
|
+
return createHash("sha256").update(point).digest().subarray(0, 20);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** DER omits `critical` when it is the DEFAULT FALSE. */
|
|
149
|
+
function extension(oid: string, critical: boolean, value: Buffer): Buffer {
|
|
150
|
+
return der.sequence(
|
|
151
|
+
der.objectIdentifier(oid),
|
|
152
|
+
...(critical ? [der.boolean(true)] : []),
|
|
153
|
+
der.octetString(value),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildCertificate(fields: CertificateFields, signTbs: SignTbs): Buffer {
|
|
158
|
+
// ecdsa-with-SHA256 takes no parameters, not even NULL (RFC 5758 §3.2).
|
|
159
|
+
const algorithm = der.sequence(der.objectIdentifier(OID.ecdsaWithSha256));
|
|
160
|
+
const tbs = der.sequence(
|
|
161
|
+
der.explicit(0, der.integer(2)), // v3
|
|
162
|
+
der.integer(fields.serial),
|
|
163
|
+
algorithm,
|
|
164
|
+
fields.issuer,
|
|
165
|
+
der.sequence(der.time(new Date(fields.notBefore)), der.time(new Date(fields.notAfter))),
|
|
166
|
+
fields.subject,
|
|
167
|
+
fields.publicKey.export({ type: "spki", format: "der" }),
|
|
168
|
+
der.explicit(3, der.sequence(...fields.extensions)),
|
|
169
|
+
);
|
|
170
|
+
return der.sequence(tbs, algorithm, der.bitString(signTbs(tbs)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validityOf(certificate: X509Certificate): { notBefore: number; notAfter: number } {
|
|
174
|
+
// validFromDate/validToDate only exist from Node 22.10; the string forms
|
|
175
|
+
// name the same instants on every supported runtime.
|
|
176
|
+
const from = certificate.validFromDate ?? new Date(certificate.validFrom);
|
|
177
|
+
const to = certificate.validToDate ?? new Date(certificate.validTo);
|
|
178
|
+
return { notBefore: from.getTime(), notAfter: to.getTime() };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function checkValidity(
|
|
182
|
+
failures: string[],
|
|
183
|
+
certificate: X509Certificate,
|
|
184
|
+
expected: { notBefore: number; notAfter: number },
|
|
185
|
+
maxSpanMs: number,
|
|
186
|
+
): void {
|
|
187
|
+
const { notBefore, notAfter } = validityOf(certificate);
|
|
188
|
+
if (notBefore !== expected.notBefore || notAfter !== expected.notAfter) {
|
|
189
|
+
failures.push("validity is not the requested window");
|
|
190
|
+
}
|
|
191
|
+
if (!(notAfter > notBefore) || notAfter - notBefore > maxSpanMs) {
|
|
192
|
+
failures.push("validity is not bounded");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkSerial(failures: string[], certificate: X509Certificate): void {
|
|
197
|
+
const serial = certificate.serialNumber;
|
|
198
|
+
if (!/^[0-9A-Fa-f]+$/.test(serial) || BigInt(`0x${serial}`) <= 0n) {
|
|
199
|
+
failures.push("serial is not a positive integer");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extendedKeyUsage(certificate: X509Certificate): readonly string[] {
|
|
204
|
+
// Node names the EKU list `keyUsage` (newer releases add `extKeyUsage`);
|
|
205
|
+
// it is undefined when the extension is absent.
|
|
206
|
+
const renamed = (certificate as { extKeyUsage?: string[] }).extKeyUsage;
|
|
207
|
+
return renamed ?? certificate.keyUsage ?? [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function fail(what: string, failures: readonly string[]): void {
|
|
211
|
+
if (failures.length > 0) {
|
|
212
|
+
throw new Error(`${what} failed self-validation: ${failures.join("; ")}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Throws unless `certificate` is a self-signed CA certificate for
|
|
218
|
+
* `privateKey` over exactly the expected window. Exported for tests.
|
|
219
|
+
*/
|
|
220
|
+
export function validateCertificateAuthority(
|
|
221
|
+
certificate: X509Certificate,
|
|
222
|
+
expected: { privateKey: KeyObject; notBefore: number; notAfter: number },
|
|
223
|
+
): void {
|
|
224
|
+
const failures: string[] = [];
|
|
225
|
+
// `ca` is X509_check_ca() == 1: basicConstraints CA:TRUE, and a keyUsage
|
|
226
|
+
// that (when present) allows keyCertSign.
|
|
227
|
+
if (certificate.ca !== true) failures.push("not a CA certificate");
|
|
228
|
+
// X509_check_issued: names and key ids chain, and the issuer's keyUsage
|
|
229
|
+
// allows certificate signing — here, of itself.
|
|
230
|
+
if (!certificate.checkIssued(certificate)) failures.push("not self-issued");
|
|
231
|
+
if (!certificate.verify(certificate.publicKey)) failures.push("self-signature does not verify");
|
|
232
|
+
if (!certificate.checkPrivateKey(expected.privateKey)) failures.push("private key does not match");
|
|
233
|
+
checkValidity(failures, certificate, expected, MAX_CA_VALIDITY_DAYS * DAY_MS + BACKDATE_MS);
|
|
234
|
+
checkSerial(failures, certificate);
|
|
235
|
+
fail("certificate authority", failures);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Throws unless `certificate` is a server leaf for exactly `hostname`, issued
|
|
240
|
+
* and signed by `authority`, matching `privateKey`, over exactly the expected
|
|
241
|
+
* window. Exported for tests.
|
|
242
|
+
*/
|
|
243
|
+
export function validateLeafCertificate(
|
|
244
|
+
certificate: X509Certificate,
|
|
245
|
+
expected: {
|
|
246
|
+
authority: CertificateAuthority;
|
|
247
|
+
hostname: string;
|
|
248
|
+
privateKey: KeyObject;
|
|
249
|
+
notBefore: number;
|
|
250
|
+
notAfter: number;
|
|
251
|
+
},
|
|
252
|
+
): void {
|
|
253
|
+
const failures: string[] = [];
|
|
254
|
+
if (certificate.ca !== false) failures.push("leaf is a CA certificate");
|
|
255
|
+
if (certificate.subjectAltName !== `DNS:${expected.hostname}`) {
|
|
256
|
+
failures.push("subjectAltName is not exactly the hostname");
|
|
257
|
+
}
|
|
258
|
+
if (!extendedKeyUsage(certificate).includes(OID.serverAuth)) {
|
|
259
|
+
failures.push("extKeyUsage lacks serverAuth");
|
|
260
|
+
}
|
|
261
|
+
if (!certificate.checkIssued(expected.authority.certificate)) {
|
|
262
|
+
failures.push("not issued by the CA");
|
|
263
|
+
}
|
|
264
|
+
if (!certificate.verify(expected.authority.certificate.publicKey)) {
|
|
265
|
+
failures.push("signature does not verify against the CA");
|
|
266
|
+
}
|
|
267
|
+
if (!certificate.checkPrivateKey(expected.privateKey)) failures.push("private key does not match");
|
|
268
|
+
const matched = certificate.checkHost(expected.hostname, {
|
|
269
|
+
subject: "never",
|
|
270
|
+
wildcards: false,
|
|
271
|
+
partialWildcards: false,
|
|
272
|
+
});
|
|
273
|
+
if (matched !== expected.hostname) failures.push("hostname verification fails");
|
|
274
|
+
checkValidity(failures, certificate, expected, MAX_LEAF_VALIDITY_HOURS * HOUR_MS + BACKDATE_MS);
|
|
275
|
+
checkSerial(failures, certificate);
|
|
276
|
+
fail("leaf certificate", failures);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createCertificateAuthority(options: {
|
|
280
|
+
now: number;
|
|
281
|
+
commonName: string;
|
|
282
|
+
validityDays: number;
|
|
283
|
+
/** Receives the new key once, for a store to seal; nothing else sees it. */
|
|
284
|
+
onPrivateKey?: (privateKey: KeyObject) => unknown;
|
|
285
|
+
}): CertificateAuthority {
|
|
286
|
+
const { now, commonName, validityDays } = options;
|
|
287
|
+
if (!Number.isFinite(now)) throw new RangeError("now must be a finite epoch-ms value");
|
|
288
|
+
// X.520 caps commonName at 64 characters.
|
|
289
|
+
if (typeof commonName !== "string" || commonName.length < 1 || commonName.length > 64) {
|
|
290
|
+
throw new RangeError("CA common name must be 1..64 characters");
|
|
291
|
+
}
|
|
292
|
+
if (!Number.isInteger(validityDays) || validityDays < 1 || validityDays > MAX_CA_VALIDITY_DAYS) {
|
|
293
|
+
throw new RangeError(`CA validity must be 1..${MAX_CA_VALIDITY_DAYS} days`);
|
|
294
|
+
}
|
|
295
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
296
|
+
const signTbs: SignTbs = (tbs) => sign("sha256", tbs, { key: privateKey, dsaEncoding: "der" });
|
|
297
|
+
const notBefore = floorToSecond(now - BACKDATE_MS);
|
|
298
|
+
const notAfter = floorToSecond(now + validityDays * DAY_MS);
|
|
299
|
+
const subjectName = distinguishedName(commonName);
|
|
300
|
+
const keyIdentifier = subjectKeyIdentifier(publicKey);
|
|
301
|
+
const certificate = new X509Certificate(
|
|
302
|
+
buildCertificate(
|
|
303
|
+
{
|
|
304
|
+
serial: randomSerial(),
|
|
305
|
+
issuer: subjectName,
|
|
306
|
+
subject: subjectName,
|
|
307
|
+
notBefore,
|
|
308
|
+
notAfter,
|
|
309
|
+
publicKey,
|
|
310
|
+
extensions: [
|
|
311
|
+
extension(OID.basicConstraints, true, der.sequence(der.boolean(true), der.integer(0))),
|
|
312
|
+
extension(
|
|
313
|
+
OID.keyUsage,
|
|
314
|
+
true,
|
|
315
|
+
der.namedBitString([KEY_USAGE.keyCertSign, KEY_USAGE.cRLSign]),
|
|
316
|
+
),
|
|
317
|
+
extension(OID.subjectKeyIdentifier, false, der.octetString(keyIdentifier)),
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
signTbs,
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
validateCertificateAuthority(certificate, { privateKey, notBefore, notAfter });
|
|
324
|
+
options.onPrivateKey?.(privateKey);
|
|
325
|
+
return Object.freeze({
|
|
326
|
+
certificatePem: certificate.toString(),
|
|
327
|
+
certificate,
|
|
328
|
+
subjectName,
|
|
329
|
+
keyIdentifier,
|
|
330
|
+
notBefore,
|
|
331
|
+
notAfter,
|
|
332
|
+
sign: signTbs,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Rebuilds a CA handle from what a store kept: the certificate, its PKCS#8
|
|
338
|
+
* private key and the common name it was created with. Everything is
|
|
339
|
+
* re-validated exactly as at creation (CA-ness, self-signature, key match,
|
|
340
|
+
* bounded validity, serial), and the subject must be the one this code
|
|
341
|
+
* encodes for that name, because every leaf embeds it byte for byte as its
|
|
342
|
+
* issuer. Throws (withhold) on any mismatch.
|
|
343
|
+
*/
|
|
344
|
+
export function loadCertificateAuthority(stored: {
|
|
345
|
+
certificatePem: string;
|
|
346
|
+
privateKeyPem: string;
|
|
347
|
+
commonName: string;
|
|
348
|
+
}): CertificateAuthority {
|
|
349
|
+
const certificate = new X509Certificate(stored.certificatePem);
|
|
350
|
+
const privateKey = createPrivateKey(stored.privateKeyPem);
|
|
351
|
+
const { notBefore, notAfter } = validityOf(certificate);
|
|
352
|
+
validateCertificateAuthority(certificate, { privateKey, notBefore, notAfter });
|
|
353
|
+
if (certificate.subject !== `CN=${stored.commonName}`) {
|
|
354
|
+
throw new Error("stored certificate authority does not carry its recorded name");
|
|
355
|
+
}
|
|
356
|
+
const signTbs: SignTbs = (tbs) => sign("sha256", tbs, { key: privateKey, dsaEncoding: "der" });
|
|
357
|
+
return Object.freeze({
|
|
358
|
+
certificatePem: certificate.toString(),
|
|
359
|
+
certificate,
|
|
360
|
+
subjectName: distinguishedName(stored.commonName),
|
|
361
|
+
keyIdentifier: subjectKeyIdentifier(certificate.publicKey),
|
|
362
|
+
notBefore,
|
|
363
|
+
notAfter,
|
|
364
|
+
sign: signTbs,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function mintLeafCertificate(options: {
|
|
369
|
+
ca: CertificateAuthority;
|
|
370
|
+
hostname: string;
|
|
371
|
+
now: number;
|
|
372
|
+
validityHours?: number;
|
|
373
|
+
}): LeafCertificate {
|
|
374
|
+
const { ca, hostname, now, validityHours = DEFAULT_LEAF_VALIDITY_HOURS } = options;
|
|
375
|
+
if (canonicalHostname(hostname) !== hostname) {
|
|
376
|
+
throw new Error("leaf hostname must be a canonical DNS name");
|
|
377
|
+
}
|
|
378
|
+
if (!Number.isFinite(now)) throw new RangeError("now must be a finite epoch-ms value");
|
|
379
|
+
if (!(validityHours > 0) || validityHours > MAX_LEAF_VALIDITY_HOURS) {
|
|
380
|
+
throw new RangeError(`leaf validity must be within (0, ${MAX_LEAF_VALIDITY_HOURS}] hours`);
|
|
381
|
+
}
|
|
382
|
+
const notBefore = floorToSecond(now - BACKDATE_MS);
|
|
383
|
+
// Past the CA's notAfter the chain fails anyway; a leaf never claims more.
|
|
384
|
+
const notAfter = Math.min(floorToSecond(now + validityHours * HOUR_MS), ca.notAfter);
|
|
385
|
+
if (now < ca.notBefore || notAfter <= now) {
|
|
386
|
+
throw new Error("certificate authority is outside its validity window");
|
|
387
|
+
}
|
|
388
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
389
|
+
const { subject, sanCritical } = leafSubject(hostname);
|
|
390
|
+
const certificate = new X509Certificate(
|
|
391
|
+
buildCertificate(
|
|
392
|
+
{
|
|
393
|
+
serial: randomSerial(),
|
|
394
|
+
issuer: ca.subjectName,
|
|
395
|
+
subject,
|
|
396
|
+
notBefore,
|
|
397
|
+
notAfter,
|
|
398
|
+
publicKey,
|
|
399
|
+
extensions: [
|
|
400
|
+
// CA:FALSE is the DEFAULT, so DER encodes it as an empty SEQUENCE.
|
|
401
|
+
extension(OID.basicConstraints, true, der.sequence()),
|
|
402
|
+
extension(OID.keyUsage, true, der.namedBitString([KEY_USAGE.digitalSignature])),
|
|
403
|
+
extension(OID.extKeyUsage, false, der.sequence(der.objectIdentifier(OID.serverAuth))),
|
|
404
|
+
extension(
|
|
405
|
+
OID.subjectAltName,
|
|
406
|
+
sanCritical,
|
|
407
|
+
der.sequence(der.implicit(2, der.ia5String(hostname))),
|
|
408
|
+
),
|
|
409
|
+
extension(OID.subjectKeyIdentifier, false, der.octetString(subjectKeyIdentifier(publicKey))),
|
|
410
|
+
extension(
|
|
411
|
+
OID.authorityKeyIdentifier,
|
|
412
|
+
false,
|
|
413
|
+
der.sequence(der.implicit(0, der.octetString(ca.keyIdentifier))),
|
|
414
|
+
),
|
|
415
|
+
],
|
|
416
|
+
},
|
|
417
|
+
ca.sign,
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
validateLeafCertificate(certificate, { authority: ca, hostname, privateKey, notBefore, notAfter });
|
|
421
|
+
return Object.freeze({
|
|
422
|
+
hostname,
|
|
423
|
+
certificatePem: certificate.toString(),
|
|
424
|
+
privateKeyPem: String(privateKey.export({ type: "pkcs8", format: "pem" })),
|
|
425
|
+
certificate,
|
|
426
|
+
notBefore,
|
|
427
|
+
notAfter,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The CA handle the proxy engine takes: leaves minted on demand, cached per
|
|
432
|
+
* hostname, and re-minted an hour before they expire. */
|
|
433
|
+
export interface LeafCertificateCache {
|
|
434
|
+
readonly authority: CertificateAuthority;
|
|
435
|
+
/** A valid leaf for a canonical hostname. Throws (withhold) when minting or
|
|
436
|
+
* self-validation fails. */
|
|
437
|
+
leafFor(hostname: string): LeafCertificate;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function createLeafCertificateCache(options: {
|
|
441
|
+
ca: CertificateAuthority;
|
|
442
|
+
now?: () => number;
|
|
443
|
+
validityHours?: number;
|
|
444
|
+
maxEntries?: number;
|
|
445
|
+
}): LeafCertificateCache {
|
|
446
|
+
const { ca, now = Date.now, validityHours = DEFAULT_LEAF_VALIDITY_HOURS } = options;
|
|
447
|
+
const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? DEFAULT_LEAF_CACHE_ENTRIES));
|
|
448
|
+
const leaves = new Map<string, LeafCertificate>();
|
|
449
|
+
return {
|
|
450
|
+
authority: ca,
|
|
451
|
+
leafFor(hostname: string): LeafCertificate {
|
|
452
|
+
const at = now();
|
|
453
|
+
const cached = leaves.get(hostname);
|
|
454
|
+
if (cached !== undefined) {
|
|
455
|
+
const renewBefore = Math.min(LEAF_RENEW_BEFORE_MS, (cached.notAfter - cached.notBefore) / 4);
|
|
456
|
+
if (at >= cached.notBefore && at < cached.notAfter - renewBefore) return cached;
|
|
457
|
+
}
|
|
458
|
+
leaves.delete(hostname);
|
|
459
|
+
const leaf = mintLeafCertificate({ ca, hostname, now: at, validityHours });
|
|
460
|
+
// Only declared domains reach here (the SNI check runs first), so the
|
|
461
|
+
// cap is a backstop against a pathological declaration, not an LRU.
|
|
462
|
+
if (leaves.size >= maxEntries) {
|
|
463
|
+
const oldest = leaves.keys().next();
|
|
464
|
+
if (oldest.done !== true) leaves.delete(oldest.value);
|
|
465
|
+
}
|
|
466
|
+
leaves.set(hostname, leaf);
|
|
467
|
+
return leaf;
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root-side steps that put a running task behind the secrets proxy
|
|
3
|
+
* (ADR-153 §3 "Order"), run through the task environment's bounded exec:
|
|
4
|
+
* the CA bundle into its trust store, then the pinned names and the NAT
|
|
5
|
+
* chain. task-up runs the same scripts at a container start; these run them
|
|
6
|
+
* on a running task — after a runner restart, when the CA bundle changes, and
|
|
7
|
+
* when the task's proxied domains do.
|
|
8
|
+
*
|
|
9
|
+
* Every step is a reconciliation (safe to repeat), and every failure throws:
|
|
10
|
+
* the caller withholds the task's proxied keys until a step succeeds, since a
|
|
11
|
+
* placeholder whose domain is not pinned, or whose CA the task does not
|
|
12
|
+
* trust, would only fail later and less clearly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { TaskEnvironmentExecResult, TaskEnvironmentHandle } from "../task-environment/types";
|
|
16
|
+
import { ROOT_SCRIPT_PATH, caInstallScript, hostsReconcileScript, redirectChainScript } from "./task-scripts";
|
|
17
|
+
|
|
18
|
+
const STEP_TIMEOUT_MS = 60_000;
|
|
19
|
+
const STEP_OUTPUT_BYTES = 64 * 1024;
|
|
20
|
+
/** The shell itself starts clean, not only the script's children: the
|
|
21
|
+
* inherited PATH and loader variables can name task-writable files. */
|
|
22
|
+
const ROOT_STEP_ENV: Readonly<Record<string, string>> = Object.freeze({
|
|
23
|
+
PATH: ROOT_SCRIPT_PATH,
|
|
24
|
+
LD_PRELOAD: "",
|
|
25
|
+
LD_AUDIT: "",
|
|
26
|
+
LD_LIBRARY_PATH: "",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Exec = Pick<TaskEnvironmentHandle<unknown>, "exec">;
|
|
30
|
+
|
|
31
|
+
function checked(step: string, result: TaskEnvironmentExecResult): void {
|
|
32
|
+
if (result.exitCode === 0 && result.signal === null && !result.stdoutTruncated && !result.stderrTruncated) return;
|
|
33
|
+
const detail =
|
|
34
|
+
Buffer.from(result.stderr).toString("utf8").trim().slice(-512) ||
|
|
35
|
+
(result.signal === null ? `exit ${String(result.exitCode)}` : `signal ${result.signal}`);
|
|
36
|
+
throw new Error(`secrets proxy: ${step} failed in the task: ${detail}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Installs the bundle (`SecretsProxyRuntime.caBundlePem()`) as root. The
|
|
40
|
+
* PEM travels on stdin; it is public, but argv stays short and fixed. */
|
|
41
|
+
export async function installTaskTrust(environment: Exec, bundlePem: string): Promise<void> {
|
|
42
|
+
const result = await environment.exec({
|
|
43
|
+
argv: ["/bin/sh", "-c", caInstallScript()],
|
|
44
|
+
user: "root",
|
|
45
|
+
env: ROOT_STEP_ENV,
|
|
46
|
+
projectEnvProfile: "maintenance",
|
|
47
|
+
stdin: Buffer.from(bundlePem, "utf8"),
|
|
48
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
49
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
50
|
+
});
|
|
51
|
+
checked("installing the proxy CA", result);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Where a task's NET_ADMIN comes from: each privileged exec (Docker); the
|
|
55
|
+
* task VM itself, fixed when it was created (Apple); or the root that runs
|
|
56
|
+
* the step (a machine's sidecar, root on the task's own machine). */
|
|
57
|
+
export type TaskNetAdmin = "per-exec" | "from-creation" | "root";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pins `domains` to the task-local listener: the NAT chain that sends the pin
|
|
61
|
+
* address's :443 to `listenPort` (the task forwarder), and the /etc/hosts
|
|
62
|
+
* block that points the names there. The chain needs NET_ADMIN: on Docker
|
|
63
|
+
* the one privileged exec Uai makes; an Apple task's VM holds it from its
|
|
64
|
+
* creation, so there a root exec has it (and lacking it proves the VM never
|
|
65
|
+
* had it). Ordered so a failure part-way never leaves a name pointing at a
|
|
66
|
+
* pin with nothing behind it: when pinning, the chain goes first; when
|
|
67
|
+
* unpinning, the names go first. With no domains the block is removed and
|
|
68
|
+
* the chain emptied.
|
|
69
|
+
*/
|
|
70
|
+
export async function reconcileTaskPins(
|
|
71
|
+
environment: Exec,
|
|
72
|
+
options: {
|
|
73
|
+
domains: readonly string[];
|
|
74
|
+
listenPort: number;
|
|
75
|
+
netAdmin: TaskNetAdmin;
|
|
76
|
+
/** Redirect only this user's traffic (a machine, where the sidecar's own
|
|
77
|
+
* upstream connections share the network); everyone's otherwise. */
|
|
78
|
+
uidOwner?: string;
|
|
79
|
+
/** Pin the names in this view of the hosts, built from `from`, rather
|
|
80
|
+
* than in /etc/hosts itself (a machine, whose /etc/hosts the root
|
|
81
|
+
* sidecar shares). The pins count only while `proven` says every task
|
|
82
|
+
* process sees the view. */
|
|
83
|
+
hostsView?: { readonly path: string; readonly from: string; readonly proven: () => boolean };
|
|
84
|
+
},
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const enabled = options.domains.length > 0;
|
|
87
|
+
// Both generated first: a domain that is not canonical and proxiable is
|
|
88
|
+
// refused before anything runs.
|
|
89
|
+
const hosts =
|
|
90
|
+
options.hostsView === undefined
|
|
91
|
+
? hostsReconcileScript(options.domains)
|
|
92
|
+
: hostsReconcileScript(options.domains, options.hostsView.path, { sourcePath: options.hostsView.from });
|
|
93
|
+
const chain = redirectChainScript({
|
|
94
|
+
toPort: options.listenPort,
|
|
95
|
+
enabled,
|
|
96
|
+
netAdminFromCreation: options.netAdmin === "from-creation",
|
|
97
|
+
...(options.uidOwner === undefined ? {} : { uidOwner: options.uidOwner }),
|
|
98
|
+
});
|
|
99
|
+
const hostsStep = async (): Promise<void> => {
|
|
100
|
+
const pinned = await environment.exec({
|
|
101
|
+
argv: ["/bin/sh", "-c", hosts],
|
|
102
|
+
user: "root",
|
|
103
|
+
env: ROOT_STEP_ENV,
|
|
104
|
+
projectEnvProfile: "maintenance",
|
|
105
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
106
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
107
|
+
});
|
|
108
|
+
checked("pinning the proxied names", pinned);
|
|
109
|
+
};
|
|
110
|
+
const chainStep = async (): Promise<void> => {
|
|
111
|
+
const redirected = await environment.exec({
|
|
112
|
+
argv: ["/bin/sh", "-c", chain],
|
|
113
|
+
user: "root",
|
|
114
|
+
env: ROOT_STEP_ENV,
|
|
115
|
+
...(options.netAdmin === "per-exec" ? { privileged: true as const } : {}),
|
|
116
|
+
projectEnvProfile: "maintenance",
|
|
117
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
118
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
119
|
+
});
|
|
120
|
+
checked("redirecting the proxied names", redirected);
|
|
121
|
+
};
|
|
122
|
+
if (enabled) {
|
|
123
|
+
await chainStep();
|
|
124
|
+
await hostsStep();
|
|
125
|
+
// With a view, pins count only while every task process sees it: one
|
|
126
|
+
// a runner without the view started resolves the real names and goes
|
|
127
|
+
// around the proxy. Until then the step fails and the keys stay withheld.
|
|
128
|
+
if (options.hostsView !== undefined && !options.hostsView.proven()) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"secrets proxy: processes this task started before its hosts view may still run; " +
|
|
131
|
+
"its proxied keys stay withheld until its machine reboots",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
await hostsStep();
|
|
136
|
+
await chainStep();
|
|
137
|
+
}
|
|
138
|
+
}
|