Skip to content

feat(sdk): add sdk.getRootCa for this server's root CA - #3619

Open
MattDHill wants to merge 2 commits into
masterfrom
feat/sdk-get-root-ca
Open

feat(sdk): add sdk.getRootCa for this server's root CA#3619
MattDHill wants to merge 2 commits into
masterfrom
feat/sdk-get-root-ca

Conversation

@MattDHill

@MattDHill MattDHill commented Aug 3, 2026

Copy link
Copy Markdown
Member

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:

Package Code Result
hermes-agent certs.slice(-1) correct
openclaw certs.slice(-1) correct
dev-env certs[0] the leaf — installed as a trust anchor, trusts nothing, raises no error

One in three wrong, on a call whose failure is invisible. That is the whole argument for this change.

Implementation note

Built over getSslCertificate rather 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-in HOST_IP subnet check (net/ssl.rs) 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 and no package needs touching.

It returns a Watchable to 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 — new Watchable<string>
  • projects/start-sdk/lib/StartSdk.tssdk.getRootCa
  • docs/src/service-to-service.md — new "Trusting this server's certificates" section, with the "don't index getSslCertificate" warning and the rootfs-not-volume placement rule
  • docs/src/recipes.md, CHANGELOG.md (2.0.10, unreleased)

Verified

  • tsc clean in shared-libs/ts-modules/start-core and projects/start-sdk (the latter built against this branch's start-core dist, not the installed one)
  • start-core TS suite: 86 passed, 9 skipped
  • prettier --check clean on every changed file

Not 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

  • Start9Labs/uptime-kuma-startos#12 is the first consumer; it currently hand-rolls this and can collapse to sdk.getRootCa.
  • #3618 is the underlying gap — if internal addresses become visible in the UI, most callers won't need this at all.

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>
* 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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expires in 10 years... Do we really need to make it watchable?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be an empty array? Might be nice to not have to make 2 separate rpc calls

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@MattDHill
MattDHill requested a review from dr-bonez August 3, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants