Skip to content

feat(proxy): expose on-demand TLS, mTLS client CA and the ACME cache path - #85

Merged
mhenrixon merged 1 commit into
dashfrom
issue-74-tls-surface
Jul 30, 2026
Merged

mhenrixon merged 1 commit into
dashfrom
issue-74-tls-surface

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

A proxy.tls block for the three TLS deploy flags that shipped in the proxy with no deploy.yml key.

proxy:
  ssl: true
  tls:
    on_demand_url: https://app.example.com/api/v1/tls/ask
    client_ca_path: config/tls/client-ca.pem
    acme_cache_path: /home/kamal-proxy/.config/kamal-proxy/acme
  • lib/kamal/configuration/proxy.rb — the tls block, a tls_options group merged into deploy_options, and host_client_ca/container_client_ca built from the existing tls_path plumbing (split into tls_file_path so a client CA no longer has to pretend a server certificate was configured).
  • lib/kamal/configuration/loadbalancer.rb — re-adds tls_options at the edge.
  • lib/kamal/configuration/validator/proxy.rb — mirrors kamal-proxy's exclusivity rules; exempts on_demand_url from the "Must set a host to enable automatic SSL" check.
  • lib/kamal/cli/app/ssl_certificates.rb — uploads the CA bundle alongside the server certificate.
  • test/proxy_flag_coverage_test.rb — unions two maximal fixtures instead of one.

Closes #74

Where each value lives

client_ca_path and acme_cache_path look alike and are opposites:

Whose filesystem What the flag gets
client_ca_path the machine running kamal the container path, after the file is uploaded into .kamal/proxy/apps-config/<service>/tls/<role>/client-ca.pem
acme_cache_path the proxy container the value, verbatim

A deploy-host path for the CA cannot work: the flag is an argument to docker exec kamal-proxy kamal-proxy deploy, and a deploy cannot add a mount to a proxy container that is already running and shared by every app on the host. Uploading into the apps-config tree the proxy already mounts is the shape error_pages_path and ssl.certificate_pem both use.

Test plan

  • bundle exec rubocop --parallel — clean, 209 files
  • Unit suite — 1225 runs; 4 failures + 3 errors, all in builder_test/build_test, byte-identical on pristine dash HEAD (host-arch dependent, pass in CI)
  • Deploy with on_demand_url pointing at a real ask endpoint; confirm a cert issues for an approved hostname and is refused for a rejected one
  • Deploy with client_ca_path; confirm a client without a certificate is rejected and one signed by the CA is accepted
  • Confirm acme_cache_path inside the kamal-proxy-config volume survives kamal proxy reboot
  • Load-balanced deploy: confirm the three flags appear on the load balancer's deploy and not on the per-app one

Automated coverage

Test Proves
on_demand_url becomes --tls-on-demand-url + reaches the generated deploy command AC1
client_ca_path is translated to a container path / is role-scoped like the server certificate AC2 — the flag carries the container path
boot with an mTLS client CA (CLI) AC2 end-to-end — the bundle is uploaded into the mounted tree and the flag matches
on_demand_url cannot be combined with hosts / a custom certificate / tls_domains / requires ssl AC3 — fails at config time, mirroring ServiceOptions.Validate
ssl without a host is allowed when on_demand_url is set the existing host check does not reject on-demand's required shape
no tls block leaves the deploy command unchanged AC4 — pure addition
the tls flags move to the load balancer when load balancing the flags land where TLS actually terminates
every kamal-proxy deploy flag is exposed in deploy.yml or waived the three flags are really wired, not just accessors

Deviations & judgment calls

Deviations from the issue

  • AC3 asked validation to state "which wins". Neither wins: kamal-proxy's ServiceOptions.Validate (internal/server/service.go:218-253) hard-rejects on_demand_url alongside hosts, a custom certificate, canonical-host or tls_domains, and requires TLS. The gem mirrors those rules rather than inventing a precedence the proxy would then refuse to honour.
  • The maximal fixture could not absorb on_demand_url — it sets hosts, a custom certificate and tls_domains, all three forbidden alongside on-demand. test/proxy_flag_coverage_test.rb now unions the flags emitted by two maximal fixtures. The alternative was waiving tls-on-demand-url as unprovable, which would ship this issue's headline feature unverified — exactly what the canary from test(proxy): fail the build when kamal-proxy grows a flag the gem cannot emit #83 exists to stop.
  • client_ca_path is read locally, not from the deploy host. The issue's "pointing at a host file" is ambiguous; see the table above for why a deploy-host path is not implementable at deploy time.

Judgment calls

  • The three flags are stripped when load balancing and re-added by the load balancer, like host, tls and tls-domains-*. Not in the issue. Leaving it out would have made the feature a silent no-op for load-balanced dash users, who are the majority — an ask endpoint on a proxy that never terminates TLS is never consulted.
  • client_ca_path existence is checked at config time. The upload would fail anyway, but mid-deploy, after containers may have started. mTLS quietly not being enforced is a security outcome, so it fails before the first SSH connection.
  • canonical_host exclusivity is not implemented. The proxy also rejects on-demand + canonical host, but canonical_host is not a deploy.yml key yet (R7 backlog, Expose header rules, redirects, rewrites and error interception in deploy.yml #78). Nothing to check against until it lands — worth remembering when Expose header rules, redirects, rewrites and error interception in deploy.yml #78 does.
  • tls_path was split rather than relaxed. Proxy#tls_path is guarded by custom_ssl_certificate?; the guard now sits in tls_path and the bare path construction in tls_file_path, so the client CA reuses the path shape without inheriting an unrelated condition.

…path

Three TLS deploy flags shipped in the proxy with no deploy.yml key. On-demand
TLS is the largest single differentiator in the roadmap and there was no way to
turn it on; mTLS was in the same position.

    proxy:
      ssl: true
      tls:
        on_demand_url: https://app.example.com/api/v1/tls/ask
        client_ca_path: config/tls/client-ca.pem
        acme_cache_path: /home/kamal-proxy/.config/kamal-proxy/acme

Three things the issue left open, decided here:

client_ca_path names a file on the machine running kamal, not on the deploy
host. A deploy host path cannot work — the flag is an argument to `docker exec
kamal-proxy kamal-proxy deploy`, and a deploy cannot add a mount to a container
that is already running and shared by every app on the host. The bundle is
uploaded into the apps-config tree the proxy already mounts, the same shape
error_pages_path and ssl.certificate_pem use, and the flag gets the container
path. acme_cache_path is the opposite: it names a directory inside the
container, so it passes through untranslated.

There is no precedence between on_demand_url and host/hosts to document —
kamal-proxy hard-rejects the combination, along with a custom certificate and
tls_domains, and requires TLS enabled. The gem now fails on the same rules at
config time rather than after the SSH round-trip. The existing "Must set a host
to enable automatic SSL" check had to stand aside for on-demand, whose required
shape is ssl with no host.

All three are stripped from the per-app deploy when load balancing and re-added
by the load balancer, like host/tls/tls-domains. TLS terminates at the edge, so
an ask endpoint or a client CA on a proxy that never sees a handshake does
nothing — that would have made the feature a silent no-op for load-balanced
deploys.

on_demand_url cannot coexist with the maximal fixture's hosts, custom cert and
tls_domains, so the flag-coverage canary now unions two maximal fixtures. The
alternative was waiving the headline flag as unprovable.

Closes #74
@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 164fd18 into dash Jul 30, 2026
9 checks passed
mhenrixon added a commit that referenced this pull request Aug 3, 2026
## Summary

The 3.0.0 release train's proxy side is publishing: v1.0.0.1 carries the
certificate-system unification (kamal-proxy#84/#85 - no issuance for a name
nobody asked for) and the disk-backed cache store (kamal-proxy#83).
MINIMUM_VERSION moves to it; v1.0.0.0 must never be the minimum (unbounded
SNI issuance hole).

The flag manifest was regenerated from a binary built at the v1.0.0.1 tag
(bin/sync-proxy-flags --binary): the only change is the version stamp -
v1.0.0.1 adds no deploy or run flags, so the coverage canary had nothing
new to decide. The integration seed (setup.sh) reads the constant
dynamically and needs no edit.

Full bin/test against the published ghcr image (the E4 handoff gate) runs
before bin/release-dash 3.0.0, per the release ordering.

## Verification

- [x] bundle exec rubocop --parallel passes
- [x] unit suite passes (1311 runs) - flag canary green against the
      regenerated manifest

Refs #93 (workstream G)
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.

Expose on-demand TLS, mTLS client CA and ACME cache path in deploy.yml

1 participant