Skip to content

feat(proxy): add wildcard domains with on-demand TLS - #27

Merged
rbluethl merged 8 commits into
shipzero:mainfrom
patrick-russ:feature/wildcard-domains
Oct 15, 2026
Merged

rbluethl merged 8 commits into
shipzero:mainfrom
patrick-russ:feature/wildcard-domains

Conversation

@patrick-russ

Copy link
Copy Markdown
Contributor

Summary

Adds wildcard domains: zero domain add myapp '*.myapp.com' routes every host one label below the wildcard to the app. Useful for multi-tenant apps that tell customers apart by the Host header (in our case: one subdomain per device, e.g. club1.example.com, served by one app).

Today this needs one zero domain add per host, because the proxy looks hosts up exactly, the TLS server rejects unknown SNI names, and certificates are only issued over HTTP-01 (no wildcard certificates).

How it works

  • Routing: an exact domain wins; otherwise a.myapp.com falls back to *.myapp.com. Exactly one label deep, like DNS and certificate wildcards (myapp.com and a.b.myapp.com don't match). HTTP and WebSocket upgrades both use the same lookup.
  • TLS (on-demand): hosts under a wildcard get their own Let's Encrypt certificate during their first TLS handshake, using the existing HTTP-01 flow and cert store. Renewal picks these certificates up from disk, so no DNS provider integration is needed.
  • Abuse protection: anyone can point a name under the wildcard DNS record at the server, so issuance is guarded:
    • ON_DEMAND_TLS_ASK (optional, recommended): zero calls GET <url>?domain=<host> and only issues or renews on 2xx
    • ON_DEMAND_TLS_PER_HOUR (default 20): cap on new on-demand certificates
    • a host whose issuance failed or was denied isn't retried for an hour
    • concurrent handshakes for a new host share one issuance
  • Validation: malformed wildcards (*.com, a.*.myapp.com) are rejected, and a wildcard can't be an app's primary domain, since that one is used for the app URL and preview subdomains.

No behaviour changes for existing apps without a wildcard domain.

Testing

  • New tests for matching, validation, routing (exact over wildcard, one label deep, Host header forwarded), the on-demand guards (approval, hourly cap, backoff, shared in-flight issuance) and renewal of on-demand certificates
  • npm test: 290 passed
  • npm run build works and biome check src is clean. npm run lint still reports the formatting of cli/src/commands/mcp.ts, which already fails on main and isn't touched here.

README (Domains, server configuration, TLS) and CHANGELOG ([Unreleased]) are updated.

- Route hosts one label below a wildcard domain (*.example.com) to the app; exact domains win
- Issue a Let's Encrypt certificate per host on its first TLS handshake (HTTP-01) and renew it with the others
- Guard on-demand issuance: optional ON_DEMAND_TLS_ASK approval endpoint, ON_DEMAND_TLS_PER_HOUR limit, one-hour backoff after a failure
- Reject malformed wildcards and wildcards as an app's primary domain
Preview domains (preview-<label>.<primary>) fall under a wildcard on the primary domain. Their certificates stay on disk after the preview is removed and were picked up as on-demand hosts, so they would have been renewed indefinitely.
@patrick-russ
patrick-russ requested a review from rbluethl October 15, 2026 07:12
@rbluethl

Copy link
Copy Markdown
Collaborator

Review: wildcard domains with on-demand TLS

Overall the approach is sound: no wildcard certificates (which would need DNS-01), instead one certificate per host issued during the first TLS handshake over HTTP-01, the same way exact domains and previews already get theirs in the SNI callback. Routing, restoreRoutes, renewal and validation fit the existing patterns. Tests (290), tsc and biome check pass.

Three things must be fixed before merging, the rest is optional.

Must fix

1. Renewal gates exact domains of other apps through ON_DEMAND_TLS_ASK (src/certs.ts, renewExpiringCerts)

onDemandHosts('*.example.com') returns every .crt on disk one label below the wildcard, which includes exact domains of other apps. Those end up in the onDemand set, so when the ask endpoint denies them their renewal is skipped and the certificate expires.

This hits the default setup directly: apps get <name>.<DOMAIN>, so a wildcard *.<DOMAIN> covers every other app on the server. Reproduced with:

writeCert('api.example.com')
const ask = vi.fn(async () => false)
await certs.renewExpiringCerts(['api.example.com', '*.example.com'], renew, now, ask)
// returns [] instead of ['api.example.com']

Fix: remove every exact (non-wildcard) entry of domains from the on-demand set before the loop, so only hosts that are routed purely through the wildcard are subject to the ask check. Add this case as a test.

2. Removing the primary domain can promote a wildcard to primary (src/api/domains.ts, DELETE route)

The guard "a wildcard cannot be the primary domain" only exists in addDomain. With domains = ['app.com', '*.app.com'], zero domain remove myapp app.com leaves ['*.app.com']: the app URL becomes https://*.app.com and previews would build preview-x.*.app.com.

Fix: in the DELETE route, reject removing domains[0] when the domain that would become primary is a wildcard. Test it in state.test.ts or the API test.

3. A failed ask call is treated as a denial and locks the host out for an hour (src/certs.ts, askOnDemand / issueOnDemand)

askOnDemand returns false on timeout or network error, and issueOnDemand then writes the host into onDemandFailedAt. A 10 second outage of the approval endpoint blocks every new tenant that handshakes in that window for 60 minutes.

Fix: distinguish "denied" from "ask failed". On a failed call, throw without recording the host in onDemandFailedAt (or use a short backoff of a minute or so). Same distinction for the renewal path, where a transient failure should just be retried on the next run.

Should fix

  • validateDomain is not called on deploy (src/api/apps.ts): the deploy route only checks isWildcardDomain, so zero deploy myapp --domain 'a.*.example.com' creates an app with a malformed primary domain while zero domain add rejects it. Call validateDomain there too, or move the validation into addApp.
  • onDemandFailedAt grows without bound and ask is called before the hourly cap (issueOnDemand): every distinct denied name is a permanent map entry and an outbound fetch. Prune entries older than ON_DEMAND_RETRY_AFTER_MS and check the cap before calling ask.
  • preview- prefix heuristic in onDemandHosts: a legitimate tenant called preview-demo.example.com is never renewed. Exclude actual preview domains from state instead of matching on the name prefix.
  • Stale preview hosts now reach production: once a preview is destroyed, preview-pr-12.app.com matches *.app.com and is routed to the production app with its leftover certificate. Decide whether that is acceptable or exclude the preview- label space from wildcard matching.
  • askOnDemand never consumes the response body: add await res.body?.cancel() before returning res.ok so keep-alive connections are released.
  • Readability of const uniqueDomains = [...new Set([...domains.filter(...), ...onDemand])]: split into two named arrays (regularDomains, onDemandHostsToRenew) and a plain loop. That also makes point 1 obvious in the code.

Docs and CLI

README and CHANGELOG are updated, good. The CLI is untouched:

  • zero domain help only shows staging.example.com; add a wildcard example.
  • The MCP tool add_domain (cli/src/commands/mcp.ts) describes the domain as api.example.com and says the certificate is provisioned in the background. Mention wildcards and that their hosts get certificates on the first TLS handshake.
- Renew exact domains (of any app, or DOMAIN) below a wildcard as regular domains, never through ON_DEMAND_TLS_ASK
- Distinguish a denial (4xx, one-hour backoff) from an ask failure (timeout, network error, 5xx): retry the host after a minute and a due renewal on the next run
- Check the hourly cap before calling the ask endpoint and prune expired backoff entries
- Release the ask response body for keep-alive
- Refuse removing the primary domain when a wildcard would take its place
- Validate the domain passed to zero deploy like zero domain add does
- Never match preview-* hosts with a wildcard, so a destroyed preview is not routed to the production app and its leftover certificate is not renewed
@patrick-russ

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! All points are addressed in e8efbed..3d66ac3:

Must fix

  1. Renewal gating exact domains: reproduced with your snippet. renewExpiringCerts now splits the input into regularDomains and onDemandHostsToRenew; every exact domain (of any app, or DOMAIN) is removed from the on-demand set, so only hosts routed purely through a wildcard go through the ask check. Your case is a test now.
  2. Wildcard promoted to primary: removeDomain refuses to remove domains[0] when a wildcard would take its place, and the DELETE route answers 400 with that message. Tested in state.test.ts and api.test.ts.
  3. Ask failure locking hosts out: askOnDemand now returns 'approved' (2xx) or 'denied' (4xx) and throws when the endpoint can't decide (timeout, network error, 5xx). A denial keeps the one-hour backoff. A failed call only blocks that host for a minute. In the renewal path a failed call skips the host for this run, so it's retried on the next one.

Should fix

  • validateDomain on deploy: called in the /deploy route too, so --domain 'a.*.example.com' is rejected with 400.

  • Bounded state and cap before ask: expired backoff entries are pruned on every on-demand issuance, and the hourly cap is checked before ask is called.

  • res.body?.cancel(): added.

  • Readability: done as part of point 1 (two named arrays, two plain loops, a small tryRenew helper).

  • Previews: I went with reserving the preview- label space for wildcards (wildcardFor returns null for preview-* hosts) rather than excluding preview domains from state. This answers both preview points:

    • Why not state: a destroyed preview is no longer in state, so its leftover certificate would count as an on-demand host again, and without ON_DEMAND_TLS_ASK it would be renewed indefinitely.
    • What this changes: destroyed previews keep answering 502 as today instead of reaching production, and their certificates aren't renewed. Live previews are exact routes and work as before.
    • Trade-off: a tenant can't use a preview-* name under a wildcard. The README says so.

    If you'd rather not reserve that label space, I can switch to excluding previews from state.

Docs and CLI

  • zero domain help has a wildcard example.
  • The add_domain MCP tool describes wildcards and on-demand certificates.
  • README and CHANGELOG cover the ask verdicts, the preview label space and the primary domain rules.

I only touched the two strings in cli/src/commands/mcp.ts. The file's existing formatting (already flagged by biome check on main) is unchanged so the diff stays small.

Tests: 302 server (12 new), 37 CLI, both tsc runs, npm run build and biome check src pass.

@rbluethl rbluethl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified e8efbed..3d66ac3: every point from the review is addressed, the exact-domain renewal case is covered by a test, and tests (302), both tsc runs and biome check src pass on the branch. Reserving the preview- label space is the cleaner answer to the preview question, fine with the trade-off.

One minor thing, not blocking: the comment "a flood of random names costs neither ask calls nor certificates" only holds once 20 approved certificates were issued in the hour. Denied names don't count against ON_DEMAND_TLS_PER_HOUR, so until then every distinct random name still costs one ask call. Fine for us since the ask endpoint is cheap, but if that ever matters, ask calls would need their own hourly budget.

@rbluethl
rbluethl merged commit c2a435c into shipzero:main Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants