Skip to content

fix(router): let on-demand TLS gate issuance ahead of the cert registry - #50

Merged
mhenrixon merged 1 commit into
dashfrom
feature/on-demand-tls-integration
Jul 29, 2026
Merged

fix(router): let on-demand TLS gate issuance ahead of the cert registry#50
mhenrixon merged 1 commit into
dashfrom
feature/on-demand-tls-integration

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

Issue #13 asked us to port basecamp#63 (on-demand TLS). Upstream got there first: PR basecamp#225 landed on 2026-07-16 as an explicit port of #63, upstream closed #63 as superseded, and our July 26 sync merge (0f93ce3) brought it into dash byte-for-byte. git diff upstream/main dash -- internal/server/tls_on_demand.go is empty.

So this PR is not the implementation — it is the verification #13 still needed, plus the one real gap it turned up.

The gap

That sync merge reconciled on-demand TLS with two of the fork's three cert subsystems:

  • service.go:584 — an explicit --tls-on-demand-url wins over the proxy-wide SAN manager
  • service_options_validation.go:36tls-domains-source and tls-on-demand-url are rejected together

It did not reconcile it with the certificate registry. Router.GetCertificate consulted certRegistry first (router.go:475), and CertificateRegistry.GetCertificate provisions on lookup (cert_registry.go:284provisionCertificate) — it has no host policy at all.

On a proxy running with the registry enabled (--acme-email + a DNS provider) and a service deployed with --tls-on-demand-url, every SNI reaching the listener started an ACME order before the ask endpoint was consulted. The gate the feature exists to provide was bypassed, and attacker-chosen SNI could drive unbounded orders against the ACME account.

The fix

Resolve the service before the registry, and when it carries an on-demand URL hand off to its cert manager directly. Hosts routed to a host-scoped service still go through the registry unchanged.

This mirrors the precedent already set in createCertManager.

Test plan

  • TestRouter_GetCertificate_OnDemandServiceGatesRegistryProvisioning — the registry drains pending domains under a root as soon as it begins provisioning, so a seeded sibling surviving proves it never started. Fails on dash, passes here.
  • TestRouter_GetCertificate_RegistryStillServesHostScopedServices — an on-demand catch-all must not disable the registry wholesale.
  • TestRouter_DeployService_CatchAllTLSServicesConflict — dynamic domains and on-demand TLS both bind the host-less catch-all; nothing downstream breaks the tie, so they must collide at deploy. Already correct; now covered.
  • make test green (752 tests in internal/server)
  • go test -race ./internal/server/ clean
  • gofmt -l internal/ cmd/ clean, go vet ./... clean

Performance

Router.GetCertificate runs on every TLS handshake, and the fix moves a service-map lookup ahead of the registry, so registry-served handshakes now pay for it. No benchmark existed for this path; BenchmarkRouter_GetCertificate is added here and was run on both sides, same machine (M2 Max), -count 5:

ns/op (median) B/op allocs/op
dash baseline 47.8 0 0
this branch 67.5 0 0

About +20 ns/op, no new allocations. That is proxy-level cost only — it is noise against the microseconds-to-milliseconds of the handshake it sits inside, and it buys back an unbounded ACME order on the same path. Handshakes that were already falling through to a per-service cert manager pay nothing new; that lookup was always on their path.

Refs #13

Deviations & judgment calls

  • The issue's premise was stale. R4: On-demand TLS with ask endpoint #13 says "port implement the On-Demand TLS feature basecamp/kamal-proxy#63" and "integrate with dash's CertificateRegistry rather than the PR's standalone path." Upstream shipped it instead, using an autocert.HostPolicy on the per-service cert manager, and dash already had it. I did not re-implement it against the registry — upstream's approach is in production and reverting to the issue's design would be a rewrite with no user-visible gain. The registry integration became a precedence question, which is what this PR answers.
  • Fixed at the router, not in the registry. The alternative was giving CertificateRegistry its own host-policy hook. That is a larger change to fork-only code with no upstream safety net, and it would duplicate the gate that already exists in the on-demand checker. Skipping the registry for on-demand-served hosts is narrower and matches the existing createCertManager precedent. The tradeoff: the registry is now unreachable for any host an on-demand catch-all catches. That is correct — those hosts are precisely the ones the ask endpoint should decide — but it is a real behavior change worth knowing about.
  • Registry cert-serving is skipped too, not just provisioning. For an on-demand host the router no longer even checks whether the registry already holds a cert. A registry only holds certs for domains it provisioned, and for on-demand hosts that could only have happened via the bug being fixed, so there is nothing legitimate to lose. Called out because a narrower fix (skip provisioning, still serve cached) was possible and I chose not to.
  • Test seam. The tests build a registry via NewCertificateRegistry + ready = true without Initialize, which leaves both solvers nil — a ready registry that provisions with no network. The pending-domain drain is used as the provisioning-attempt observable rather than adding a counter to production code. Existing cert_registry_test.go already sets ready directly, so this follows house practice.
  • Test cache paths. defaultServiceOptions leaves ACMECachePath empty, so ScopedCachePath() resolves to a sha256-named directory relative to the working directory — deploying a TLS service in a test writes into the repo. The new tests set ACMECachePath to a t.TempDir(). Pre-existing tests (e.g. TestRouter_RestoreLastSavedState_TLSOnDemandURL) still have this; not fixed here to keep the diff scoped, but worth a follow-up.
  • R3: mTLS client certs (--tls-client-ca-path) #12 (mTLS) deliberately not included. You chose verification-first. R3: mTLS client certs (--tls-client-ca-path) #12 is genuinely un-done upstream (PR Add mutual TLS (mTLS) support basecamp/kamal-proxy#204 open since April, unmerged, zero ClientCAs in upstream/main) and lands next, on top of this.

Upstream shipped on-demand TLS in basecamp#225, which the July 26
sync merged into dash. That merge reconciled it with the SAN cert manager and
with tls-domains-source, but not with the fork's certificate registry.

Router.GetCertificate consulted the registry first, and the registry provisions
on lookup -- it has no host policy. So on a proxy running with the registry
enabled, any SNI reaching a service deployed with --tls-on-demand-url started an
ACME order before the ask endpoint was consulted. The gate the feature exists to
provide was bypassed, and attacker-chosen SNI could drive unbounded orders
against the ACME account.

Resolve the service first and hand off to its cert manager when it carries an
on-demand URL, so the endpoint decides issuance for every host that service
catches. Hosts routed to a host-scoped service still go to the registry.

This mirrors createCertManager, where an explicit --tls-on-demand-url already
wins over the proxy-wide SAN manager.

Refs #13
@mhenrixon mhenrixon self-assigned this Jul 29, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 29, 2026
@mhenrixon
mhenrixon merged commit 55a7367 into dash Jul 29, 2026
2 checks passed
@mhenrixon
mhenrixon deleted the feature/on-demand-tls-integration branch July 29, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant