Skip to content

Add Caddy-style tls internal local CA#54

Merged
chrislearn merged 2 commits into
mainfrom
chris/tls-internal-local-ca
May 20, 2026
Merged

Add Caddy-style tls internal local CA#54
chrislearn merged 2 commits into
mainfrom
chris/tls-internal-local-ca

Conversation

@chrislearn
Copy link
Copy Markdown
Member

Summary

Adds a Caddy-style local CA so users can serve HTTPS for localhost / internal hosts without ACME and without managing PEM files. Mirrors the feature you can quote from Caddy:

https://local.host:443 {
    tls internal
    ...
}
  • On first start, gatel generates an ECDSA P-256 root (10 years) + intermediate (7 days) under the platform user-data dir.
  • Leaves are signed by the intermediate on demand at TLS handshake time, valid 12 h, cached in memory, re-issued when ≤20% lifetime remains.
  • Sites opt in per-site (tls internal) or globally (tls { internal }, used as a fallback only when ACME is not configured). Per-site tls internal always wins.
  • The composite resolver now consults: manual certs → local CA → ACME / on-demand. Existing setups are unchanged.
  • Two new CLI subcommands install / remove the root in the OS trust store:
    • Windows: writes to current-user Root via schannel — no UAC.
    • macOS: shells out to security add-trusted-cert -k login.keychain-db.
    • Linux: drops the PEM into the distro's anchors dir and runs update-ca-certificates / update-ca-trust extract.

Test plan

  • cargo test --lib — 177 passed (3 new in tls::local_ca).
  • Local HTTPS smoke test: gatel run with tls internal on localhost, curl --resolve localhost:18443:127.0.0.1 https://localhost:18443/ returns 200.
  • gatel trust installs the root into the Windows current-user Root store; verified with PowerShell Get-ChildItem Cert:\CurrentUser\Root and certutil -user -store Root.
  • gatel untrust removes it.
  • After gatel trust, curl --ssl-no-revoke https://localhost:18443/ validates the chain end-to-end.
  • macOS path — not exercised on this dev box; relies on security CLI and follows mkcert/Caddy conventions.
  • Linux path — not exercised; uses standard distro anchor dirs.

Docs

  • New section in docs/en/tls-and-acme.md and docs/zh/tls-and-acme.md covering quick start, storage layout, lifetimes, and the gatel trust flow.
  • New example: examples/tls-internal.kdl.
  • README + README.zh feature bullet updated.
  • CHANGELOG.

🤖 Generated with Claude Code

Generates a 10-year ECDSA P-256 root + 7-day intermediate on first start,
signs 12-hour leaf certificates on demand at TLS handshake time, and
exposes `gatel trust` / `gatel untrust` subcommands to install or remove
the root in the OS trust store (Windows via schannel without UAC; macOS
shells out to `security`; Linux drops into the distro CA anchors dir and
runs `update-ca-trust`/`update-ca-certificates`).

Opt in per-site with `tls internal`, or globally via `tls { internal }`
as a fallback when ACME is not configured. The composite cert resolver
now consults the local CA between manual certs and the ACME / on-demand
paths, so existing setups are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d64e6e6941

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/core/src/tls/manager.rs Outdated
Comment on lines +441 to +442
if opted_in || self.global_internal {
return resolve_local_ca(local_ca, &normalized);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate global internal fallback when ACME is enabled

Avoid resolving tls internal for every SNI whenever tls { internal } is present. In this resolver path, self.global_internal causes the local CA branch to run before ACME for all hostnames, so a config that enables both ACME and internal will serve local-CA certs instead of ACME certs. This contradicts the documented "internal as fallback only when ACME isn't configured" behavior and can break public HTTPS deployments that expect ACME-issued chains.

Useful? React with 👍 / 👎.

Comment on lines +317 to +318
let mut guard = self.resolver.internal_hosts.write().await;
*guard = new_internal;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist global/internal CA state on TLS reload

Make reload update all local-CA resolver state, not just hostnames. reload() recomputes global_internal and new_internal, but only writes internal_hosts; it never updates self.resolver.global_internal and cannot initialize self.resolver.local_ca if internal TLS is enabled after startup. As a result, toggling tls internal via hot reload does not reliably take effect (or disable) until a full process restart.

Useful? React with 👍 / 👎.

- P1: gate the global `tls { internal }` fallback so it only kicks in
  when ACME is *not* also configured. With ACME present the local-CA
  branch would otherwise shadow ACME-issued certs for every unknown
  SNI, breaking public deployments.
- P2: make the resolver's `local_ca` an `ArcSwapOption<LocalCa>` and
  `global_internal` an `AtomicBool` so a hot reload can both toggle
  the flag and bootstrap a `LocalCa` instance on first need, without
  a process restart.
- Add regression tests covering both: ACME + global internal must not
  produce a local CA at startup, and a config that flips a site to
  `tls internal` must bring the local CA up on reload.
- Fix clippy `collapsible_if` violations flagged by CI on Rust 1.95.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chrislearn chrislearn merged commit e46bafa into main May 20, 2026
11 checks passed
@chrislearn chrislearn deleted the chris/tls-internal-local-ca branch May 20, 2026 03:26
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.

1 participant