No way to serve a name no public CA will issue for #50

Open
opened 2026-10-23 13:16:18 +00:00 by james.oates · 1 comment
Owner

The gap

ER can only serve a name Let's Encrypt will issue for. There is no way to serve HTTPS on anything else:

  • An RFC 6761/6762 reserved name (.localhost, .local, .test, .invalid, .example) — skipped by is_issuable, marked NotCertifiable, handshake refused.
  • An internal-only name in a zone no public CA can validate.
  • An IP literal, or a bare single label.
  • A certificate the operator already holds, issued by their own CA or bought from a commercial one.

The self-signed fallback (TLS_FALLBACK_CERT) covers only names ER was never configured for. A configured route whose name cannot be certified gets a refused handshake, deliberately — ER must never serve one name's certificate for another (#38).

Can a public CA issue for grafana.localhost?

No, and this is settled rather than a limitation of Let's Encrypt specifically. CA/Browser Forum Baseline Requirements forbid issuing for names outside the public DNS, and .localhost is reserved by RFC 6761 and is not in the root zone — so there is nothing for any challenge type to validate. Every publicly trusted CA is bound by this.

Can a private ACME CA?

Yes. step-ca, Caddy's internal CA and a self-hosted Boulder are ordinary ACME servers that will issue for any name, including reserved ones. They are not publicly trusted, so clients must trust their root — which for local dev or an internal estate is exactly the trade you want, and is strictly better than a self-signed certificate because one root covers every name.

ER already speaks ACME, so this is a configuration gap rather than a new subsystem. Two things block it:

  1. acme::directory_url returns only LetsEncrypt::Staging or LetsEncrypt::Production. [acme] takes email and staging, with no way to name a directory.
  2. is_issuable skips reserved suffixes unconditionally, before any order is attempted.

Two candidate designs — not decided

James wants to think about the how before either is built; this issue records the options and the facts, not a chosen approach.

(a) Configurable ACME directory. Add directory_url to [acme], defaulting to Let's Encrypt, plus a trust anchor for ER's own connection to it. Allow reserved names when a non-public directory is configured, since the reason for skipping them is that public CAs cannot issue.

  • Reuses the whole existing path: one account per directory (already keyed that way, migration 013), the same reconciler, the same renewal, the same per-name issuance.
  • Consistent with the httpreq decision — follow the standard, do not care who implements it.
  • Costs: the skip rule becomes conditional, and a private directory usually needs its own CA trust configured.

(b) An operator-supplied certificate source. A route names a certificate and key file that ER loads and watches.

  • Serves anything, including a commercial certificate or a name with no ACME server at all. nginx's most basic TLS feature, which ER lacks.
  • Was considered on 2026-10-20 and dropped from the Kyoshi gating list, but the product gap was acknowledged as real then.
  • Costs: a second cert source to reconcile against the first, and the mode question — does a file override a derived certificate, or is naming both an error?

(a) is the smaller change and keeps one mechanism. (b) is the only answer for a certificate that no ACME server will ever issue. They are not exclusive.

Not blocking v2.0.0

Nothing regresses for a name a public CA can issue for. The one behaviour change is that a .localhost route served the self-signed fallback on HTTPS before and now refuses — recorded in the release notes. Local dev should use a real name under a zone ER can get a certificate for, which is what devinfra is moving to (*.azula.oates.ws, IsoHex/devinfra#5).

## The gap ER can only serve a name Let's Encrypt will issue for. There is no way to serve HTTPS on anything else: - An **RFC 6761/6762 reserved name** (`.localhost`, `.local`, `.test`, `.invalid`, `.example`) — skipped by `is_issuable`, marked `NotCertifiable`, handshake refused. - An **internal-only name** in a zone no public CA can validate. - An **IP literal**, or a bare single label. - A certificate the **operator already holds**, issued by their own CA or bought from a commercial one. The self-signed fallback (`TLS_FALLBACK_CERT`) covers only names ER was never configured for. A *configured* route whose name cannot be certified gets a refused handshake, deliberately — ER must never serve one name's certificate for another (#38). ## Can a public CA issue for `grafana.localhost`? No, and this is settled rather than a limitation of Let's Encrypt specifically. CA/Browser Forum Baseline Requirements forbid issuing for names outside the public DNS, and `.localhost` is reserved by RFC 6761 and is not in the root zone — so there is nothing for any challenge type to validate. Every publicly trusted CA is bound by this. ## Can a *private* ACME CA? **Yes.** `step-ca`, Caddy's internal CA and a self-hosted Boulder are ordinary ACME servers that will issue for any name, including reserved ones. They are not publicly trusted, so clients must trust their root — which for local dev or an internal estate is exactly the trade you want, and is strictly better than a self-signed certificate because one root covers every name. ER already speaks ACME, so this is a configuration gap rather than a new subsystem. Two things block it: 1. `acme::directory_url` returns only `LetsEncrypt::Staging` or `LetsEncrypt::Production`. `[acme]` takes `email` and `staging`, with no way to name a directory. 2. `is_issuable` skips reserved suffixes unconditionally, before any order is attempted. ## Two candidate designs — **not decided** James wants to think about the *how* before either is built; this issue records the options and the facts, not a chosen approach. **(a) Configurable ACME directory.** Add `directory_url` to `[acme]`, defaulting to Let's Encrypt, plus a trust anchor for ER's own connection to it. Allow reserved names when a non-public directory is configured, since the reason for skipping them is that *public* CAs cannot issue. - Reuses the whole existing path: one account per directory (already keyed that way, migration 013), the same reconciler, the same renewal, the same per-name issuance. - Consistent with the httpreq decision — follow the standard, do not care who implements it. - Costs: the skip rule becomes conditional, and a private directory usually needs its own CA trust configured. **(b) An operator-supplied certificate source.** A route names a certificate and key file that ER loads and watches. - Serves anything, including a commercial certificate or a name with no ACME server at all. nginx's most basic TLS feature, which ER lacks. - Was considered on 2026-10-20 and dropped from the Kyoshi gating list, but the product gap was acknowledged as real then. - Costs: a second cert source to reconcile against the first, and the mode question — does a file override a derived certificate, or is naming both an error? (a) is the smaller change and keeps one mechanism. (b) is the only answer for a certificate that no ACME server will ever issue. They are not exclusive. ## Not blocking v2.0.0 Nothing regresses for a name a public CA can issue for. The one behaviour change is that a `.localhost` route served the self-signed fallback on HTTPS before and now refuses — recorded in the release notes. Local dev should use a real name under a zone ER can get a certificate for, which is what devinfra is moving to (`*.azula.oates.ws`, IsoHex/devinfra#5).
Author
Owner

Agreed design (James, 2026-10-23) — still not scheduled

Option (a), the configurable ACME directory, with the staging concept generalised. Settles the third open question from the issue body.

Why staging is not a protocol concept

Verified rather than assumed: ACME (RFC 8555) has no notion of staging. There is only a directory URL. Staging is an environment Let's Encrypt happens to operate, with its own untrusted root and looser rate limits, and clients that offer a --staging flag simply ship hardcoded knowledge of that URL — certbot's STAGING_URI is the Let's Encrypt staging directory, and --staging together with a non-default --server is an error ("--server value conflicts with --staging").

A private CA such as step-ca has no staging environment at all, because it is your own CA. So staging cannot be modelled as a protocol-level flag; the directory URL is the primitive, and "staging" is one CA's second server.

Shape

[acme]
email              = "[email protected]"
directory_url      = "https://acme-v02.api.letsencrypt.org/directory"  # the primitive
test_directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"  # optional
# use_test_directory = true
  • directory_url is required and is what a certificate is normally issued from. Defaulting it to Let's Encrypt production keeps existing configs working.
  • use_test_directory selects test_directory_url instead. Setting it with no test_directory_url is a startup error, the same class as omitting directory_url — consistent with this codebase erroring on ambiguity rather than ranking silently.
  • Not named dry_run. Certbot's --dry-run discards the certificate while --staging keeps it; this keeps it, so it behaves like staging. Naming it dry-run would invite the assumption that nothing is persisted, which is how certbot grew #10494.

Identical URLs are allowed, with a warning

Initially argued these should be a hard error, on the reasoning that a dry run against production would burn the duplicate-certificate limit. That reasoning was wrong, and the mechanism is worth stating because it is what makes the feature safe:

migration 013 stamps every certificate with the directory that issued it, and needs_reissue treats a directory change as needing an order. When the test and live URLs are identical the configured directory does not change, so needs_reissue falls through to the ordinary expiry check — false for a healthy certificate. Test mode therefore reissues nothing. Existing certificates keep serving, no rate limit is consumed, and only names that have no certificate yet are ordered.

That makes identical URLs a legitimate configuration rather than a footgun:

  • A private ACME with no staging environment has nothing else to point at, and no rate limits to protect, so pointing both at it lets an operator use one uniform process across environments.
  • It still exercises the thing worth exercising — whether a new name can be issued end to end through this directory, httpreq endpoint and DNS zone.

What is lost is isolation for new names: with both URLs on a public CA, test mode issues real certificates for names that do not have one. So ER should warn when the two resolve to the same directory and the test one is selected, saying plainly that no isolation is in effect and why someone might want that anyway. A warning, not an error — ER cannot tell a rate-limited public CA from a private one, and refusing would block the private case for a problem only the public case has.

Also noted: the switch between directories is already safe

The workflow of "point at the test directory, bring ER up, confirm everything issues, shut down, switch to production, bring it up again" needs no extra mechanism. Because certificates record their issuing directory, changing the URL marks every certificate from the old one as needing reissue, so test certificates are replaced rather than left serving untrusted. That fell out of the staging-flip fix (fa2e643) rather than being designed for this.

Explicitly deferred: several directories with per-name selection

A mix of private ACME for internal names and Let's Encrypt for public ones on one ER is a real end state — Kyoshi has both kinds of name. It is not part of this and is not scheduled.

It is a materially bigger design, because choosing a provider per certificate reintroduces per-name certificate configuration, which v2.0.0 removed in favour of deriving everything from the route table. A route carrying acme = "internal" is [[tls]] by another name, with the same precedence and conflict questions. If it becomes a requirement it gets its own issue and its own thinking; recorded here so the want is not lost.

## Agreed design (James, 2026-10-23) — still not scheduled Option (a), the configurable ACME directory, with the staging concept generalised. Settles the third open question from the issue body. ### Why staging is not a protocol concept Verified rather than assumed: ACME (RFC 8555) has no notion of staging. There is only a directory URL. Staging is an environment Let's Encrypt happens to operate, with its own untrusted root and looser rate limits, and clients that offer a `--staging` flag simply ship hardcoded knowledge of that URL — certbot's `STAGING_URI` is the Let's Encrypt staging directory, and `--staging` together with a non-default `--server` is an error ("--server value conflicts with --staging"). A private CA such as `step-ca` has no staging environment at all, because it is your own CA. So staging cannot be modelled as a protocol-level flag; the directory URL is the primitive, and "staging" is one CA's second server. ### Shape ```toml [acme] email = "[email protected]" directory_url = "https://acme-v02.api.letsencrypt.org/directory" # the primitive test_directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory" # optional # use_test_directory = true ``` - `directory_url` is required and is what a certificate is normally issued from. Defaulting it to Let's Encrypt production keeps existing configs working. - `use_test_directory` selects `test_directory_url` instead. Setting it with no `test_directory_url` is a **startup error**, the same class as omitting `directory_url` — consistent with this codebase erroring on ambiguity rather than ranking silently. - **Not** named `dry_run`. Certbot's `--dry-run` discards the certificate while `--staging` keeps it; this keeps it, so it behaves like staging. Naming it dry-run would invite the assumption that nothing is persisted, which is how certbot grew #10494. ### Identical URLs are allowed, with a warning Initially argued these should be a hard error, on the reasoning that a dry run against production would burn the duplicate-certificate limit. **That reasoning was wrong**, and the mechanism is worth stating because it is what makes the feature safe: migration 013 stamps every certificate with the directory that issued it, and `needs_reissue` treats a directory change as needing an order. When the test and live URLs are *identical* the configured directory does not change, so `needs_reissue` falls through to the ordinary expiry check — false for a healthy certificate. **Test mode therefore reissues nothing.** Existing certificates keep serving, no rate limit is consumed, and only names that have no certificate yet are ordered. That makes identical URLs a legitimate configuration rather than a footgun: - A **private ACME with no staging environment** has nothing else to point at, and no rate limits to protect, so pointing both at it lets an operator use one uniform process across environments. - It still exercises the thing worth exercising — whether a new name can be issued end to end through this directory, httpreq endpoint and DNS zone. What is lost is isolation for *new* names: with both URLs on a public CA, test mode issues real certificates for names that do not have one. So ER should **warn** when the two resolve to the same directory and the test one is selected, saying plainly that no isolation is in effect and why someone might want that anyway. A warning, not an error — ER cannot tell a rate-limited public CA from a private one, and refusing would block the private case for a problem only the public case has. ### Also noted: the switch between directories is already safe The workflow of "point at the test directory, bring ER up, confirm everything issues, shut down, switch to production, bring it up again" needs no extra mechanism. Because certificates record their issuing directory, changing the URL marks every certificate from the old one as needing reissue, so test certificates are replaced rather than left serving untrusted. That fell out of the staging-flip fix (`fa2e643`) rather than being designed for this. ### Explicitly deferred: several directories with per-name selection A mix of private ACME for internal names and Let's Encrypt for public ones on one ER is a real end state — Kyoshi has both kinds of name. It is **not** part of this and is not scheduled. It is a materially bigger design, because choosing a provider per certificate reintroduces per-name certificate configuration, which v2.0.0 removed in favour of deriving everything from the route table. A route carrying `acme = "internal"` is `[[tls]]` by another name, with the same precedence and conflict questions. If it becomes a requirement it gets its own issue and its own thinking; recorded here so the want is not lost.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#50
No description provided.