navree
Documentation

Custom domains

Workspaces on their own domains: DNS verification, automatic TLS through an on-demand proxy or certificates the app gets itself over ACME, the domain workspace resolver, managed DNS zones and certificate expiry reports.

On this page

Introduction

Featuredomains A workspace of your app lives at acme.app.example.com, or under /w/acme. Many customers want it on a domain of their own: crm.acme-corp.com for their team, go.acme-corp.com for the links they send, book.acme-corp.com for a public booking page. That takes three things: proof that the domain is theirs, a certificate for it, and a way for each request to find its workspace from the host name.

navree-domains does all three:

  • Domains per workspace. Domains::add checks the domain, refuses your own domains and ones another workspace has, and answers with the DNS records to create: a TXT record that proves ownership, and a CNAME (A records for an apex domain) that points the domain at your app.
  • DNS verification. A scheduled command looks the records up with hickory-resolver until they appear, then marks the domain verified. Verified domains are checked again every day.
  • Automatic TLS, two ways. With an on-demand TLS proxy in front of the app (Caddy, which Harbor sets up), the app answers the proxy's ask endpoint, GET /_domains/allow?domain=, and the proxy gets each certificate on the domain's first visit. Without a proxy, the app gets certificates from an ACME CA such as Let's Encrypt itself (instant-acme on ring), renews them, and serves them over HTTPS with a rustls certificate resolver.
  • Requests on a domain. With domain in workspace.resolvers, a request to a verified domain names its workspace for CurrentWorkspace. Public pages take CurrentDomain instead, with no sign-in.
  • Managed zones. With a Cloudflare API token, domains under a zone you own (acme.links.example.com) get their record from the app and are verified at once.
  • Expiry reports. Certificates that come close to expiry are logged, emitted as an event and, with Lighthouse, reported as an issue.

Installation

Turn on the domains feature of the navree facade. It is part of the default full feature.

Cargo.toml

Add DomainsModule to your app:

src/app.rs

Only ConfigModule and DatabaseModule are required. WorkspaceModule is needed for the routes and the domain resolver, CryptoModule with an application key for certificates the app gets itself, ScheduleModule for the checks to run on their own, and EventsModule for the events.

The module contributes the migration set domains, which creates four tables: custom_domains (the domains, their status and certificate dates), custom_domain_certificates (certificates obtained over ACME, their private keys sealed), custom_domain_challenges (open ACME challenge answers) and custom_domain_acme_accounts (the ACME account of each directory, sealed). They run on PostgreSQL (with or without TimescaleDB), MySQL, MariaDB and SQLite.

Configuration

Tell customers where to point their domains: a host name of yours for CNAME records, and the addresses for apex domains, which can't have a CNAME.

config/app.toml

Name the verification record after your product: a customer of example.com sees _example-verification.crm.acme-corp.com. Every key also comes from the environment: APP_DOMAINS__TARGET=domains.example.com, APP_DOMAINS__ADDRESSES=203.0.113.10,2001:db8::10. A target that is not a host name, an address that is not an IP address, an ask path without a leading /, or tls = "acme" without domains.acme.terms_agreed = true fails the boot, naming the key. DomainsModule::for_root(DomainsOptions { .. }) takes the same options in code. The reference lists every key.

Adding a domain

Workspace admins add domains from your settings page through the routes, or you add them in code:

Rust
Text

The host is stored in its normal form: lower case, without a trailing dot, an international name as its xn-- form (bücher.example is xn--bcher-kva.example). The domain starts pending, with a random 32-character verification token. NewDomain::new(host).purpose("links") labels what the workspace uses it for (app by default), which CurrentDomain passes on to your handlers.

An apex domain (acme-corp.com) gets A and AAAA records of domains.addresses instead of the CNAME. With neither target nor addresses set, only the TXT record is asked for and checked.

add refuses:

RefusalCodeWhen
Not a domaininvalid_domain (400)A URL, a port, a path, a wildcard, an IP address, a single label, a purpose that is not 1 to 32 lower-case letters, digits, - or _
Reserveddomain_not_allowed (400)The host of app.url, workspace.base_domain, domains.target, an entry of domains.reserved, or a subdomain of one: a workspace can't claim admin.app.example.com
Takendomain_taken (409)Another workspace (or this one) has the host
Too manydomain_limit_reached (409)The workspace has domains.max_per_workspace domains (default 10; 0 for no limit)

remove(id) deletes a domain, its certificate and, in a managed zone, its record. When a workspace is deleted, its domains go with it.

Verification

The scheduler runs domains:verify --due every minute. It checks each domain whose next check is due:

  1. Ownership: a TXT record at {domains.verification.prefix}.{host} holds the domain's token.
  2. Routing: the domain points at the app: a CNAME to domains.target, or an address that domains.target or domains.addresses has. Nothing is checked while both are unset; domains.verification.require_routing = false verifies on the TXT record alone.

A domain that passes becomes verified and DomainVerified is emitted. One that doesn't stays pending, with what is missing in last_error, and is checked again domains.verification.interval_mins later (default 5):

Text

A domain still pending domains.verification.give_up_after_hours after it was added (default 72) becomes failed, DomainVerificationFailed is emitted, and the scheduler stops checking it. verify(id) checks a domain now, whatever its status, which is what a "Check now" button calls (POST /api/domains/{id}/verify); a failed domain whose records are in place becomes verified.

A resolver failure (a timeout, SERVFAIL) is not a missing record: it is recorded in last_error, the domain is checked again at the next interval, and verify returns it as a 502 dns_lookup_failed.

Verified domains are checked again every domains.verification.recheck_hours (default 24; 0 never). A domain that no longer points at the app stays verified, so its workspace keeps working through a DNS change, but points_to_app turns false, last_error says why, and a warning is logged.

resolve(host) answers which verified domain a host is, from memory for domains.cache_secs (default 60). Adding, verifying and removing a domain clear its cached answer on that server; other servers see the change within cache_secs.

Requests on a custom domain

The workspace resolver

Put domain first in workspace.resolvers, so a request to a verified domain names that domain's workspace:

config/app.toml

CurrentWorkspace, InWorkspace and every route behind them then work on https://crm.acme-corp.com/api/... with no X-Workspace header, and the membership check still applies. Requests to your app's own host fall through to the next resolver. See resolving the workspace.

Public pages

A landing page or a booking form on a workspace's domain has no signed-in user. Take CurrentDomain, which needs none:

Rust

It answers 404 domain_not_found for any host that is not a verified domain; Option<CurrentDomain> gives None instead. It carries the domain's id, workspace_id, host and purpose.

make_primary(id) marks a verified domain as its workspace's primary domain (the previous one stops being primary; an unverified domain is a 409 domain_not_verified). primary_url(workspace) gives https:// and that host, for links in mail and pages:

Rust

Automatic TLS

domains.tls says how domains get certificates.

On-demand TLS (the default)

A proxy in front of the app gets each domain's certificate when the domain is first visited, after asking the app whether the domain is one of yours. The app answers at domains.ask_path (default /_domains/allow), outside the API prefix, without authentication:

HTTP
200 OK

Any other domain is a 404 domain_not_found, so the proxy never gets a certificate for a domain nobody verified. With Harbor, turn on [web.custom_domains] and Caddy is set up for you. For a Caddy of your own, ask through its local address:

Caddyfile

Verified domains show tls: "active". To learn when the proxy's certificates expire, the module reads them over TLS: see certificate expiry.

Certificates the app gets itself

Without a proxy, the app gets certificates from an ACME CA (RFC 8555; Let's Encrypt by default) and serves HTTPS itself:

config/app.toml
  • Issuing. domains:verify --due orders a certificate for each newly verified domain right after its check. The CA validates the domain with an HTTP-01 challenge: the app serves the answer at /.well-known/acme-challenge/{token} on http.addr (port 80 in production) from the custom_domain_challenges table, so whichever of your servers the CA reaches can answer. The key and signing request are made by instant-acme with rcgen, on ring.
  • Storing. The certificate chain and its private key go to custom_domain_certificates, the key sealed with the application key (app.key); a missing key fails the issuance before anything is ordered. The ACME account is created on the first order and stored, sealed, per directory.
  • Serving. With domains.acme.https_addr set, serve also listens for HTTPS there: the same router, every layer and route, behind rustls with the certificate of the server name each client asks for. It loads the certificates from the database at start and every domains.acme.reload_secs (default 60), so a renewal made by one server reaches the others. A name with no certificate gets no handshake.
  • Renewing. domains:certificates --due (hourly) renews each certificate within domains.acme.renew_days of expiry (default 30), checking each domain every domains.certificates.check_hours (default 24).
  • Failures. A refused order sets the domain's tls to failed with the CA's answer in tls_error, and is tried again a day later. issue_certificate(host) orders one now (502 certificate_not_issued when the CA refuses).

Try the setup against Let's Encrypt's staging directory first (directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory", the constant LETS_ENCRYPT_STAGING). A private CA, or a test CA, goes in directory_url with its root in domains.acme.ca_file.

To serve HTTPS from a server of your own, leave https_addr unset and hand it the certificates: domains.certificates() is a DomainCertificates, a rustls ResolvesServerCert, and server_config() builds a rustls::ServerConfig around it (ring, TLS 1.2 and 1.3, ALPN h2 and http/1.1). HttpsServer::bind(&ctx, addr) and serve() run the module's listener yourself.

Off

tls = "off": something else terminates TLS. Domains are still verified and resolve their workspace; no ask endpoint and no challenge route are mounted.

Certificate expiry

domains:certificates --due runs hourly. Besides ACME renewals, with tls = "on_demand" it reads each verified domain's certificate over TLS on port 443 (TlsProbe: it connects, says the domain's name, and reads the dates of the certificate presented, which is read, not trusted) and records its expiry in certificate_expires_at. A domain that can't be read gets tls: "failed" and the reason in tls_error. Customers control their domains' DNS, so a domain that resolves only to loopback, private, link-local or shared addresses is not connected to: its probe fails with that reason. domains.certificates.probe = false turns the probes off.

A certificate that expires within domains.certificates.warn_days (default 14) is reported once per check:

  • a warning in the log;
  • the event DomainCertificateExpiring, for a mail to the workspace's admins;
  • with the lighthouse feature (on when both domains and lighthouse are) and LighthouseModule in the app, a Lighthouse warning, The certificate for crm.acme-corp.com expires in 9 days, tagged with the domain and grouped into one issue per domain.

Managed zones

Domains under a zone you own, such as acme.links.example.com for each workspace's tracked links, need no customer DNS work. Give the module a DNS provider and the zone:

config/app.toml

Keep the token in the environment: APP_DOMAINS__DNS__CLOUDFLARE__API_TOKEN. A domain added inside the zone gets its CNAME to domains.target (or A and AAAA records of domains.addresses) through the Cloudflare API and is verified at once, with no TXT record to check (managed: true). Removing it deletes the record. The zone itself can't be added, and a provider failure (502 dns_provider_failed) adds nothing. domains.dns.cloudflare.proxied proxies the records through Cloudflare; ttl sets their TTL (1 is automatic).

A provider of your own implements DnsProvider (set(kind, name, values) and delete(kind, name)) and replaces Cloudflare's with override_value::<dyn DnsProvider>(..).

Routes

With domains.routes on (the default), the module mounts a workspace's domain settings under {http.api_prefix}/domains. Each route acts on the request's workspace (the InWorkspace guard) and requires the permission domains.manage, which the workspace owner role has through all.*; give it to other roles under workspace.roles.

RouteAnswer
GET /api/domains{data: [CustomDomain]}
POST /api/domains {host, purpose?}201 CustomDomain with its dns_records
GET /api/domains/{id}CustomDomain
POST /api/domains/{id}/verifyCustomDomain after a DNS check
POST /api/domains/{id}/primaryCustomDomain, now the primary domain
DELETE /api/domains/{id}204
201 Created

status is pending, verified or failed; tls is pending, active, failed or off. Another workspace's domain is a 404 domain_not_found.

Events

Each is emitted after its change commits, when the app has EventsModule:

EventNamePayload
DomainAddeddomains.addeddomain (a CustomDomain)
DomainVerifieddomains.verifieddomain
DomainVerificationFaileddomains.verification_faileddomain, reason
DomainRemoveddomains.removeddomain_id, workspace_id, host
DomainCertificateIssueddomains.certificate_issuedhost, domain_id, expires_at, renewal
DomainCertificateExpiringdomains.certificate_expiringhost, domain_id, expires_at, days_left
Rust

Register it with m.listen::<DomainVerified, DomainLog>() in your module.

Errors

CodeStatusWhen
invalid_domain400The input is not a domain, or the ask endpoint got no ?domain=
domain_not_allowed400The domain is reserved
domain_not_found404No such domain (or another workspace's); a host that is not a verified domain
domain_taken409Another workspace has the domain
domain_limit_reached409The workspace has domains.max_per_workspace domains
domain_not_verified409Making an unverified domain primary, or ordering its certificate
dns_lookup_failed502The resolver failed (not a missing record)
dns_provider_failed502The DNS provider refused a change
certificate_not_issued502The ACME CA refused the order

Commands

Shell

The scheduler runs domains:verify --due every minute and domains:certificates --due hourly, without overlapping, when ScheduleModule is in the app.

Testing

Replace the drivers with the module's fakes: DnsFake answers lookups from records you set, AcmeFake issues certificates from a test root without checking challenges, ProbeFake says what each host's certificate is, and DnsProviderFake is a zone in memory.

tests/domains.rs
FakeDoes
DnsFakewith_txt, with_cname, with_address (builders); set_txt, set_cname, set_address, remove(name) after boot; fail(name, reason) makes lookups of a name fail; lookups() lists them. Addresses follow CNAMEs
AcmeFakeIssues certificates signed by its own root (ca_pem(), to trust in a TLS client); expire_at(at), fail_next(reason), keep_challenges() (leave the answer published, to fetch it from the app), orders(), challenges()
ProbeFakeexpires(host, at), fail(host, reason), probed()
DnsProviderFakerecords(kind, name), all(), fail_next(message)

Use a FixedClock (override_value::<dyn Clock>(..)) and clock.advance(..) to walk through the schedule: the five-minute retries, giving up after 72 hours, daily rechecks and renewals. HttpsServer::bind(app.ctx(), "127.0.0.1:0") serves the app over TLS in a test, with AcmeFake's certificates.

InstantAcme itself is tested against Pebble, Let's Encrypt's test CA, when NAVREE_TEST_PEBBLE_URL and NAVREE_TEST_PEBBLE_CA are set (CI runs it).

Reference

KeyDefaultMeaning
domains.routestrueMounts the /api/domains routes
domains.tlson_demandon_demand, acme or off
domains.ask_path/_domains/allowThe ask endpoint (tls = "on_demand")
domains.targetnoneThe host name customers point a CNAME at
domains.addresses[]The addresses apex domains point at (a list, or a comma-separated string)
domains.reserved[]Domains no workspace may add, with their subdomains
domains.max_per_workspace10Domains per workspace (0: no limit)
domains.cache_secs60How long a host lookup is kept in memory (0: never)
domains.verification.prefix_domain-verificationThe label of the TXT record in front of the domain
domains.verification.interval_mins5Minutes between checks of a pending domain
domains.verification.give_up_after_hours72A domain still pending this long fails
domains.verification.recheck_hours24Hours between routing checks of verified domains (0: never)
domains.verification.require_routingtrueA domain must point at the app to be verified
domains.certificates.warn_days14Report certificates that expire within this many days
domains.certificates.check_hours24Hours between certificate checks of a domain
domains.certificates.probetrueRead on-demand certificates over TLS
domains.acme.directory_urlLet's EncryptThe ACME directory
domains.acme.contact[]The account's contact addresses
domains.acme.terms_agreedfalseYou agree to the CA's terms (required with tls = "acme")
domains.acme.https_addrnoneWhere serve also listens for HTTPS
domains.acme.renew_days30Renew this many days before expiry
domains.acme.hosts[]Your own hosts that get certificates too
domains.acme.ca_filenoneA PEM root for the directory's HTTPS (a private or test CA)
domains.acme.reload_secs60Seconds between certificate reloads of the HTTPS listener
domains.dns.providernonecloudflare
domains.dns.zonenoneThe zone the provider manages
domains.dns.cloudflare.api_tokennoneAn API token with Zone.DNS:Edit
domains.dns.cloudflare.zone_idnoneThe zone's id
domains.dns.cloudflare.api_urlhttps://api.cloudflare.com/client/v4The API
domains.dns.cloudflare.proxiedfalseProxy the records through Cloudflare
domains.dns.cloudflare.ttl1The records' TTL (1: automatic)
TypeWhat it is
DomainsThe service: add, list, all, get, get_for, find, resolve, allows, verify, verify_due, make_primary, primary, primary_url, remove, remove_workspace, issue_certificate, issue_pending, check_certificates, reload_certificates, challenge_answer, certificates, dns_records
DomainsModulefor_root, from_config
CustomDomain, DnsInstruction, DomainStatus, TlsStatus, NewDomain, CertificateRunWhat the service takes and returns
CurrentDomainThe extractor of public pages
DomainWorkspaceThe request extension the domain resolver reads
DnsLookup, SystemDnsDNS lookups (hickory-resolver)
AcmeClient, InstantAcme, AcmeOrder, IssuedCertificateACME orders
DomainCertificates, HttpsServerServing certificates
CertificateProbe, TlsProbe, ProbedCertificateReading a host's certificate
DnsProvider, CloudflareDns, RecordKindManaged zones
models::{Domain, Certificate, Challenge, AcmeAccount}The tables as navree-orm models, to read
normalize_host, request_hostHost names