Skip to content

Hosted DNS service: reserve, records, lease and release - #1

Merged
nick-potts merged 7 commits into
mainfrom
feat/hosted-dns
Sep 25, 2026
Merged

nick-potts merged 7 commits into
mainfrom
feat/hosted-dns

Conversation

@nick-potts

@nick-potts nick-potts commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A new Hosted DNS service: an axum API in front of one Route 53 zone (ployz.app) and Postgres. Cloud is its only client. It also signs one wildcard certificate per name from Cloud's CSR through Google Trust Services DNS-01. It never sees the private key and stores no certificate.

POST   /domains                  anon    {preferred?} → 201 {name, token}   token shown once, bcrypt-hashed
                                 mint key  Bearer <MINT_KEYS entry> skips the per-IP limit; wrong key → 401
POST   /domains/{name}/rotate    bearer  → {token}                          old token dead at once
PUT    /domains/{name}/records   bearer  {a?, aaaa?} → 204                  full apex set; *.name CNAME written once
POST   /domains/{name}/lease     bearer  → {name, lease_expires_at}
DELETE /domains/{name}           bearer  → 204                              released names never come back
POST   /domains/{name}/certificate  bearer  {csr} → {certificate_chain_pem}  CSR must name exactly name + *.name; 429 honours CA Retry-After
src/
├── api.rs       # routes, authorize_and_renew, per-IP mint limit, mint keys, reaper
├── cert.rs      # Ca trait, Issuer (owns CA backoff), CSR scope check, DNS-01: TXT → wait INSYNC → finalise → remove TXT
├── acme.rs      # Ca backed by instant-acme: EAB account kept in Postgres, records 429 Retry-After
├── domain.rs    # ClusterDomain + Token newtypes; preferred → granted: valid, suffixed, or random
├── zone.rs      # Zone trait: apex A/AAAA, wildcard CNAME, challenge TXT, wait for INSYNC
├── route53.rs   # Zone backed by Route 53
└── main.rs      # config from env, reaper every 5 min
migrations/0001_domains.sql
migrations/0002_acme_accounts.sql
tests/api.rs     # HTTP API against a fake Zone, a fake ACME CA (DNS-01 via the fake Zone) + throwaway Postgres
.github/workflows/ci.yml   # fmt, clippy -D warnings, test on ubuntu-latest

How a name moves through its life:

minted ──no records and no certificate after 24 h──▶ reaped (name free again)
   │
   ├─ records PUT or certificate call ──▶ used: never reaped, only retired
   ├─ any authenticated call ──▶ lease renewed for 7 days
   │
   ├─ DELETE ─────────────▶ retired forever
   └─ lease runs out ─────▶ retired forever

Evidence

  • Before: the repo was empty. Managed hostnames were reserved by the daemon from a third-party DNS service.
    After: cargo test passes 16 HTTP API tests (Docker required), locally and in CI. Clippy with -D warnings and fmt --check are clean.
mint_grants_preferred_suffixed_or_random_labels            ✔
mint_is_rate_limited_per_source_ip                         ✔
mint_key_skips_the_limit_and_a_wrong_key_is_refused       ✔  no header limited / key bypasses / wrong key 401
rotate_kills_the_old_token_at_once                         ✔
authentication_failures_are_distinguished                  ✔  401 bad token / 404 unknown / 410 retired
records_put_replaces_the_apex_set_and_writes_the_wildcard  ✔
records_put_refuses_empty_private_and_malformed_sets       ✔  422
authenticated_calls_renew_the_seven_day_lease              ✔
release_retires_the_name_forever                           ✔
reaper_frees_unused_reservations_and_retires_expired_leases ✔
certificate_is_issued_through_dns01_and_the_challenge_removed ✔  fake CA only sees TXT after wait_in_sync
certificate_refuses_csrs_outside_the_token_holders_name    ✔  422 invalid_csr / 401 other name
certificate_honours_the_cas_retry_after                    ✔  429 ca_rate_limited + Retry-After, held for all callers
failed_issuance_still_removes_the_challenge                ✔  502 ca_error
reaper_never_frees_a_name_that_asked_for_a_certificate     ✔  cert, no records, 25 h → still leased, not re-mintable
reaper_keeps_an_expired_name_when_route53_fails_and_retries ✔  reap errors, name stays, next run retires

Merge Danger

Door: two-way

Nothing is deployed and nothing calls this service yet. #1018 deploys it, and Cloud starts calling it in getployz/ployz#1061.

Blast Radius: none

Things to decide at deploy time (#1018):

  • Mint limit vs Hosted Cloud. The limit is 30 mints per hour per IP (MINTS_PER_HOUR). Give Hosted Cloud a MINT_KEYS entry so its single egress IP is not limited.
  • Client IP on Railway. Without CLIENT_IP_HEADER=x-real-ip the limiter sees the proxy's IP, not the caller's.
  • The limiter is in memory. It is only correct while the service runs as one instance.
  • EAB keys are single use. The first certificate call creates the ACME account and stores its credentials in acme_accounts, keyed by directory URL. Wiping that table, or switching from the staging to the production directory, needs a fresh EAB key from gcloud publicca external-account-keys create.
  • That table holds the ACME account key. It is the CA account key, never a certificate key, but it must not leak.
  • Real CA and Route 53 paths are untested here. Tests use fakes. Run the first real issuance against the GTS staging directory.
  • IAM. The AWS user needs route53:ChangeResourceRecordSets, route53:ListResourceRecordSets and route53:GetChange on the zone.
  • Long requests. A certificate call can take several minutes: up to 5 min waiting for INSYNC plus up to 5 min for validation and download each. Railway's request timeout and Cloud's HTTP client timeout must allow it.
  • The CA backoff is in memory. A 429 without Retry-After pauses issuance for 1 hour; a restart forgets it.
  • No contact email is set on the ACME account.
  • This repo is public. All credentials and zone ids come from env; .env.example holds only placeholders.

Closes getployz/ployz#1016
Closes getployz/ployz#1017

Codex added 4 commits September 25, 2026 04:22
Axum service over Route 53 and Postgres. Anonymous mint returns a name
and a bcrypt-hashed bearer token; rotate, full-set apex records, lease
renewal and release use that token. A reaper frees unused reservations
after 24 hours and retires expired leases. Released names are never
reissued. HTTP API tests run against a fake Route 53 and a Postgres
testcontainer.
POST /domains/{name}/certificate takes a CSR naming exactly name and
*.name, orders both from the configured ACME CA (Google Trust Services,
EAB-bound account), answers DNS-01 with a TXT record in the zone, waits
for Route 53 to report INSYNC, finalises and returns the PEM chain. The
challenge is removed either way. The service never sees the private key
and stores no certificate.

A CA 429 pauses all issuance until its Retry-After and is passed back
as 429 ca_rate_limited. The ACME account credentials are kept in
Postgres because EAB keys are single use.
MINT_KEYS holds comma-separated keys. POST /domains with a known key as
the bearer token skips the per-IP limiter; any other Authorization
header is refused with 401 rather than treated as anonymous. Keys are
compared without an early exit.
@nick-potts nick-potts closed this Sep 25, 2026
@nick-potts nick-potts reopened this Sep 25, 2026
Codex added 3 commits September 25, 2026 04:58
- The reaper frees only reservations that never published records and
  never asked for a certificate; a name that may hold a valid wildcard
  is only ever retired, so it can not be reissued.
- ClusterDomain and Token newtypes own the derived names, token
  generation and bcrypt hashing.
- cert::Issuer owns the CA backoff and returns CertError; the handler is
  authorize, mark used, issue.
- One RecordType to RrType mapping; TXT is never read back.
- The wildcard CNAME is always upserted.
- JsonRejection converts into ApiError; IntoResponse is one match.
- authenticate is now authorize_and_renew.
- Error-path test for the reaper; documented the API contract.
- ClusterDomain::parse accepts one DNS label under the configured apex;
  any other path name is 404 like an unknown name.
- ClusterDomain::names() gives [name, *.name] to records, CSR check and
  order.
- CertError maps into ApiError's own InvalidCsr, Ca and Zone variants,
  so IntoResponse has one arm per case.
- Document CertError::Ca and CertError::Zone.
- The mint test asserts the token contract (charset, distinct tokens)
  instead of its length; TOKEN_LEN is private again.
@nick-potts
nick-potts marked this pull request as ready for review September 25, 2026 05:07
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@nick-potts
nick-potts merged commit 8d22e4c into main Sep 25, 2026
1 check passed
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.

Hosted DNS signs one wildcard certificate per Cluster Domain from a CSR Hosted DNS service: reserve, records, lease and release

1 participant