feat(sdk): add sdk.getRootCa for this server's root CA - #3619
Conversation
A service that dials an address the *user* supplies — a monitor target, a notification endpoint, a webhook — cannot resolve a bridge address for it, and users cannot see bridge addresses anyway, so what they paste is whatever StartOS showed them: on the LAN, always HTTPS with a certificate chaining to this server's root CA. No container trusts that root, so the dial fails verification. The only way to obtain that root was to mint a certificate you didn't want and take the last link of the chain, and packages doing it by hand have taken the wrong link. hermes-agent and openclaw slice the tail correctly; dev-env takes `certs[0]` — the leaf — and installs it as a trust anchor, so it trusts nothing while looking correct and no error is ever raised. That is one in three on a call whose failure is invisible. Implemented over `getSslCertificate` rather than as a new OS effect, so it works on shipping StartOS instead of gating callers on the next release. The pretext hostname is the OS bridge IP: the sole hostname admitted by the allowlist's compiled-in `HOST_IP` subnet check rather than by whatever the gateway table currently holds, which is what admits `127.0.0.1`. If the OS later exposes the root directly, only `GetRootCa.fetch` changes — the SDK surface is already right. Returned as a Watchable for consistency with the other readers, not to fix anything: the root is minted once at setup with a ten-year life and there is no rotation flow, so in practice it never changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eb4b1f6 to
5797527
Compare
| * into its own trust store or via a runtime that takes one (e.g. Node's | ||
| * `NODE_EXTRA_CA_CERTS`). | ||
| */ | ||
| export class GetRootCa extends Watchable<string> { |
There was a problem hiding this comment.
Expires in 10 years... Do we really need to make it watchable?
There was a problem hiding this comment.
No — dropped it. It's a plain Promise<string> now, same as getOsIp: minted once at setup, ten-year life, no rotation flow, so there's nothing to subscribe to.
| // to hold. Collapses to a single call if the OS ever exposes the root direct. | ||
| protected async fetch(callback?: () => void) { | ||
| const [, , rootCa] = await this.effects.getSslCertificate({ | ||
| hostnames: [await this.effects.getOsIp()], |
There was a problem hiding this comment.
Can this be an empty array? Might be nice to not have to make 2 separate rpc calls
There was a problem hiding this comment.
Yes, and it now is — one RPC instead of two. The validation loop has nothing to check, and cert_for mints the discarded leaf with SANInfo::new over an empty set; make_leaf_cert already falls back to CN=localhost with no DNS name, and an empty SubjectAlternativeName builds fine on openssl 0.10.80 (I compiled a throwaway crate against the pinned version to be sure — the modern stack-based build encodes an empty GENERAL_NAMES rather than choking on an empty config string, which is what would have failed on older rust-openssl).
Nice side effect: the leaf it leaves behind is now inert rather than a plausible-looking certificate for the OS's own bridge address.
… set Both per review. Not watchable: the root is minted once at setup and lives ten years with no rotation flow, so there is nothing to subscribe to. Matches `getOsIp`, the other reader of a value that never moves, which the docs already describe as "a plain Promise, not reactive". Empty hostname set instead of the OS bridge IP, which drops the `getOsIp` round trip. The OS accepts it: the validation loop has nothing to check, and `cert_for` mints the discarded leaf with `SANInfo::new` over an empty set — `make_leaf_cert` already falls back to CN=localhost when there is no DNS name, and an empty `SubjectAlternativeName` builds (verified against openssl 0.10.80, the version the workspace resolves; the modern stack-based `build` encodes an empty GENERAL_NAMES rather than failing on an empty config string). The leaf is now inert rather than a plausible-looking certificate for the OS's own bridge address, which is the better artifact to leave in the shared cert store. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds
sdk.getRootCa(effects), returning this server's root CA certificate as PEM.Why
A service that dials an address the user supplies — a monitor target, a notification endpoint, a webhook — can't resolve a bridge address for it, and users can't see bridge addresses anyway (#3618). So what they paste is whatever StartOS showed them, which on the LAN is always HTTPS with a certificate chaining to this server's root CA. No container trusts that root, so the dial fails verification with
SELF_SIGNED_CERT_IN_CHAIN.Until now the only way to get that root was to mint a certificate you didn't want and take the last link of the chain — and packages doing it by hand have taken the wrong link:
hermes-agentcerts.slice(-1)openclawcerts.slice(-1)dev-envcerts[0]One in three wrong, on a call whose failure is invisible. That is the whole argument for this change.
Implementation note
Built over
getSslCertificaterather than as a new OS effect, so it works on shipping StartOS instead of gating every caller on the next release. The pretext hostname is the OS bridge IP — the one hostname admitted by the allowlist's compiled-inHOST_IPsubnet check (net/ssl.rs) rather than by whatever the gateway table currently holds, which is what admits127.0.0.1.If the OS later exposes the root directly, only
GetRootCa.fetchchanges; the SDK surface is already right and no package needs touching.It returns a
Watchableto match the other readers, not to fix a reactivity problem — the root is minted once at setup (AccountInfo::new,setup.rs) with a ten-year life and there is no rotation flow, so in practice it never changes.Changes
shared-libs/ts-modules/start-core/lib/util/GetRootCa.ts— newWatchable<string>projects/start-sdk/lib/StartSdk.ts—sdk.getRootCadocs/src/service-to-service.md— new "Trusting this server's certificates" section, with the "don't indexgetSslCertificate" warning and the rootfs-not-volume placement ruledocs/src/recipes.md,CHANGELOG.md(2.0.10, unreleased)Verified
tscclean inshared-libs/ts-modules/start-coreandprojects/start-sdk(the latter built against this branch'sstart-coredist, not the installed one)prettier --checkclean on every changed fileNot exercised against a running service — the SDK surface is a thin wrapper over an effect that already works, but the trust install itself is verified end-to-end in the consumer PR below.
Follow-on
sdk.getRootCa.