From 7b3f0f764c618cfa4867840abcc5123a6e95e185 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Wed, 14 Oct 2026 15:46:36 +0100 Subject: [PATCH] The web UI's certificate from Let's Encrypt, through Jarl tls.acme names the certificate and jarl: the Jarl server and a token that may only write the challenge record. Steward gets the certificate with DNS-01 (lego), writing the record through Jarl's API into every view that has the zone, renews it with a third of its lifetime left and swaps it in without a restart. A check runs before every order: Jarl, the zone, the token's permission, a test record, the zone's name servers, public resolvers, clean-up; each step says what to fix. Staging by default. With a certificate, each listener serves HTTPS and plain HTTP on one port (redirecting plain HTTP, except from the server itself). steward cert / cert check / cert renew, Server -> Certificate in the web UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md and docs/jarl.md (the Jarl user and the token's grant). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 + CHANGELOG.md | 19 + README.md | 7 + cmd/steward/cert.go | 158 ++++++ cmd/steward/main.go | 2 +- cmd/stewardd/main.go | 17 +- docs/api.md | 13 + docs/certificates.md | 120 +++++ docs/cli.md | 10 + docs/configuration.md | 11 +- docs/development.md | 27 + docs/jarl.md | 85 +++ docs/permissions.md | 2 + docs/validation.md | 11 + go.mod | 20 +- go.sum | 44 ++ internal/api/types.go | 46 ++ internal/certs/check.go | 379 ++++++++++++++ internal/certs/check_test.go | 266 ++++++++++ internal/certs/manager.go | 484 ++++++++++++++++++ internal/config/config.go | 149 +++++- internal/config/config_test.go | 18 +- internal/jarl/jarl.go | 249 +++++++++ internal/server/h_cert.go | 94 ++++ internal/server/h_leases.go | 3 + internal/server/routes.go | 3 + internal/server/server.go | 32 +- internal/server/sniff.go | 147 ++++++ internal/server/sniff_test.go | 58 +++ internal/web/static/assets/app.js | 5 +- .../web/static/assets/pages/certificate.js | 86 ++++ internal/web/static/assets/style.css | 1 + 32 files changed, 2544 insertions(+), 27 deletions(-) create mode 100644 cmd/steward/cert.go create mode 100644 docs/certificates.md create mode 100644 docs/jarl.md create mode 100644 internal/certs/check.go create mode 100644 internal/certs/check_test.go create mode 100644 internal/certs/manager.go create mode 100644 internal/jarl/jarl.go create mode 100644 internal/server/h_cert.go create mode 100644 internal/server/sniff.go create mode 100644 internal/server/sniff_test.go create mode 100644 internal/web/static/assets/pages/certificate.js diff --git a/AGENTS.md b/AGENTS.md index 4b56689..908d028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,11 @@ When in doubt, do it the way Jarl does. Go module `steward`, result, saves the objects and a history entry in one transaction, then hands the new state to the DHCP server. Never write subnets, reservations or settings to the store from a handler. +* **The web UI's certificate** (`internal/certs`, with `internal/jarl` for + Jarl's API) is obtained through lego, with Jarl publishing the + DNS-01 challenge. A preflight check runs before every order. Test it + against Jarl's e2e environment (`KEEP=1 PEBBLE=0 scripts/e2e.sh` in + `../jarl`) with Pebble pointed at its named (docs/development.md). * **Leases are not configuration.** `dhcpd` owns them: kept in memory, and written to the store (`PutLease`) before the ACK is sent. They are not in the history. diff --git a/CHANGELOG.md b/CHANGELOG.md index f48014a..46a34ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,25 @@ and a CLI, built the way Jarl is. - **`stewardd`** commands: `init` (asks for the administrator's name), `user add|password|token`, `check`, `backup` (with pruning), `restore`, `config --example`. +- **The web UI's certificate from Let's Encrypt, through Jarl** + (docs/certificates.md, docs/jarl.md): + - `tls.acme` in `steward.yaml` names the certificate, and `jarl:` the + Jarl server and a token that may only write the challenge record. + - Steward gets the certificate with the DNS-01 challenge, writing the + record through Jarl's API into every view that has the zone. + - It renews with a third of the lifetime left, and swaps the new + certificate in without a restart. + - A check runs before every order, and on demand: Jarl, the zone, the + token's permission, a test record, the zone's public name servers and + public resolvers, clean-up. Each step says what to fix. + - It starts with Let's Encrypt's staging service by default. + - `steward cert`, `cert check`, `cert renew`; Server → Certificate in the + web UI; `GET /certificate`, `POST /certificate/check|renew`. + - Dashboard problems: `certificate_failed`, `certificate_expiring`, + `certificate_staging`. +- **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is + redirected to HTTPS, except from the server itself (also for + `tls.cert_file`). - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`, Rotate in the web UI): a new secret for a token, with an optional grace period for the old one, as in Jarl. diff --git a/README.md b/README.md index c652804..275e525 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ network: it greets it, gives it an address, and tells it the way around administrator, or `config`, `leases` and `audit` access, read or write. On the server itself, root needs no setup: the CLI uses stewardd's local socket. +* **HTTPS on its own.** Give it a name and a Jarl token, and Steward gets its + web UI's certificate from Let's Encrypt and renews it, with Jarl + publishing the DNS-01 challenge. It checks the whole path first, so a + broken setup never wastes Let's Encrypt's rate limits + ([docs/certificates.md](docs/certificates.md)). * **One small service.** `stewardd` is one static binary, run by a sandboxed systemd unit whose only privilege is binding port 67. Debian packages and a daily backup timer are included. @@ -89,6 +94,8 @@ sudo systemctl disable --now kea-dhcp4-server * [subnets.md](docs/subnets.md): subnets, interfaces, pools, reservations, options, leases, and how addresses are chosen * [options.md](docs/options.md): the options Steward knows and how to write their values * [kea.md](docs/kea.md): moving from Kea +* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl +* [jarl.md](docs/jarl.md): connecting Steward to Jarl, and what its token may do * [cli.md](docs/cli.md): the `steward` command line client * [api.md](docs/api.md): the REST API * [configuration.md](docs/configuration.md): `/etc/steward/steward.yaml` diff --git a/cmd/steward/cert.go b/cmd/steward/cert.go new file mode 100644 index 0000000..99d6310 --- /dev/null +++ b/cmd/steward/cert.go @@ -0,0 +1,158 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "steward/internal/api" +) + +func certCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cert", + Aliases: []string{"certificate"}, + Short: "The web UI's certificate, obtained through Jarl (administrators)", + Long: `Shows the certificate stewardd obtains itself for its web UI (tls.acme in +steward.yaml, with Jarl publishing the DNS-01 challenge; docs/certificates.md). +'check' tests the whole path without asking the CA; 'renew' gets a new +certificate now.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }, + } + cmd.AddCommand(&cobra.Command{Use: "status", Short: "Show the certificate", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }}) + cmd.AddCommand(&cobra.Command{ + Use: "check", + Short: "Check that the challenge can be published and seen, without the CA", + Long: `Runs the check stewardd runs before every certificate order: Jarl answers +and knows the token, a zone holds _acme-challenge.DOMAIN, the token may +write it, a test record is written, the zone's name servers and the public +resolvers serve it, and it is removed again. It can take a minute or two.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + c.HTTP.Timeout = 5 * time.Minute + if !flagQuiet && flagOutput == "table" { + fmt.Fprintln(os.Stderr, "checking (this can take a minute or two)…") + } + var ch api.CertCheck + if err := c.Do(ctx(), "POST", "/certificate/check", nil, nil, &ch); err != nil { + return err + } + if err := output(ch, func(w io.Writer) { printCheck(w, &ch) }); err != nil { + return err + } + if !ch.OK { + return fmt.Errorf("the check failed") + } + return nil + }, + }) + var wait bool + renew := &cobra.Command{ + Use: "renew", + Short: "Get a new certificate now (the check runs first)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var r api.CertificateResponse + if err := c.Do(ctx(), "POST", "/certificate/renew", nil, nil, &r); err != nil { + return err + } + if !wait { + fmt.Println("renewing in the background; follow it with 'steward cert'") + return nil + } + start := time.Now() + for { + time.Sleep(3 * time.Second) + if err := c.Get(ctx(), "/certificate", nil, &r); err != nil { + return err + } + st := r.Status + if st.State != "working" && st.LastAttempt != nil && !st.LastAttempt.Before(start.Add(-time.Second)) { + break + } + } + if err := certStatus(); err != nil { + return err + } + if r.Status.LastError != "" { + return fmt.Errorf("renewing failed") + } + return nil + }, + } + renew.Flags().BoolVarP(&wait, "wait", "w", false, "wait until it is done") + cmd.AddCommand(renew) + return cmd +} + +func certStatus() error { + c, err := newClient() + if err != nil { + return err + } + var r api.CertificateResponse + if err := c.Get(ctx(), "/certificate", nil, &r); err != nil { + return err + } + return output(r, func(w io.Writer) { + switch r.Source { + case "file": + fmt.Fprintln(w, "Source:\ttls.cert_file (renewed by you)") + return + case "none": + fmt.Fprintln(w, "Source:\tnone: the web UI is plain HTTP (set tls.acme or tls.cert_file in steward.yaml; docs/certificates.md)") + return + } + st := r.Status + fmt.Fprintf(w, "Domain:\t%s\n", st.Domain) + ca := st.Directory + if st.Staging { + ca += " (staging: browsers don't trust it)" + } + fmt.Fprintf(w, "CA:\t%s\n", ca) + fmt.Fprintf(w, "State:\t%s\n", st.State) + if st.NotAfter != nil { + fmt.Fprintf(w, "Valid:\t%s to %s, from %s\n", st.NotBefore.Local().Format("2006-01-02"), st.NotAfter.Local().Format("2006-01-02 15:04"), st.Issuer) + fmt.Fprintf(w, "Renews:\t%s\n", st.RenewAt.Local().Format("2006-01-02")) + } + if st.LastAttempt != nil { + fmt.Fprintf(w, "Last attempt:\t%s\n", st.LastAttempt.Local().Format("2006-01-02 15:04")) + } + if st.NextAttempt != nil && st.State != "working" { + fmt.Fprintf(w, "Next check:\t%s\n", st.NextAttempt.Local().Format("2006-01-02 15:04")) + } + if st.LastError != "" { + fmt.Fprintf(w, "Error:\t%s\n", st.LastError) + } + if st.Check != nil { + fmt.Fprintf(w, "\nLast check, %s:\n", st.Check.Time.Local().Format("2006-01-02 15:04")) + printCheck(w, st.Check) + } + }) +} + +func printCheck(w io.Writer, c *api.CertCheck) { + for _, s := range c.Steps { + mark := map[string]string{"ok": "ok ", "fail": "FAIL", "warn": "warn", "skip": "skip"}[s.Status] + fmt.Fprintf(w, " %s\t%s\t%s\n", mark, s.Name, s.Detail) + if s.Status == "fail" && len(s.Queries) > 0 { + for _, q := range s.Queries { + fmt.Fprintf(w, " \t\t%s\n", strings.TrimSpace(q)) + } + } + } +} diff --git a/cmd/steward/main.go b/cmd/steward/main.go index 28672ab..41e7e4b 100644 --- a/cmd/steward/main.go +++ b/cmd/steward/main.go @@ -175,7 +175,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, } add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd()) add("changes", historyCmd(), importCmd()) - add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd()) + add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd()) return root } diff --git a/cmd/stewardd/main.go b/cmd/stewardd/main.go index d7a2e5a..5232418 100644 --- a/cmd/stewardd/main.go +++ b/cmd/stewardd/main.go @@ -23,9 +23,11 @@ import ( "golang.org/x/term" "steward/internal/auth" + "steward/internal/certs" "steward/internal/config" "steward/internal/dhcpd" "steward/internal/engine" + "steward/internal/jarl" "steward/internal/model" "steward/internal/server" "steward/internal/store" @@ -153,10 +155,23 @@ func serveCmd() *cobra.Command { for _, p := range eng.Problems() { a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code) } + var acme *certs.Manager + if a.cfg.TLS.ACME.Domain != "" { + tok, err := a.cfg.Jarl.ReadToken() + if err != nil { + return err + } + jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure) + if err != nil { + return err + } + acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log) + go acme.Run(ctx) + } srv := server.New(server.Config{ Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), - CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, + CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme, }, eng, d, host, a.log, web.FS()) go d.Run(ctx) hup := make(chan os.Signal, 1) diff --git a/docs/api.md b/docs/api.md index 719189e..752f8cf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -124,6 +124,19 @@ settings'. | `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already | | `POST /import/kea` | `{"config": TEXT, "leases": [TEXT, ...], "interfaces": {"PREFIX": "IFACE"}, "replace": false}` ([kea.md](kea.md)); answers with `leases`, `notes`, `skipped` too | +### Certificate + +Administrators only ([certificates.md](certificates.md)). + +| | | +|---|---| +| `GET /certificate` | `{"configured", "source": "acme"\|"file"\|"none", "status": {"domain", "directory", "staging", "state": "none"\|"ok"\|"working"\|"error", "not_before", "not_after", "issuer", "renew_at", "last_error", "last_attempt", "next_attempt", "check"}}` | +| `POST /certificate/check` | run the check now (up to a few minutes); answers `{"time", "domain", "ok", "steps": [{"name", "status", "detail", "queries"}]}` | +| `POST /certificate/renew` | get a new certificate now, in the background (202); follow it with `GET /certificate` | + +`GET /status` lists the certificate's problems (`certificate_failed`, +`certificate_expiring`, `certificate_staging`) for administrators. + ### Users, tokens, audit | | | diff --git a/docs/certificates.md b/docs/certificates.md new file mode 100644 index 0000000..04138b9 --- /dev/null +++ b/docs/certificates.md @@ -0,0 +1,120 @@ +# The web UI's certificate + +Steward can serve its web UI and API over HTTPS in two ways: + +* **A certificate you manage**: `tls.cert_file` and `tls.key_file` in + `steward.yaml`. stewardd rereads them when they change + ([configuration.md](configuration.md#https)). +* **A certificate it gets itself** from Let's Encrypt (or another ACME CA), + with Jarl publishing the DNS-01 challenge. That's this page. + +With DNS-01 the web UI doesn't need to be reachable from the internet. +`dhcp.adbraun.lol` can point at a private address and the certificate +still works on your network. Only the challenge record has to be visible +on the internet, and Jarl puts it in every view that has your zone. + +## Setting it up + +1. **Connect Steward to Jarl** with a user and a token that may only write + Steward's own challenge record: [jarl.md](jarl.md). +2. **Give your web UI a name in DNS**, such as `dhcp.adbraun.lol` with the + Steward server's address, in Jarl. It is the name you will type in the + browser. +3. **Configure the certificate** in `/etc/steward/steward.yaml`: + + ```yaml + listen: ["10.0.10.2:8067"] # an address your browser can reach + tls: + acme: + domain: dhcp.adbraun.lol + email: you@adbraun.lol # Let's Encrypt writes here if something is wrong + directory: letsencrypt-staging # first; see below + agree_tos: true # you accept Let's Encrypt's terms of service + jarl: + server: https://dns.adbraun.lol:8053 + token_file: /etc/steward/jarl.token + ``` + +4. **Restart stewardd** (`sudo systemctl restart steward`), and check: + + ```sh + sudo steward cert check + ``` + +5. **Watch it get the certificate:** `sudo steward cert`, or Server → + Certificate in the web UI. Then open `https://dhcp.adbraun.lol:8067/`. +6. **Switch to the real CA.** The staging certificate works, but browsers + warn about it, because Let's Encrypt's staging service isn't trusted. + It's there to prove everything works without touching Let's Encrypt's + rate limits. Once it does, set `directory: letsencrypt`, restart, and + Steward gets a trusted certificate right away. + +Until the first certificate arrives, the web UI is served over plain +HTTP. stewardd switches to HTTPS on the same port without a restart. From +then on, plain HTTP is redirected to HTTPS, except from the server itself +(`127.0.0.1`), so tools there keep working. + +## Checking + +Before every certificate order, and whenever you ask (`steward cert +check`, or Test in the web UI), Steward checks the whole path without +asking the CA. Let's Encrypt allows only 5 failed validations per name +per hour, so a broken setup shouldn't find out the hard way. + +| Step | What it checks | +|---|---| +| Jarl | Jarl answers at `jarl.server`, and the token is valid (it says whose it is) | +| Zone | a primary zone the token can see holds `_acme-challenge.`, and in which views | +| Permission | the token may write the TXT record there (Jarl checks it as a dry run) | +| Write | a test record with a random value is written through Jarl, which loads it into named | +| Name servers | each of the zone's public name servers serves it, asked directly as the CA's resolvers will. A name server passes if any of its addresses does (`tls.acme.check_propagation`) | +| Public resolvers | the resolvers in `tls.acme.resolvers` (1.1.1.1, 9.9.9.9, 8.8.8.8) see it. If the name servers do but a resolver doesn't, it is only a warning: it may be holding an earlier answer | +| Clean up | the test record is removed | + +Each step says what went wrong and what to do. For example: + +``` + ok Jarl the token is steward's, on Jarl v0.6.1 + ok Zone adbraun.lol in views internal, external + FAIL Permission the token may not write the challenge record (...); give it the grant: + records:write zones=adbraun.lol names=_acme-challenge.dhcp.adbraun.lol types=TXT +``` + +The checks wait up to 90 seconds for the record to appear. A failing step +lists the DNS questions it asked, as `dig` commands, so you can ask them +yourself. + +If the challenge name is a CNAME into another zone, the check follows it, +as Let's Encrypt does. + +## Renewal + +A certificate is renewed when a third of its lifetime is left: 30 days +before the end for Let's Encrypt's 90-day certificates, 2 days for 6-day +ones. stewardd looks twice a day. The check runs first every time, and +the new certificate is used at once, without a restart. + +If renewing fails, the old certificate keeps being served, and stewardd +tries again after 1 hour, then 3, 6 and every 12. The dashboard shows a +problem (`certificate_failed`), and `certificate_expiring` once less than +14 days are left. `steward cert renew` (Renew now in the web UI) tries +again at once. + +The ACME account and the certificate, with its key, are kept in Steward's +database, so backups include them. + +## Settings + +All under `tls.acme` in `steward.yaml` ([configuration.md](configuration.md)): + +| Setting | Default | | +|---|---|---| +| `domain` | none (off) | the name on the certificate | +| `email` | none | the account's contact address | +| `directory` | `letsencrypt-staging` | `letsencrypt`, `letsencrypt-staging`, or an ACME directory URL (a private CA such as step-ca) | +| `agree_tos` | `false` | accept the CA's terms of service; no account is made without it | +| `resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | public resolvers, to see the challenge as the CA does | +| `check_propagation` | `true` | also ask each of the zone's name servers directly | +| `ca_bundle` | none | extra CAs to trust for the ACME server itself | + +`tls.cert_file` and `tls.acme` can't both be set. diff --git a/docs/cli.md b/docs/cli.md index e969a97..f358330 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -129,6 +129,16 @@ sent. `steward import kea CONFIG [--leases FILE]... [--interface PREFIX=IFACE]... [--replace]`: see [kea.md](kea.md). +### cert + +Administrators only ([certificates.md](certificates.md)). + +| Command | | +|---|---| +| `cert` | the certificate: name, CA, validity, renewal, the last check | +| `cert check` | check that the challenge can be published through Jarl and seen, without the CA (fails with exit code 1 if it can't) | +| `cert renew [-w]` | get a new certificate now; `-w` waits and shows the result | + ### users, tokens, audit | Command | | diff --git a/docs/configuration.md b/docs/configuration.md index 0b1c619..dd15421 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,8 @@ the file when it starts. |---|---|---| | `listen` | `["127.0.0.1:8067"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network | | `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; stewardd rereads it when the file changes, or on `systemctl reload steward` | +| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) | +| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server Steward uses for DNS, and its token ([jarl.md](jarl.md)) | | `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed | | `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off | | `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log | @@ -29,8 +31,9 @@ Durations are written `15s`, `12h` or `30d`. ## HTTPS Over plain HTTP, passwords and tokens cross the network in the clear; -stewardd warns when it listens on a non-loopback address without TLS. Give -it a certificate: +stewardd warns when it listens on a non-loopback address without TLS. Let +it get its own certificate through Jarl ([certificates.md](certificates.md)), +or give it one: ```yaml listen: ["10.0.10.2:8067"] @@ -40,5 +43,7 @@ tls: ``` Or keep it on `127.0.0.1` behind a reverse proxy that terminates TLS, and -list the proxy in `trusted_proxies`. Ports below 1024 work too: the unit +list the proxy in `trusted_proxies`. With a certificate, the same port +answers HTTPS and redirects plain HTTP to it, except from the server +itself. Ports below 1024 work too: the unit grants `CAP_NET_BIND_SERVICE` for DHCP's port 67. diff --git a/docs/development.md b/docs/development.md index f4d797f..d67a896 100644 --- a/docs/development.md +++ b/docs/development.md @@ -71,6 +71,33 @@ sandbox is only tested by installing the package in a container that runs systemd (`--privileged /sbin/init`) and pointing a client container at it. +## Certificates + +`internal/certs` has unit tests with a fake Jarl and an in-process DNS +server, covering every failure the check reports. To test a real order: + +1. Run Jarl's e2e and keep its jarld and named: + `KEEP=1 PEBBLE=0 JARL_BIND_ROOT=... WORK=DIR scripts/e2e.sh` in `../jarl`. + jarld is then on `127.0.0.1:28053` and named on `127.0.0.1:25353`, with + the zone `adbraun.test` in two views. +2. Make the restricted user and token there, as docs/jarl.md says, with + `DIR/jarl --config DIR/cli.yaml`. +3. Start Pebble against that named: + `docker run -d --network host ghcr.io/letsencrypt/pebble -config test/config/pebble-config.json -dnsserver 127.0.0.1:25353`, + and copy its `/test/certs/pebble.minica.pem` out. +4. Point a scratch stewardd at both: + + ```yaml + tls: + acme: {domain: dhcp.adbraun.test, email: t@adbraun.test, agree_tos: true, + directory: https://localhost:14000/dir, ca_bundle: pebble.minica.pem, + resolvers: ["127.0.0.1:25353"], check_propagation: false} + jarl: {server: http://127.0.0.1:28053, token_file: jarl.token} + ``` + + `check_propagation` has to be off here: the test named isn't on port 53. + The unit tests cover that step. + ## The web UI Plain JavaScript modules under `internal/web/static/`, built with the diff --git a/docs/jarl.md b/docs/jarl.md new file mode 100644 index 0000000..8ee3989 --- /dev/null +++ b/docs/jarl.md @@ -0,0 +1,85 @@ +# Connecting Steward to Jarl + +Steward uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND +manager, for its DNS records. In this version that means one thing: the +DNS-01 challenge for the web UI's certificate ([certificates.md](certificates.md)). +Registering clients' names is planned to use the same connection. + +Steward talks to Jarl's REST API with an API token. Give it a **user of its +own**, with **only the permission it needs**. + +## 1. A user for Steward, in Jarl + +On the Jarl server, make a user without a password. It can't log in to +Jarl's web UI, only use its tokens. Grant it the challenge record of +Steward's own name, and nothing else: + +```sh +jarl user create steward --no-password --comment "Steward on dhcp01" \ + --grant "records:write zones=adbraun.lol names=_acme-challenge.dhcp.adbraun.lol types=TXT" +``` + +Use your zone for `adbraun.lol`, and the name Steward's web UI is reached +by for `dhcp.adbraun.lol` (`tls.acme.domain`). The grant lets the token add +and remove TXT records at exactly `_acme-challenge.dhcp.adbraun.lol`: + +* nothing else in the zone (`names=`); +* no other record type (`types=TXT`); +* no other zone (`zones=`). + +It can see that the zone exists, which it needs to find it, but it can't +read the zone's other records. + +Why not something wider: + +* **Jarl's `acme` role** (`records:write names=_acme-challenge.* types=TXT`) + would let the token publish challenges for *any* name in *any* zone, and + so obtain certificates for all of them. Steward needs only its own name. +* **Your own account's token** would carry all your permissions, and + Steward's certificate would stop renewing the day your account changes. + A user per program keeps the two apart, and the audit log shows + `steward/...` for what Steward did. + +If the challenge name is a CNAME into a zone kept for challenges, such as +`_acme-challenge.dhcp.adbraun.lol CNAME dhcp.acme.adbraun.lol.`, grant the +target instead: `zones=acme.adbraun.lol names=dhcp.acme.adbraun.lol`. + +## 2. The token + +Make the token, and copy it to the Steward server into a file only the +`steward` user can read: + +```sh +jarl token create dhcp01 --user steward -q > steward.token # on the Jarl server, or with the jarl CLI anywhere +scp steward.token dhcp01: +ssh dhcp01 'sudo install -m 0600 -o steward -g steward steward.token /etc/steward/jarl.token && rm steward.token' +rm steward.token +``` + +A token file is better than putting the token in `steward.yaml`: the +configuration file is readable by the `steward` group, and gets copied +around more readily. + +You can give the token an expiry (`--expires 365d`). Rotate it in Jarl with +`jarl token rotate ID --grace 1d`, then put the new secret in the file and +restart stewardd within the grace period. Since the certificate is renewed +every couple of months, nothing breaks while you do. + +## 3. Steward's settings + +In `/etc/steward/steward.yaml`: + +```yaml +jarl: + server: https://dns.adbraun.lol:8053 + token_file: /etc/steward/jarl.token + # ca_cert: /etc/steward/jarl-ca.pem # if Jarl's certificate isn't from a public CA +``` + +Then restart stewardd (`systemctl restart steward`). Steward needs to +reach Jarl's API over the network: Jarl's `listen` must include an +address Steward can reach, ideally over HTTPS. + +`steward cert check` ([certificates.md](certificates.md#checking)) tests +the connection, the token's permission, and that the record reaches the +internet. diff --git a/docs/permissions.md b/docs/permissions.md index 145193e..2b3a050 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -10,6 +10,8 @@ else gets **grants**, `resource:access`: | `config` | settings, subnets, reservations, history, interfaces (the web UI shows the forms locked) | change them, undo, import | | `leases` | leases and DHCP activity | delete a lease (making a reservation from one needs `config:write` too) | | `audit` | the audit log | – | + +The certificate (`steward cert`, Server → Certificate) is for administrators only. | `*` | all of the above | | **Roles** are ready-made sets of grants: diff --git a/docs/validation.md b/docs/validation.md index 2a90611..626ead6 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -63,3 +63,14 @@ These apply to options anywhere (settings, subnets, reservations). | `reservation_client_taken` | error | the client has a reservation in this subnet already (one per subnet; a device on two VLANs may have one in each) | | `reservation_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) | | `reservation_server_address` | warning, host | the server's own address | + +## Certificate + +Not configuration problems, but shown with them on the dashboard and in +`steward status`, to administrators ([certificates.md](certificates.md)): + +| Code | | When | +|---|---|---| +| `certificate_failed` | warning | the last attempt to get the web UI's certificate failed (the message says where) | +| `certificate_expiring` | warning | the certificate ends in less than 14 days and hasn't been renewed | +| `certificate_staging` | warning | the certificate is from Let's Encrypt's staging service, which browsers don't trust | diff --git a/go.mod b/go.mod index 081b9fa..5b45300 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,29 @@ module steward go 1.27.1 require ( + github.com/go-acme/lego/v4 v4.35.2 + github.com/miekg/dns v1.1.73 + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/crypto v0.57.0 + golang.org/x/net v0.58.0 + golang.org/x/term v0.46.0 + modernc.org/sqlite v1.60.1 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect - modernc.org/sqlite v1.60.1 // indirect ) diff --git a/go.sum b/go.sum index a3a0702..11c4a27 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,26 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -16,23 +30,53 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/types.go b/internal/api/types.go index 9e69e49..0b2f715 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -255,3 +255,49 @@ type ImportResult struct { Notes []string `json:"notes"` Skipped []string `json:"skipped"` } + +// CertStep is one step of the preflight check. +type CertStep struct { + Name string `json:"name"` + Status string `json:"status"` // ok, fail, warn, skip + Detail string `json:"detail"` + // Queries are the DNS questions asked, as dig commands. + Queries []string `json:"queries,omitempty"` +} + +// CertCheck is the result of a preflight check: can Steward publish its +// challenge through Jarl, and does the internet see it? +type CertCheck struct { + Time time.Time `json:"time"` + Domain string `json:"domain"` + OK bool `json:"ok"` + Steps []CertStep `json:"steps"` +} + +// CertStatus describes the certificate, for the API and the dashboard. +type CertStatus struct { + Domain string `json:"domain"` + Directory string `json:"directory"` + Staging bool `json:"staging"` + State string `json:"state"` // none, ok, working, error + NotBefore *time.Time `json:"not_before,omitempty"` + NotAfter *time.Time `json:"not_after,omitempty"` + Issuer string `json:"issuer,omitempty"` + // IssuedBy is the directory the current certificate came from (it may + // differ from Directory until the next renewal). + IssuedBy string `json:"issued_by,omitempty"` + RenewAt *time.Time `json:"renew_at,omitempty"` + LastError string `json:"last_error,omitempty"` + LastAttempt *time.Time `json:"last_attempt,omitempty"` + NextAttempt *time.Time `json:"next_attempt,omitempty"` + Check *CertCheck `json:"check,omitempty"` +} + +// CertificateResponse is GET /certificate. +type CertificateResponse struct { + // Configured is false when tls.acme is off. + Configured bool `json:"configured"` + // Source is acme, file or none. + Source string `json:"source"` + Status *CertStatus `json:"status,omitempty"` +} diff --git a/internal/certs/check.go b/internal/certs/check.go new file mode 100644 index 0000000..9e7fa97 --- /dev/null +++ b/internal/certs/check.go @@ -0,0 +1,379 @@ +package certs + +import ( + "context" + "errors" + "fmt" + "net" + "sort" + "strings" + "time" + + "github.com/miekg/dns" + + "steward/internal/api" + "steward/internal/auth" + "steward/internal/jarl" +) + +// Step and Check are api.CertStep and api.CertCheck. +type ( + Step = api.CertStep + Check = api.CertCheck +) + +// note records a query and its answer, replacing an earlier answer to +// the same question (the checks ask again until the record shows up). +func note(s *Step, q string) { + cmd, _, _ := strings.Cut(q, " → ") + for i, old := range s.Queries { + if prev, _, _ := strings.Cut(old, " → "); prev == cmd { + s.Queries[i] = q + return + } + } + s.Queries = append(s.Queries, q) +} + +func add(c *Check, name, status, format string, args ...any) *Step { + c.Steps = append(c.Steps, Step{Name: name, Status: status, Detail: fmt.Sprintf(format, args...)}) + return &c.Steps[len(c.Steps)-1] +} + +// How long the check waits for the record to show up, and the name +// servers' port (tests change them). +var ( + propagationWait = 90 * time.Second + pollEvery = 2 * time.Second + nsPort = "53" +) + +// Preflight checks, without the CA, that a certificate can be obtained: +// Jarl answers and knows the token, a zone holds the challenge name, the +// token may write it, a test record can be written, the zone's name +// servers and public resolvers serve it, and it can be removed again. +func (m *Manager) Preflight(ctx context.Context) *Check { + c := &Check{Time: time.Now().UTC(), Domain: m.cfg.ACME.Domain} + defer func() { + c.OK = true + for _, s := range c.Steps { + if s.Status == "fail" { + c.OK = false + } + } + m.mu.Lock() + m.lastCheck = c + m.mu.Unlock() + }() + name := jarl.ChallengeName(m.cfg.ACME.Domain) + // A CNAME at the challenge name sends the challenge elsewhere (into a + // zone kept for challenges); the CA and lego follow it, so the check + // does too. + if target := m.cname(ctx, name); target != "" { + name = target + } + shown := strings.TrimSuffix(name, ".") + + who, err := m.jarl.Whoami(ctx) + if err != nil { + add(c, "Jarl", "fail", "%s", explainJarl(err)) + return c + } + add(c, "Jarl", "ok", "the token is %s's, on Jarl %s", who.User.Username, who.Build.Label) + + all, err := m.jarl.Zones(ctx) + if err != nil { + add(c, "Zone", "fail", "listing the zones: %s", explainJarl(err)) + return c + } + zones := jarl.ZonesFor(name, all, "") + if len(zones) == 0 { + add(c, "Zone", "fail", "no primary zone the token can see holds %s: add the zone to Jarl, or give the token's user access to it (zones=...)", shown) + return c + } + zone := strings.TrimSuffix(zones[0].Name, ".") + // Every view with the zone gets the record (linked zones through + // their link), so name them all. + var inViews []jarl.Zone + for _, z := range all { + if strings.EqualFold(z.Name, zones[0].Name) { + inViews = append(inViews, z) + } + } + add(c, "Zone", "ok", "%s%s", zone, viewsText(inViews)) + + value := "steward-check-" + auth.RandomString(8) + if err := m.jarl.SetTXT(ctx, name, value, zones, true, true, "Steward's certificate check"); err != nil { + add(c, "Permission", "fail", "%s", explainWrite(err, zone, shown)) + return c + } + add(c, "Permission", "ok", "the token may write TXT records at %s", shown) + + if err := m.jarl.SetTXT(ctx, name, value, zones, true, false, "Steward's certificate check"); err != nil { + add(c, "Write", "fail", "writing a test record: %s", explainWrite(err, zone, shown)) + return c + } + add(c, "Write", "ok", "wrote TXT %q at %s; Jarl has it in named", value, shown) + defer func() { + clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) + defer cancel() + if err := m.jarl.SetTXT(clean, name, value, zones, false, false, "Steward's certificate check"); err != nil { + add(c, "Clean up", "fail", "removing the test record: %s (remove it in Jarl by hand)", explainJarl(err)) + return + } + add(c, "Clean up", "ok", "removed the test record") + }() + + authOK := true + if m.cfg.ACME.CheckPropagation { + authOK = m.checkNameServers(ctx, c, zone, name, value) + } else { + add(c, "Name servers", "skip", "tls.acme.check_propagation is off") + } + m.checkResolvers(ctx, c, name, value, authOK && m.cfg.ACME.CheckPropagation) + return c +} + +func viewsText(zs []jarl.Zone) string { + var views []string + for _, z := range zs { + if z.View != "" { + views = append(views, z.View) + } + } + switch len(views) { + case 0: + return "" + case 1: + return " in view " + views[0] + } + return " in views " + strings.Join(views, ", ") +} + +func explainJarl(err error) string { + var je *jarl.Error + if errors.As(err, &je) && je.Status == 401 { + return "Jarl refused the token (" + je.Message + "): check jarl.token or jarl.token_file" + } + return err.Error() +} + +func explainWrite(err error, zone, name string) string { + var je *jarl.Error + if errors.As(err, &je) && je.Status == 403 { + return fmt.Sprintf("the token may not write the challenge record (%s); give it the grant: records:write zones=%s names=%s types=TXT", je.Message, zone, name) + } + return explainJarl(err) +} + +// checkNameServers asks each of the zone's public name servers directly +// (as the CA's resolvers will) until all serve the value. +func (m *Manager) checkNameServers(ctx context.Context, c *Check, zone, name, value string) bool { + st := add(c, "Name servers", "fail", "") + ns, q, err := m.lookupNS(ctx, zone) + st.Queries = append(st.Queries, q...) + if err != nil || len(ns) == 0 { + st.Detail = fmt.Sprintf("the public resolvers know no name servers for %s, so the CA can't see it: is the zone delegated to your name servers? (%v)", zone, err) + return false + } + type server struct { + host string + addrs []string + } + var servers []server + for _, h := range ns { + addrs, q := m.lookupAddrs(ctx, h) + st.Queries = append(st.Queries, q...) + if len(addrs) == 0 { + st.Detail = fmt.Sprintf("name server %s has no address the public resolvers know", strings.TrimSuffix(h, ".")) + return false + } + servers = append(servers, server{h, addrs}) + } + // A server passes when any of its addresses serves the value (this + // machine may not reach its IPv6 address, for one). + deadline := time.Now().Add(propagationWait) + missing := servers + for { + var still []server + for _, s := range missing { + found := false + for _, a := range s.addrs { + ok, q := m.hasTXT(ctx, net.JoinHostPort(a, nsPort), name, value, false) + note(st, q) + if ok { + found = true + break + } + } + if !found { + still = append(still, s) + } + } + missing = still + if len(missing) == 0 { + st.Status, st.Detail = "ok", fmt.Sprintf("all %d name servers of %s serve it", len(servers), zone) + return true + } + if time.Now().After(deadline) || ctx.Err() != nil { + var names []string + for _, s := range missing { + names = append(names, strings.TrimSuffix(s.host, ".")+" ("+strings.Join(s.addrs, ", ")+")") + } + st.Detail = fmt.Sprintf("after %s, %s still don't serve it: a secondary that doesn't get the zone's updates (check NOTIFY and transfers), or an address that doesn't reach your name server", propagationWait, strings.Join(names, ", ")) + return false + } + sleep(ctx, pollEvery) + } +} + +// checkResolvers asks the public resolvers until each returns the value. +// When the name servers already serve it, a resolver that doesn't is only +// a warning: it may be holding an earlier answer. +func (m *Manager) checkResolvers(ctx context.Context, c *Check, name, value string, soft bool) { + st := add(c, "Public resolvers", "fail", "") + if len(m.cfg.ACME.Resolvers) == 0 { + st.Status, st.Detail = "skip", "no tls.acme.resolvers" + return + } + deadline := time.Now().Add(propagationWait) + missing := append([]string(nil), m.cfg.ACME.Resolvers...) + for { + var still []string + for _, r := range missing { + ok, q := m.hasTXT(ctx, r, name, value, true) + note(st, q) + if !ok { + still = append(still, r) + } + } + missing = still + if len(missing) == 0 { + st.Status, st.Detail = "ok", "every resolver sees it" + return + } + if time.Now().After(deadline) || ctx.Err() != nil { + st.Detail = fmt.Sprintf("after %s, %s don't see it", propagationWait, strings.Join(missing, ", ")) + if soft { + st.Status = "warn" + st.Detail += "; the name servers do, so they are probably holding an earlier answer" + } + return + } + sleep(ctx, pollEvery) + } +} + +func sleep(ctx context.Context, d time.Duration) { + select { + case <-ctx.Done(): + case <-time.After(d): + } +} + +// exchange asks server (HOST:PORT) one question. +func exchange(ctx context.Context, server, name string, qtype uint16, recurse bool) (*dns.Msg, string, error) { + if _, _, err := net.SplitHostPort(server); err != nil { + server = net.JoinHostPort(server, "53") + } + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(name), qtype) + m.RecursionDesired = recurse + host, port, _ := net.SplitHostPort(server) + q := fmt.Sprintf("dig @%s -p %s %s %s", host, port, dns.TypeToString[qtype], strings.TrimSuffix(name, ".")) + if !recurse { + q += " +norec" + } + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cl := &dns.Client{Timeout: 5 * time.Second} + r, _, err := cl.ExchangeContext(c, m, server) + if err == nil && r.Truncated { + cl.Net = "tcp" + r, _, err = cl.ExchangeContext(c, m, server) + } + return r, q, err +} + +func (m *Manager) hasTXT(ctx context.Context, server, name, value string, recurse bool) (bool, string) { + r, q, err := exchange(ctx, server, name, dns.TypeTXT, recurse) + if err != nil { + return false, q + " → " + err.Error() + } + for _, rr := range r.Answer { + if t, ok := rr.(*dns.TXT); ok && strings.Join(t.Txt, "") == value { + return true, q + " → found" + } + } + return false, q + " → " + dns.RcodeToString[r.Rcode] + ", not there (yet)" +} + +// cname returns the target of a CNAME at name ("" when there is none). +func (m *Manager) cname(ctx context.Context, name string) string { + for _, res := range m.cfg.ACME.Resolvers { + r, _, err := exchange(ctx, res, name, dns.TypeCNAME, true) + if err != nil { + continue + } + for _, rr := range r.Answer { + if c, ok := rr.(*dns.CNAME); ok { + return strings.ToLower(c.Target) + } + } + return "" + } + return "" +} + +// lookupNS asks the resolvers for a zone's name servers. +func (m *Manager) lookupNS(ctx context.Context, zone string) ([]string, []string, error) { + var queries []string + var last error = errors.New("no resolvers") + for _, res := range m.cfg.ACME.Resolvers { + r, q, err := exchange(ctx, res, zone, dns.TypeNS, true) + queries = append(queries, q) + if err != nil { + last = err + continue + } + var out []string + for _, rr := range r.Answer { + if ns, ok := rr.(*dns.NS); ok { + out = append(out, strings.ToLower(ns.Ns)) + } + } + if len(out) > 0 { + sort.Strings(out) + return out, queries, nil + } + last = fmt.Errorf("%s answered %s", res, dns.RcodeToString[r.Rcode]) + } + return nil, queries, last +} + +// lookupAddrs asks the resolvers for a host's addresses. +func (m *Manager) lookupAddrs(ctx context.Context, host string) ([]string, []string) { + var queries []string + for _, res := range m.cfg.ACME.Resolvers { + var out []string + for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} { + r, q, err := exchange(ctx, res, host, t, true) + queries = append(queries, q) + if err != nil { + continue + } + for _, rr := range r.Answer { + switch a := rr.(type) { + case *dns.A: + out = append(out, a.A.String()) + case *dns.AAAA: + out = append(out, a.AAAA.String()) + } + } + } + if len(out) > 0 { + return out, queries + } + } + return nil, queries +} diff --git a/internal/certs/check_test.go b/internal/certs/check_test.go new file mode 100644 index 0000000..61b9cdf --- /dev/null +++ b/internal/certs/check_test.go @@ -0,0 +1,266 @@ +package certs + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + + "steward/internal/config" + "steward/internal/jarl" +) + +// fakeJarl is Jarl's API as far as Steward uses it, and the DNS server +// that serves what it writes (both the "public resolver" and the zone's +// name server). +type fakeJarl struct { + mu sync.Mutex + txt map[string][]string // name -> values + canWrite bool + token string + served bool // the DNS server serves what is written + zones []jarl.Zone + writes int + dryRuns int + nsHasIPv6 bool +} + +func (f *fakeJarl) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+f.token { + w.WriteHeader(401) + w.Write([]byte(`{"error":{"code":"unauthenticated","message":"invalid API token: unknown token"}}`)) + return + } + switch r.URL.Path { + case "/api/v1/auth/whoami": + w.Write([]byte(`{"user":{"username":"steward"},"build":{"label":"v0.6.1"}}`)) + case "/api/v1/zones": + json.NewEncoder(w).Encode(f.zones) + case "/api/v1/apply": + var b struct { + Ops []struct { + Op string `json:"op"` + Data map[string]any `json:"data"` + } `json:"ops"` + DryRun bool `json:"dry_run"` + } + json.NewDecoder(r.Body).Decode(&b) + if !f.canWrite { + w.WriteHeader(403) + w.Write([]byte(`{"error":{"code":"forbidden","message":"permission denied: this needs records:write"}}`)) + return + } + f.mu.Lock() + defer f.mu.Unlock() + if b.DryRun { + f.dryRuns++ + w.Write([]byte(`{}`)) + return + } + f.writes++ + for _, op := range b.Ops { + name, v := op.Data["name"].(string), op.Data["data"].(string) + var keep []string + for _, x := range f.txt[name] { + if x != v { + keep = append(keep, x) + } + } + if op.Op == "record.create" { + keep = append(keep, v) + } + f.txt[name] = keep + } + w.Write([]byte(`{}`)) + default: + w.WriteHeader(404) + } +} + +func (f *fakeJarl) dns(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + q := r.Question[0] + hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 60} + switch q.Qtype { + case dns.TypeNS: + if q.Name == "adbraun.test." { + hdr.Rrtype = dns.TypeNS + m.Answer = append(m.Answer, &dns.NS{Hdr: hdr, Ns: "ns1.adbraun.test."}) + } else { + m.Rcode = dns.RcodeNameError + } + case dns.TypeA: + hdr.Rrtype = dns.TypeA + m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("127.0.0.1")}) + case dns.TypeAAAA: + // An IPv6 address nothing answers on (the check must not fail + // because of it). + if f.nsHasIPv6 { + hdr.Rrtype = dns.TypeAAAA + m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("100::1")}) + } + case dns.TypeCNAME: + if q.Name == "_acme-challenge.dhcp.other.test." { + hdr.Rrtype = dns.TypeCNAME + m.Answer = append(m.Answer, &dns.CNAME{Hdr: hdr, Target: "dhcp.acme.adbraun.test."}) + } + case dns.TypeTXT: + f.mu.Lock() + if f.served { + for _, v := range f.txt[q.Name] { + hdr.Rrtype = dns.TypeTXT + m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{v}}) + } + } + f.mu.Unlock() + } + w.WriteMsg(m) +} + +func setup(t *testing.T, f *fakeJarl, edit func(a *config.ACME)) (*Manager, string) { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(f.dns)} + go srv.ActivateAndServe() + t.Cleanup(func() { srv.Shutdown() }) + hs := httptest.NewServer(f) + t.Cleanup(hs.Close) + addr := pc.LocalAddr().String() + _, port, _ := net.SplitHostPort(addr) + oldPort, oldWait, oldPoll := nsPort, propagationWait, pollEvery + nsPort, propagationWait, pollEvery = port, 2*time.Second, 100*time.Millisecond + t.Cleanup(func() { nsPort, propagationWait, pollEvery = oldPort, oldWait, oldPoll }) + a := config.ACME{Domain: "dhcp.adbraun.test", Resolvers: []string{addr}, CheckPropagation: true} + if edit != nil { + edit(&a) + } + jc, _ := jarl.New(hs.URL, "steward_good", "", false) + m := &Manager{cfg: Config{ACME: a}, jarl: jc, now: time.Now} + return m, addr +} + +func steps(c *Check) string { + var out []string + for _, s := range c.Steps { + out = append(out, s.Name+"="+s.Status) + } + return strings.Join(out, " ") +} + +func newFake() *fakeJarl { + return &fakeJarl{txt: map[string][]string{}, canWrite: true, token: "steward_good", served: true, + zones: []jarl.Zone{{Name: "adbraun.test.", View: "internal", LinkGroup: 7}, {Name: "adbraun.test.", View: "external", LinkGroup: 7}, {Name: "other.test.", View: "internal"}}} +} + +func TestPreflightOK(t *testing.T) { + f := newFake() + f.nsHasIPv6 = true + m, _ := setup(t, f, nil) + c := m.Preflight(context.Background()) + want := "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=ok Public resolvers=ok Clean up=ok" + if !c.OK || steps(c) != want { + t.Fatalf("got %s\n%+v", steps(c), c.Steps) + } + if !strings.Contains(c.Steps[1].Detail, "views internal, external") { + t.Errorf("zone step: %s", c.Steps[1].Detail) + } + // Linked zones are written once; the test record is gone afterwards. + if f.dryRuns != 1 || f.writes != 2 || len(f.txt["_acme-challenge.dhcp.adbraun.test."]) != 0 { + t.Errorf("dry runs %d writes %d records %v", f.dryRuns, f.writes, f.txt) + } +} + +func TestPreflightFailures(t *testing.T) { + cases := []struct { + name string + edit func(f *fakeJarl, a *config.ACME) + want string + text string + }{ + {"bad token", func(f *fakeJarl, a *config.ACME) { f.token = "other" }, "Jarl=fail", "check jarl.token"}, + {"no zone", func(f *fakeJarl, a *config.ACME) { a.Domain = "dhcp.example.net" }, "Jarl=ok Zone=fail", "no primary zone the token can see"}, + {"no permission", func(f *fakeJarl, a *config.ACME) { f.canWrite = false }, "Jarl=ok Zone=ok Permission=fail", + "records:write zones=adbraun.test names=_acme-challenge.dhcp.adbraun.test types=TXT"}, + {"not served", func(f *fakeJarl, a *config.ACME) { f.served = false }, + "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=fail Clean up=ok", "still don't serve it"}, + {"not delegated", func(f *fakeJarl, a *config.ACME) { f.zones[0].Name, f.zones[1].Name = "test.", "test." }, + "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=ok Clean up=ok", "know no name servers for test"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFake() + var am *config.ACME + m, _ := setup(t, f, func(a *config.ACME) { am = a }) + tc.edit(f, am) + m.cfg.ACME = *am + c := m.Preflight(context.Background()) + if c.OK || steps(c) != tc.want { + t.Fatalf("got %s, want %s\n%+v", steps(c), tc.want, c.Steps) + } + all := "" + for _, s := range c.Steps { + all += s.Detail + "\n" + } + if !strings.Contains(all, tc.text) { + t.Errorf("details lack %q:\n%s", tc.text, all) + } + }) + } +} + +func TestResolversOnlyWarnWhenNameServersServe(t *testing.T) { + f := newFake() + m, _ := setup(t, f, nil) + // The "public resolver" is another server that never sees the record. + pc, _ := net.ListenPacket("udp", "127.0.0.1:0") + srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + mm := new(dns.Msg) + mm.SetReply(r) + if r.Question[0].Qtype == dns.TypeTXT { + mm.Rcode = dns.RcodeNameError + } else { + f.dns(w, r) + return + } + w.WriteMsg(mm) + })} + go srv.ActivateAndServe() + defer srv.Shutdown() + m.cfg.ACME.Resolvers = []string{pc.LocalAddr().String()} + c := m.Preflight(context.Background()) + if !c.OK || !strings.Contains(steps(c), "Public resolvers=warn") { + t.Fatalf("got %s %+v", steps(c), c.Steps) + } +} + +func TestPreflightFollowsCNAME(t *testing.T) { + f := newFake() + m, _ := setup(t, f, func(a *config.ACME) { a.Domain = "dhcp.other.test" }) + c := m.Preflight(context.Background()) + if !c.OK || !strings.Contains(c.Steps[2].Detail, "dhcp.acme.adbraun.test") { + t.Fatalf("got %s %+v", steps(c), c.Steps) + } +} + +func TestZonesFor(t *testing.T) { + zs := []jarl.Zone{{Name: "lol.", View: "a"}, {Name: "adbraun.lol.", View: "internal", LinkGroup: 3}, {Name: "adbraun.lol.", View: "external", LinkGroup: 3}, + {Name: "adbraun.lol.", View: "guest"}, {Name: "notadbraun.lol.", View: "internal"}} + got := jarl.ZonesFor("_acme-challenge.dhcp.adbraun.lol.", zs, "") + if len(got) != 2 || got[0].View != "internal" || got[1].View != "guest" { + t.Errorf("got %+v", got) + } + if n := jarl.ChallengeName("*.Dhcp.adbraun.lol."); n != "_acme-challenge.dhcp.adbraun.lol." { + t.Errorf("challenge name %q", n) + } +} diff --git a/internal/certs/manager.go b/internal/certs/manager.go new file mode 100644 index 0000000..f3146b4 --- /dev/null +++ b/internal/certs/manager.go @@ -0,0 +1,484 @@ +// Package certs obtains and renews the web UI's certificate from an ACME CA +// (Let's Encrypt), with Jarl publishing the DNS-01 challenge +// (docs/certificates.md). Before every order a preflight check proves the +// challenge can be published and seen, so a broken setup never spends the +// CA's rate limits. +package certs + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/lego" + legolog "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/registration" + + "steward/internal/api" + "steward/internal/config" + "steward/internal/jarl" +) + +// Store keeps the account and certificate (the database's meta table). +type Store interface { + GetMeta(ctx context.Context, key string) (string, error) + SetMeta(ctx context.Context, key, value string) error +} + +const ( + metaAccount = "acme.account" + metaCert = "acme.certificate" +) + +// Config configures the manager. +type Config struct { + ACME config.ACME + Version string +} + +// Status is api.CertStatus. +type Status = api.CertStatus + +// Manager keeps the web UI's certificate. +type Manager struct { + cfg Config + jarl *jarl.Client + store Store + log *slog.Logger + now func() time.Time + + mu sync.Mutex + cert *tls.Certificate + leaf *x509.Certificate + issuedBy string + working bool + lastErr string + lastAttempt time.Time + nextAttempt time.Time + lastCheck *Check + failures int + force bool // renew now, even if not due (Kick) + + lego sync.Mutex // one ACME order at a time + kick chan struct{} +} + +// New makes a manager and loads the stored certificate. +func New(ctx context.Context, cfg Config, j *jarl.Client, st Store, log *slog.Logger) *Manager { + if log == nil { + log = slog.Default() + } + // lego logs each step of an order; send it to the journal with the rest. + legolog.Logger = slog.NewLogLogger(log.With("component", "acme").Handler(), slog.LevelInfo) + m := &Manager{cfg: cfg, jarl: j, store: st, log: log, now: time.Now, kick: make(chan struct{}, 1)} + m.load(ctx) + return m +} + +type stored struct { + Domain string `json:"domain"` + Directory string `json:"directory"` + CertPEM string `json:"cert_pem"` + KeyPEM string `json:"key_pem"` + Obtained time.Time `json:"obtained"` +} + +func (m *Manager) load(ctx context.Context) { + raw, err := m.store.GetMeta(ctx, metaCert) + if err != nil || raw == "" { + return + } + var s stored + if json.Unmarshal([]byte(raw), &s) != nil { + return + } + if err := m.use(s); err != nil { + m.log.Warn("the stored certificate can't be used; getting a new one", "err", err) + } +} + +// use makes a stored certificate the one served. +func (m *Manager) use(s stored) error { + c, err := tls.X509KeyPair([]byte(s.CertPEM), []byte(s.KeyPEM)) + if err != nil { + return err + } + leaf, err := x509.ParseCertificate(c.Certificate[0]) + if err != nil { + return err + } + c.Leaf = leaf + m.mu.Lock() + defer m.mu.Unlock() + // A certificate for another name isn't served. + if !strings.EqualFold(s.Domain, m.cfg.ACME.Domain) { + m.cert, m.leaf = nil, nil + return fmt.Errorf("it is for %s, not %s", s.Domain, m.cfg.ACME.Domain) + } + m.cert, m.leaf, m.issuedBy = &c, leaf, s.Directory + return nil +} + +// Certificate is the certificate to serve (nil when there is none yet). +func (m *Manager) Certificate() *tls.Certificate { + m.mu.Lock() + defer m.mu.Unlock() + if m.cert == nil || m.now().After(m.leaf.NotAfter) { + return nil + } + return m.cert +} + +// Status describes the certificate. +func (m *Manager) Status() Status { + m.mu.Lock() + defer m.mu.Unlock() + st := Status{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), Staging: m.cfg.ACME.Staging(), State: "none", + IssuedBy: m.issuedBy, LastError: m.lastErr, Check: m.lastCheck} + if m.leaf != nil { + nb, na := m.leaf.NotBefore, m.leaf.NotAfter + ra := m.renewAt() + st.NotBefore, st.NotAfter, st.RenewAt, st.Issuer, st.State = &nb, &na, &ra, m.leaf.Issuer.CommonName, "ok" + } + if m.lastErr != "" { + st.State = "error" + } + if m.working { + st.State = "working" + } + if !m.lastAttempt.IsZero() { + t := m.lastAttempt + st.LastAttempt = &t + } + if !m.nextAttempt.IsZero() { + t := m.nextAttempt + st.NextAttempt = &t + } + return st +} + +// renewAt is when the certificate is renewed: a third of its lifetime +// before it ends (30 days for Let's Encrypt's 90), m.mu held. +func (m *Manager) renewAt() time.Time { + life := m.leaf.NotAfter.Sub(m.leaf.NotBefore) + return m.leaf.NotAfter.Add(-life / 3).UTC().Truncate(time.Second) +} + +// due reports whether a new certificate is needed: none yet, another +// directory configured, or renewal time. +func (m *Manager) due() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.force || m.leaf == nil || m.issuedBy != m.cfg.ACME.DirectoryURL() || !m.now().Before(m.renewAt()) +} + +// Run keeps the certificate: it gets one when needed, checks twice a day +// whether it is due, and retries failures with a growing wait. +func (m *Manager) Run(ctx context.Context) { + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-m.kick: + case <-timer.C: + } + wait := 12 * time.Hour + if m.due() { + if err := m.Renew(ctx); err != nil { + m.mu.Lock() + m.failures++ + // 1h, 3h, 6h, then 12h. + waits := []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour} + wait = waits[min(m.failures, len(waits))-1] + m.mu.Unlock() + m.log.Warn("getting the web UI's certificate failed; trying again later", "domain", m.cfg.ACME.Domain, "err", err, "retry_in", wait) + } + } + m.mu.Lock() + m.nextAttempt = m.now().Add(wait).UTC() + m.mu.Unlock() + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(wait) + } +} + +// Kick asks Run to renew now, even if the certificate isn't due. +func (m *Manager) Kick() { + m.mu.Lock() + m.force, m.failures = true, 0 + m.mu.Unlock() + select { + case m.kick <- struct{}{}: + default: + } +} + +// ErrBusy is returned while another order runs. +var ErrBusy = errors.New("a certificate order is already running") + +// Renew gets a new certificate now: the preflight check, then the order. +func (m *Manager) Renew(ctx context.Context) error { + if !m.lego.TryLock() { + return ErrBusy + } + defer m.lego.Unlock() + m.mu.Lock() + m.working, m.lastAttempt = true, m.now().UTC() + m.mu.Unlock() + err := m.renew(ctx) + m.mu.Lock() + m.working, m.force = false, false + if err != nil { + m.lastErr = err.Error() + } else { + m.lastErr, m.failures = "", 0 + } + m.mu.Unlock() + return err +} + +func (m *Manager) renew(ctx context.Context) error { + c := m.Preflight(ctx) + if !c.OK { + for _, s := range c.Steps { + if s.Status == "fail" { + return fmt.Errorf("the check before asking the CA failed at %s: %s", strings.ToLower(s.Name), s.Detail) + } + } + } + m.log.Info("the check passed; asking the CA for a certificate", "domain", m.cfg.ACME.Domain, "directory", m.cfg.ACME.DirectoryURL()) + res, err := m.obtain(ctx) + if err != nil { + return err + } + s := stored{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), CertPEM: string(res.Certificate), KeyPEM: string(res.PrivateKey), Obtained: m.now().UTC()} + if err := m.use(s); err != nil { + return fmt.Errorf("the CA's certificate can't be used: %w", err) + } + b, _ := json.Marshal(s) + if err := m.store.SetMeta(ctx, metaCert, string(b)); err != nil { + return err + } + m.mu.Lock() + na := m.leaf.NotAfter + m.mu.Unlock() + m.log.Info("got a new certificate for the web UI", "domain", s.Domain, "until", na.Format(time.RFC3339), "staging", m.cfg.ACME.Staging()) + return nil +} + +// ---- ACME + +type account struct { + Directory string `json:"directory"` + Email string `json:"email,omitempty"` + URI string `json:"uri,omitempty"` + KeyPEM string `json:"key_pem"` +} + +type legoUser struct { + email string + reg *registration.Resource + key crypto.PrivateKey +} + +func (u *legoUser) GetEmail() string { return u.email } +func (u *legoUser) GetRegistration() *registration.Resource { return u.reg } +func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key } + +func (m *Manager) httpClient() (*http.Client, error) { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + if f := m.cfg.ACME.CABundle; f != "" { + pemData, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("tls.acme.ca_bundle: %w", err) + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pemData) { + return nil, fmt.Errorf("tls.acme.ca_bundle: no certificates in %s", f) + } + tr.TLSClientConfig.RootCAs = pool + } + return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil +} + +// client returns a lego client for the account, registering it if needed. +func (m *Manager) client(ctx context.Context) (*lego.Client, error) { + dir := m.cfg.ACME.DirectoryURL() + hc, err := m.httpClient() + if err != nil { + return nil, err + } + user := &legoUser{email: m.cfg.ACME.Email} + var a account + if raw, _ := m.store.GetMeta(ctx, metaAccount); raw != "" && json.Unmarshal([]byte(raw), &a) == nil && a.Directory == dir && a.KeyPEM != "" { + b, _ := pem.Decode([]byte(a.KeyPEM)) + if b == nil { + return nil, errors.New("the stored ACME account key isn't PEM") + } + if user.key, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil { + return nil, fmt.Errorf("the stored ACME account key: %w", err) + } + if a.URI != "" { + user.reg = ®istration.Resource{URI: a.URI} + } + } else { + if user.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil { + return nil, err + } + } + cfg := lego.NewConfig(user) + cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "steward/"+m.cfg.Version + cfg.Certificate.Timeout = 90 * time.Second + c, err := lego.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("the ACME server %s: %w", dir, err) + } + if user.reg != nil { + return c, nil + } + if !m.cfg.ACME.AgreeTOS { + tos := c.GetToSURL() + if tos == "" { + tos = "on the CA's website" + } + return nil, fmt.Errorf("Steward registers an account with %s only after you accept its terms of service (%s): set tls.acme.agree_tos: true in steward.yaml and restart stewardd", dir, tos) + } + reg, err := c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + if err != nil { + return nil, fmt.Errorf("registering the ACME account: %w", err) + } + user.reg = reg + der, err := x509.MarshalPKCS8PrivateKey(user.key) + if err != nil { + return nil, err + } + b, _ := json.Marshal(account{Directory: dir, Email: user.email, URI: reg.URI, + KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))}) + if err := m.store.SetMeta(ctx, metaAccount, string(b)); err != nil { + return nil, err + } + m.log.Info("ACME account registered", "directory", dir, "account", reg.URI) + return c, nil +} + +// provider publishes the challenge through Jarl. +type provider struct { + m *Manager + ctx context.Context +} + +func (p *provider) set(domain, keyAuth string, set bool) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + ctx := p.ctx + if !set { + ctx = context.WithoutCancel(ctx) + } + all, err := p.m.jarl.Zones(ctx) + if err != nil { + return err + } + zones := jarl.ZonesFor(info.EffectiveFQDN, all, "") + if len(zones) == 0 { + return fmt.Errorf("no primary zone in Jarl holds %s", strings.TrimSuffix(info.EffectiveFQDN, ".")) + } + return p.m.jarl.SetTXT(ctx, info.EffectiveFQDN, info.Value, zones, set, false, "Steward's certificate") +} + +func (p *provider) Present(domain, token, keyAuth string) error { return p.set(domain, keyAuth, true) } +func (p *provider) CleanUp(domain, token, keyAuth string) error { return p.set(domain, keyAuth, false) } + +// Timeout is how long lego waits for the record to reach the name servers. +func (p *provider) Timeout() (timeout, interval time.Duration) { + return 5 * time.Minute, 5 * time.Second +} + +func (m *Manager) obtain(ctx context.Context) (*certificate.Resource, error) { + c, err := m.client(ctx) + if err != nil { + return nil, err + } + opts := []dns01.ChallengeOption{dns01.AddDNSTimeout(10 * time.Second)} + if len(m.cfg.ACME.Resolvers) > 0 { + opts = append(opts, dns01.AddRecursiveNameservers(m.cfg.ACME.Resolvers)) + } + if !m.cfg.ACME.CheckPropagation { + opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement()) + } + if err := c.Challenge.SetDNS01Provider(&provider{m: m, ctx: ctx}, opts...); err != nil { + return nil, err + } + res, err := c.Certificate.Obtain(certificate.ObtainRequest{Domains: []string{m.cfg.ACME.Domain}, Bundle: true}) + if err != nil { + return nil, cleanLegoError(err) + } + return res, nil +} + +// cleanLegoError shortens lego's errors (as Jarl does): "[name] acme: +// error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG" becomes +// "name: the CA refused (unauthorized): MSG". +func cleanLegoError(err error) error { + msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1)) + var out []string + for _, line := range strings.Split(msg, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + domain := "" + for strings.HasPrefix(line, "[") { + end := strings.Index(line, "] ") + if end < 0 { + break + } + if domain == "" { + domain = line[1:end] + } + line = line[end+2:] + } + for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} { + line = strings.TrimPrefix(line, p) + } + if mm := problemRe.FindStringSubmatch(line); mm != nil { + line = "the CA refused (" + mm[2] + "): " + mm[3] + } + if domain != "" { + line = domain + ": " + line + } + out = append(out, line) + } + if len(out) == 0 { + return err + } + return errors.New(strings.Join(out, "; ")) +} + +var problemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`) diff --git a/internal/config/config.go b/internal/config/config.go index a696a69..b1138d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,9 @@ package config import ( "bytes" "fmt" + "net" "net/netip" + "net/url" "os" "path/filepath" "strings" @@ -31,12 +33,86 @@ type Config struct { Log Log `yaml:"log"` Session Session `yaml:"session"` Backup Backup `yaml:"backup"` + // Jarl is the Jarl server Steward uses for DNS (docs/jarl.md). + Jarl Jarl `yaml:"jarl"` } -// TLS is a certificate for HTTPS. +// TLS is the web UI's certificate: from files, or obtained through ACME +// with Jarl publishing the DNS-01 challenge (docs/certificates.md). type TLS struct { CertFile string `yaml:"cert_file"` KeyFile string `yaml:"key_file"` + ACME ACME `yaml:"acme"` +} + +// ACME obtains the web UI's certificate (docs/certificates.md). +type ACME struct { + // Domain is the certificate's name ("" turns ACME off). + Domain string `yaml:"domain"` + Email string `yaml:"email"` + // Directory is letsencrypt, letsencrypt-staging (the default, for a + // first certificate browsers don't trust) or an ACME directory URL. + Directory string `yaml:"directory"` + // AgreeTOS says you accept the CA's terms of service; no account is + // registered without it. + AgreeTOS bool `yaml:"agree_tos"` + // Resolvers are public recursive resolvers, to see the challenge as + // the CA would. + Resolvers []string `yaml:"resolvers"` + // CheckPropagation also asks each of the zone's public name servers + // directly before the CA is told to look. + CheckPropagation bool `yaml:"check_propagation"` + // CABundle is a PEM file of extra CAs to trust for the ACME server + // (a private CA such as step-ca, or Pebble in tests). + CABundle string `yaml:"ca_bundle"` +} + +// Directory URLs for the names tls.acme.directory accepts. +const ( + LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory" + LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory" +) + +// DirectoryURL is the ACME directory's URL. +func (a ACME) DirectoryURL() string { + switch a.Directory { + case "", "letsencrypt-staging": + return LetsEncryptStaging + case "letsencrypt": + return LetsEncrypt + } + return a.Directory +} + +// Staging reports whether the directory is Let's Encrypt's staging one. +func (a ACME) Staging() bool { return a.DirectoryURL() == LetsEncryptStaging } + +// Jarl is a Jarl server and an API token for it. +type Jarl struct { + // Server is Jarl's URL ("" when Steward doesn't use Jarl). + Server string `yaml:"server"` + // Token, or TokenFile holding it (better: the file can be readable by + // the steward user alone). + Token string `yaml:"token"` + TokenFile string `yaml:"token_file"` + // CACert is a PEM file with a CA to trust for Jarl's certificate. + CACert string `yaml:"ca_cert"` + Insecure bool `yaml:"insecure"` +} + +// Enabled reports whether a Jarl server is configured. +func (j Jarl) Enabled() bool { return j.Server != "" } + +// ReadToken returns the token, from the setting or the file. +func (j Jarl) ReadToken() (string, error) { + if j.TokenFile == "" { + return j.Token, nil + } + b, err := os.ReadFile(j.TokenFile) + if err != nil { + return "", fmt.Errorf("jarl.token_file: %w", err) + } + return strings.TrimSpace(string(b)), nil } // Log configures logging to stderr (the journal under systemd). @@ -102,6 +178,7 @@ func Default() *Config { Log: Log{Level: "info", Format: "text"}, Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"}, Backup: Backup{Dir: "/var/backups/steward", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7}, + TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}}, } } @@ -140,6 +217,9 @@ func (c *Config) check() error { if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") { return fmt.Errorf("tls: set both cert_file and key_file") } + if err := c.checkACME(); err != nil { + return err + } for _, p := range c.TrustedProxies { if _, err := ParsePrefix(p); err != nil { return fmt.Errorf("trusted_proxies: %v", err) @@ -169,6 +249,44 @@ func (c *Config) check() error { return nil } +func (c *Config) checkACME() error { + j := c.Jarl + if j.Server != "" { + if u, err := url.Parse(j.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("jarl.server %q: give Jarl's URL, e.g. https://dns.example.com:8053", j.Server) + } + if j.Token == "" && j.TokenFile == "" { + return fmt.Errorf("jarl: set token or token_file (a Jarl API token, docs/jarl.md)") + } + if j.Token != "" && j.TokenFile != "" { + return fmt.Errorf("jarl: set token or token_file, not both") + } + } + a := c.TLS.ACME + if a.Domain == "" { + return nil + } + if c.TLS.CertFile != "" { + return fmt.Errorf("tls: cert_file and acme can't both be set; choose one") + } + if !j.Enabled() { + return fmt.Errorf("tls.acme needs Jarl to publish the challenge: set jarl.server and its token (docs/certificates.md)") + } + d := strings.TrimSuffix(strings.ToLower(a.Domain), ".") + if strings.HasPrefix(d, "*.") || strings.Count(d, ".") < 1 || strings.ContainsAny(d, " /:") { + return fmt.Errorf("tls.acme.domain %q: give the full name the web UI is reached by, e.g. dhcp.example.com", a.Domain) + } + if u := a.DirectoryURL(); !strings.HasPrefix(u, "https://") { + return fmt.Errorf("tls.acme.directory %q: use letsencrypt, letsencrypt-staging or an https:// ACME directory URL", a.Directory) + } + for _, r := range a.Resolvers { + if _, _, err := net.SplitHostPort(r); err != nil { + return fmt.Errorf("tls.acme.resolvers: %q must be HOST:PORT, e.g. 1.1.1.1:53", r) + } + } + return nil +} + // ParsePrefix parses an address or a prefix. func ParsePrefix(s string) (netip.Prefix, error) { if strings.Contains(s, "/") { @@ -204,10 +322,37 @@ const Example = `# Steward daemon configuration (/etc/steward/steward.yaml). listen: - "127.0.0.1:8067" -# HTTPS from a certificate you renew yourself (reread when it changes). +# HTTPS for the web UI and API: a certificate from files you renew yourself +# (reread when they change)... # tls: # cert_file: /etc/steward/tls/fullchain.pem # key_file: /etc/steward/tls/privkey.pem +# +# ...or one Steward obtains and renews itself from Let's Encrypt, with Jarl +# publishing the DNS-01 challenge (needs jarl: below; docs/certificates.md). +# Start with the staging directory: its certificate isn't trusted by +# browsers, but it proves everything works without Let's Encrypt's rate +# limits. Then set directory: letsencrypt. +tls: + acme: + domain: "" # e.g. dhcp.example.com; "" is off + email: "" + directory: letsencrypt-staging # letsencrypt, or an ACME directory URL + agree_tos: false # you accept the CA's terms of service + # Public resolvers, to see the challenge as the CA does. + resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"] + # Also ask the zone's public name servers directly. + check_propagation: true + ca_bundle: "" # extra CA for a private ACME server + +# Jarl, the BIND manager, for DNS records (docs/jarl.md). Give Steward a +# token that may only write its challenge record; keep it in a file only +# the steward user can read. +# jarl: +# server: https://dns.example.com:8053 +# token_file: /etc/steward/jarl.token +# ca_cert: "" # a CA to trust for Jarl's certificate +# insecure: false # Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are # believed. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 580d98a..3716e21 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,12 +22,18 @@ func TestExampleIsTheDefaults(t *testing.T) { func TestErrors(t *testing.T) { cases := map[string]string{ - "listen: []": "listen", - "database: relative.db": "database", - "session: {cookie_secure: x}": "cookie_secure", - "tls: {cert_file: /a}": "tls", - "no_such_setting: 1": "no_such_setting", - "backup: {retention: 3x}": "duration", + "listen: []": "listen", + "database: relative.db": "database", + "session: {cookie_secure: x}": "cookie_secure", + "tls: {cert_file: /a}": "tls", + "no_such_setting: 1": "no_such_setting", + "backup: {retention: 3x}": "duration", + "tls: {acme: {domain: dhcp.example.com}}": "needs Jarl", + "jarl: {server: https://dns.example.com}": "token or token_file", + "jarl: {server: dns.example.com, token: x}": "Jarl's URL", + "{tls: {cert_file: /a, key_file: /b, acme: {domain: d.example.com}}, jarl: {server: https://j, token: x}}": "can't both", + "{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name", + "{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://", } for yml, want := range cases { path := filepath.Join(t.TempDir(), "s.yaml") diff --git a/internal/jarl/jarl.go b/internal/jarl/jarl.go new file mode 100644 index 0000000..ccb8cf6 --- /dev/null +++ b/internal/jarl/jarl.go @@ -0,0 +1,249 @@ +// Package jarl talks to Jarl, the BIND manager, through its REST API: who +// the token belongs to, which zones it can see, and DNS-01 challenge +// records. It writes challenge records the way 'jarl acme' does: into the +// longest matching primary zone, in every view that has it (once per set +// of linked zones), so the CA and internal resolvers see the same thing. +package jarl + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "steward/internal/version" +) + +// ChallengeTTL is the TTL of challenge records (as in Jarl). +const ChallengeTTL = 60 + +// Comment marks the challenge records Steward writes. +const Comment = "ACME DNS-01 challenge (Steward)" + +// Client is a Jarl server and a token. +type Client struct { + base string + token string + http *http.Client +} + +// New makes a client. caCert is a PEM file of a CA to trust ("" for the +// system's). +func New(server, token, caCert string, insecure bool) (*Client, error) { + tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} //nolint:gosec // the user's choice + if caCert != "" { + pem, err := os.ReadFile(caCert) + if err != nil { + return nil, fmt.Errorf("jarl.ca_cert: %w", err) + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("jarl.ca_cert: no certificates in %s", caCert) + } + tc.RootCAs = pool + } + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = tc + return &Client{base: strings.TrimSuffix(server, "/") + "/api/v1", token: token, http: &http.Client{Transport: tr, Timeout: 2 * time.Minute}}, nil +} + +// Error is an error answer from Jarl. +type Error struct { + Status int + Code string + Message string +} + +func (e *Error) Error() string { return fmt.Sprintf("Jarl answered %d: %s", e.Status, e.Message) } + +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var rd io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rd = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "steward/"+version.Short()) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("can't reach Jarl at %s: %w", strings.TrimSuffix(c.base, "/api/v1"), err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + var eb struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + e := &Error{Status: resp.StatusCode, Message: strings.TrimSpace(string(b))} + if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" { + e.Code, e.Message = eb.Error.Code, eb.Error.Message + } + return e + } + if out != nil { + return json.Unmarshal(b, out) + } + return nil +} + +// Whoami is who the token belongs to. +type Whoami struct { + User struct { + Username string `json:"username"` + } `json:"user"` + Admin bool `json:"admin"` + Method string `json:"method"` + Permissions []string `json:"permissions"` + Build struct { + Label string `json:"label"` + } `json:"build"` +} + +// Whoami asks Jarl who the token is. +func (c *Client) Whoami(ctx context.Context) (*Whoami, error) { + var w Whoami + return &w, c.do(ctx, http.MethodGet, "/auth/whoami", nil, &w) +} + +// Zone is a primary zone the token can see. +type Zone struct { + Name string `json:"name"` // "adbraun.lol." + View string `json:"view,omitempty"` + LinkGroup int64 `json:"link_group,omitempty"` +} + +// Zones lists the primary zones the token can see. +func (c *Client) Zones(ctx context.Context) ([]Zone, error) { + var zs []Zone + return zs, c.do(ctx, http.MethodGet, "/zones?type=primary", nil, &zs) +} + +func fqdn(n string) string { + n = strings.ToLower(strings.TrimSpace(n)) + if !strings.HasSuffix(n, ".") { + n += "." + } + return n +} + +// ChallengeName is the record name for a domain's DNS-01 challenge. +func ChallengeName(domain string) string { + d := strings.TrimPrefix(strings.ToLower(strings.TrimSuffix(domain, ".")), "*.") + if !strings.HasPrefix(d, "_acme-challenge.") { + d = "_acme-challenge." + d + } + return d + "." +} + +// ZonesFor picks the zones a record at name goes into: the longest +// matching zone, in every view that has it (or only view), once per set of +// linked zones. +func ZonesFor(name string, zones []Zone, view string) []Zone { + name = fqdn(name) + best := "" + var out []Zone + for _, z := range zones { + if view != "" && !strings.EqualFold(z.View, view) { + continue + } + zn := fqdn(z.Name) + if name != zn && !strings.HasSuffix(name, "."+zn) && zn != "." { + continue + } + switch { + case len(zn) > len(best): + best, out = zn, []Zone{z} + case zn == best: + out = append(out, z) + } + } + var dedup []Zone + links := map[int64]bool{} + for _, z := range out { + if z.LinkGroup != 0 { + if links[z.LinkGroup] { + continue + } + links[z.LinkGroup] = true + } + dedup = append(dedup, z) + } + return dedup +} + +type op struct { + Op string `json:"op"` + Data any `json:"data"` +} + +type batch struct { + Description string `json:"description"` + Ops []op `json:"ops"` + DryRun bool `json:"dry_run,omitempty"` +} + +// SetTXT publishes (set) or removes a TXT value at name in zones, as one +// change in Jarl. Setting first removes the same value, so a retry +// succeeds; removing a value that isn't there succeeds too. With dryRun +// Jarl only checks the change, including the token's permission. +func (c *Client) SetTXT(ctx context.Context, name, value string, zones []Zone, set, dryRun bool, why string) error { + if len(zones) == 0 { + return errors.New("no zone to write into") + } + var ops []op + for _, z := range zones { + base := map[string]any{"zone": z.Name, "name": fqdn(name), "type": "TXT", "data": value} + if z.View != "" { + base["view"] = z.View + } + del := map[string]any{"missing_ok": true} + for k, v := range base { + del[k] = v + } + ops = append(ops, op{Op: "record.delete", Data: del}) + if set { + add := map[string]any{"ttl": ChallengeTTL, "comment": Comment} + for k, v := range base { + add[k] = v + } + ops = append(ops, op{Op: "record.create", Data: add}) + } + } + verb := "remove" + if set { + verb = "set" + } + desc := "ACME: " + verb + " challenge for " + strings.TrimSuffix(name, ".") + if why != "" { + desc += " (" + why + ")" + } + return c.do(ctx, http.MethodPost, "/apply", batch{Description: desc, Ops: ops, DryRun: dryRun}, nil) +} diff --git a/internal/server/h_cert.go b/internal/server/h_cert.go new file mode 100644 index 0000000..6ace299 --- /dev/null +++ b/internal/server/h_cert.go @@ -0,0 +1,94 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "time" + + "steward/internal/api" + "steward/internal/auth" + "steward/internal/certs" + "steward/internal/validate" +) + +func (s *Server) certificate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + out := api.CertificateResponse{Source: "none"} + switch { + case s.cfg.ACME != nil: + st := s.cfg.ACME.Status() + out.Configured, out.Source, out.Status = true, "acme", &st + case s.cfg.TLSCertFile != "": + out.Source = "file" + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) acmeManager() (*certs.Manager, error) { + if s.cfg.ACME == nil { + return nil, conflict("stewardd doesn't obtain its certificate: set tls.acme and jarl in steward.yaml (docs/certificates.md)") + } + return s.cfg.ACME, nil +} + +// certificateCheck: POST /certificate/check runs the preflight check now +// (it takes up to a few minutes when the record is slow to appear). +func (s *Server) certificateCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + m, err := s.acmeManager() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute) + defer cancel() + c := m.Preflight(ctx) + s.eng.Audit(r.Context(), p, "certificate.check", m.Status().Domain, map[bool]string{true: "success", false: "failure"}[c.OK], "") + writeJSON(w, http.StatusOK, c) + return nil +} + +// certificateRenew: POST /certificate/renew gets a new certificate now, in +// the background (GET /certificate follows it). +func (s *Server) certificateRenew(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + m, err := s.acmeManager() + if err != nil { + return err + } + m.Kick() + s.eng.Audit(r.Context(), p, "certificate.renew", m.Status().Domain, "success", "") + st := m.Status() + writeJSON(w, http.StatusAccepted, api.CertificateResponse{Configured: true, Source: "acme", Status: &st}) + return nil +} + +// certificateProblems are the certificate's problems for /status. +func (s *Server) certificateProblems() []validate.Problem { + if s.cfg.ACME == nil { + return nil + } + st := s.cfg.ACME.Status() + obj := "certificate " + st.Domain + var out []validate.Problem + if st.LastError != "" { + out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_failed", Object: obj, + Message: "getting the web UI's certificate failed: " + st.LastError}) + } + if st.NotAfter != nil && time.Until(*st.NotAfter) < 14*24*time.Hour { + out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_expiring", Object: obj, + Message: fmt.Sprintf("the certificate ends %s and hasn't been renewed", st.NotAfter.Local().Format("2006-01-02 15:04"))}) + } + if st.State == "ok" && st.Staging { + out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_staging", Object: obj, + Message: "the certificate is from Let's Encrypt's staging service, which browsers don't trust: once it works, set tls.acme.directory: letsencrypt and restart stewardd"}) + } + return out +} diff --git a/internal/server/h_leases.go b/internal/server/h_leases.go index 2ad6874..0f40e10 100644 --- a/internal/server/h_leases.go +++ b/internal/server/h_leases.go @@ -172,6 +172,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa leases := s.dhcp.Leases() out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{}, Problems: s.eng.Problems(), Leases: map[string]int{}, Messages: s.dhcp.Counts(), Database: s.store.Path()} + if p.IsAdmin() { + out.Problems = append(out.Problems, s.certificateProblems()...) + } if out.Problems == nil { out.Problems = []validate.Problem{} } diff --git a/internal/server/routes.go b/internal/server/routes.go index 536362e..8542069 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -63,6 +63,9 @@ func (s *Server) routes() { // Status and audit m.HandleFunc("GET "+p+"/status", s.authed(s.status)) m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit)) + m.HandleFunc("GET "+p+"/certificate", s.authed(s.certificate)) + m.HandleFunc("POST "+p+"/certificate/check", s.authed(s.certificateCheck)) + m.HandleFunc("POST "+p+"/certificate/renew", s.authed(s.certificateRenew)) m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path) }) diff --git a/internal/server/server.go b/internal/server/server.go index f669c97..d35350a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,6 +19,7 @@ import ( "time" "steward/internal/auth" + "steward/internal/certs" "steward/internal/dhcpd" "steward/internal/engine" "steward/internal/store" @@ -40,6 +41,9 @@ type Config struct { Socket string // PingProblem says why the ping check can't run (nil: always fine). PingProblem func() string + // ACME keeps the certificate stewardd obtains itself (tls.acme; nil + // when off). + ACME *certs.Manager } // Interfaces lists the host's interfaces. @@ -106,6 +110,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { if len(s.cfg.Listen) == 0 { return errors.New("no listen addresses configured") } + // With a certificate (tls.cert_file, or one stewardd obtains, which + // may turn up later), each listener serves HTTPS and plain HTTP side + // by side (sniff.go); plain HTTP is redirected once there is one. var tlsCfg *tls.Config if s.cfg.TLSCertFile != "" { cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log) @@ -113,7 +120,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { return err } s.certs = cr - tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get, NextProtos: []string{"h2", "http/1.1"}} + } + if s.certs != nil || s.cfg.ACME != nil { + tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}} } errc := make(chan error, len(s.cfg.Listen)+1) var listeners []net.Listener @@ -131,8 +140,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { listeners = append(listeners, ln) } for i, ln := range listeners { + handler := s.Handler() + if tlsCfg != nil { + handler = s.requireHTTPS(handler) + } srv := &http.Server{ - Handler: s.Handler(), + Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, WriteTimeout: 5 * time.Minute, @@ -145,11 +158,16 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { s.mu.Unlock() scheme := "http" if tlsCfg != nil { - scheme = "https" - ln = tls.NewListener(ln, tlsCfg) - } else if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil { - if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() { - s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i]) + if s.httpsAvailable() { + scheme = "https" + } + ln = newSniffListener(ln, tlsCfg) + } + if !s.httpsAvailable() && s.cfg.ACME == nil { + if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil { + if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() { + s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i]) + } } } s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String()) diff --git a/internal/server/sniff.go b/internal/server/sniff.go new file mode 100644 index 0000000..d5c4d26 --- /dev/null +++ b/internal/server/sniff.go @@ -0,0 +1,147 @@ +package server + +import ( + "bufio" + "crypto/tls" + "errors" + "net" + "net/http" + "net/netip" + "strings" + "sync" + "time" +) + +// sniffListener serves TLS and plain HTTP on the same port: it looks at a +// connection's first byte and hands the server a *tls.Conn when it is a +// TLS handshake (0x16), the plain connection otherwise. That way stewardd +// can start serving HTTPS when a certificate turns up (one it obtains, +// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets +// a redirect instead of an error. +type sniffListener struct { + net.Listener + cfg *tls.Config + ch chan net.Conn + errc chan error + done chan struct{} + once sync.Once +} + +func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener { + l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})} + go l.loop() + return l +} + +func (l *sniffListener) loop() { + for { + c, err := l.Listener.Accept() + if err != nil { + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + time.Sleep(50 * time.Millisecond) + continue + } + select { + case l.errc <- err: + default: + } + return + } + go l.classify(c) + } +} + +func (l *sniffListener) classify(c net.Conn) { + c.SetReadDeadline(time.Now().Add(10 * time.Second)) + br := bufio.NewReader(c) + b, err := br.Peek(1) + c.SetReadDeadline(time.Time{}) + if err != nil { + c.Close() + return + } + var out net.Conn = &peekedConn{Conn: c, r: br} + if b[0] == 0x16 { + out = tls.Server(out, l.cfg) + } + select { + case l.ch <- out: + case <-l.done: + c.Close() + } +} + +func (l *sniffListener) Accept() (net.Conn, error) { + select { + case c := <-l.ch: + return c, nil + case err := <-l.errc: + return nil, err + case <-l.done: + return nil, net.ErrClosed + } +} + +func (l *sniffListener) Close() error { + l.once.Do(func() { close(l.done) }) + return l.Listener.Close() +} + +// peekedConn reads through the buffer that holds the peeked byte. +type peekedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) } + +// webCertificate is the certificate stewardd serves: tls.cert_file's, or +// the one it obtains itself (tls.acme). +func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if s.certs != nil { + return s.certs.get(hello) + } + if s.cfg.ACME != nil { + if c := s.cfg.ACME.Certificate(); c != nil { + return c, nil + } + } + return nil, errNoCertificate +} + +var errNoCertificate = errors.New("no certificate for HTTPS yet") + +// httpsAvailable reports whether stewardd has a certificate to serve. +func (s *Server) httpsAvailable() bool { + return s.certs != nil || s.cfg.ACME != nil && s.cfg.ACME.Certificate() != nil +} + +// requireHTTPS sends plain-HTTP requests to HTTPS once there is a +// certificate: browsers (GET and HEAD) get a redirect, anything else an +// error, so tokens and passwords aren't sent in the clear. Requests from +// loopback (tools on this server) and from trusted proxies (which +// terminate TLS themselves) are served as they are. +func (s *Server) requireHTTPS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS != nil || !s.httpsAvailable() { + next.ServeHTTP(w, r) + return + } + host, _, _ := net.SplitHostPort(r.RemoteAddr) + if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) { + next.ServeHTTP(w, r) + return + } + target := "https://" + r.Host + r.URL.RequestURI() + if r.Method == http.MethodGet || r.Method == http.MethodHead { + http.Redirect(w, r, target, http.StatusPermanentRedirect) + return + } + msg := "stewardd serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI()) + if r.Header.Get("Authorization") != "" { + msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token" + } + writeError(w, http.StatusBadRequest, "https_required", msg) + }) +} diff --git a/internal/server/sniff_test.go b/internal/server/sniff_test.go new file mode 100644 index 0000000..96ee70d --- /dev/null +++ b/internal/server/sniff_test.go @@ -0,0 +1,58 @@ +package server + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestHTTPSAndPlainOnOnePort serves a certificate file: the same port +// answers HTTPS, and plain HTTP from loopback. +func TestHTTPSAndPlainOnOnePort(t *testing.T) { + dir := t.TempDir() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "dhcp.test"}, DNSNames: []string{"dhcp.test"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + kd, _ := x509.MarshalPKCS8PrivateKey(key) + os.WriteFile(filepath.Join(dir, "c.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600) + os.WriteFile(filepath.Join(dir, "k.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600) + ln, _ := net.Listen("tcp", "127.0.0.1:0") + addr := ln.Addr().String() + ln.Close() + e := newEnv(t) + e.srv.cfg.Listen, e.srv.cfg.TLSCertFile, e.srv.cfg.TLSKeyFile = []string{addr}, filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + go e.srv.Serve(ctx, func() { close(ready) }) + <-ready + pool := x509.NewCertPool() + pool.AddCert(func() *x509.Certificate { c, _ := x509.ParseCertificate(der); return c }()) + hc := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "dhcp.test"}}} + for _, url := range []string{"https://" + addr + "/healthz", "http://" + addr + "/healthz"} { + resp, err := hc.Get(url) + if err != nil { + t.Fatalf("%s: %v", url, err) + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 || !strings.Contains(string(b), `"ok":true`) { + t.Errorf("%s: %d %s", url, resp.StatusCode, b) + } + } +} diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index 0570c0c..cfef0c8 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -13,6 +13,7 @@ import * as history from "./pages/history.js"; import * as audit from "./pages/audit.js"; import * as users from "./pages/users.js"; import * as account from "./pages/account.js"; +import * as certificate from "./pages/certificate.js"; const routes = [ { pattern: /^$/, page: dashboard, nav: "dashboard" }, @@ -27,6 +28,7 @@ const routes = [ { pattern: /^audit$/, page: audit, nav: "audit" }, { pattern: /^users$/, page: users, nav: "users" }, { pattern: /^account$/, page: account, nav: "account" }, + { pattern: /^certificate$/, page: certificate, nav: "certificate" }, ]; // The top bar. ["key", "#/href", "Label", permission?] @@ -39,6 +41,7 @@ const navItems = [ ["history", "#/history", "History", "config"], { menu: "Server", items: [ ["settings", "#/settings", "Settings and options", "config"], + ["certificate", "#/certificate", "Certificate", "admin"], ["audit", "#/audit", "Audit log", "audit"], ] }, ]; @@ -116,7 +119,7 @@ function footer(b) { } function renderNav() { - const allowed = (n) => !n[3] || can(n[3], false); + const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false)); const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label); mount(navEl, navItems.map((n) => { if (!n.menu) return allowed(n) ? link(n) : null; diff --git a/internal/web/static/assets/pages/certificate.js b/internal/web/static/assets/pages/certificate.js new file mode 100644 index 0000000..2d27bc2 --- /dev/null +++ b/internal/web/static/assets/pages/certificate.js @@ -0,0 +1,86 @@ +// The web UI's certificate: obtained through Jarl (tls.acme), its state, +// the last check, and buttons to test and renew. +import { h, mount, table, time, fmtTime, badge, errorBox, toast } from "../ui.js"; +import { get, api } from "../api.js"; +import { onLeave, rerender } from "../app.js"; + +const stateBadge = { + ok: ["valid", "ok"], working: ["working…", "accent"], error: ["failing", "err"], none: ["none yet", ""], +}; + +export async function render(el) { + const r = await get("/certificate"); + if (r.source !== "acme") { + mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"))), + h("div", { class: "panel" }, + r.source === "file" + ? h("p", null, "The web UI uses the certificate in tls.cert_file, which you renew yourself.") + : h("p", null, "The web UI is plain HTTP. Steward can get its own certificate from Let's Encrypt, with Jarl publishing the challenge: set tls.acme and jarl in steward.yaml (see docs/certificates.md)."), + )); + return; + } + const st = r.status; + const out = h("div"); + const checkBox = h("div"); + const busy = st.state === "working"; + const testBtn = h("button", { disabled: busy, onclick: async () => { + testBtn.disabled = true; + mount(checkBox, h("div", { class: "msg info" }, h("span", { class: "spinner" }), "Checking: writing a test record through Jarl and waiting until it is seen (up to a few minutes)…")); + try { + const c = await api("POST", "/certificate/check"); + mount(checkBox, checkView(c)); + } catch (e) { mount(checkBox, errorBox(e)); } + testBtn.disabled = false; + } }, "Test"); + const renewBtn = h("button", { class: "primary", disabled: busy, onclick: async () => { + try { + await api("POST", "/certificate/renew"); + toast("Getting a new certificate…"); + setTimeout(rerender, 1500); + } catch (e) { toast(e.message, "err", 8000); } + } }, st.not_after ? "Renew now" : "Get the certificate now"); + const [label, kind] = stateBadge[st.state] || [st.state, ""]; + mount(out, + h("div", { class: "panel" }, + h("dl", { class: "kv" }, + h("dt", null, "Name"), h("dd", null, h("strong", null, st.domain), " ", badge(label, kind)), + h("dt", null, "From"), h("dd", null, st.staging ? ["Let's Encrypt staging ", badge("not trusted by browsers", "warn")] : st.directory), + st.not_after ? [h("dt", null, "Valid until"), h("dd", null, fmtTime(st.not_after), " (", days(st.not_after), "), issued by ", st.issuer)] : null, + st.renew_at ? [h("dt", null, "Renews"), h("dd", null, fmtTime(st.renew_at))] : null, + st.last_attempt ? [h("dt", null, "Last attempt"), h("dd", null, time(st.last_attempt))] : null, + st.next_attempt && !busy ? [h("dt", null, "Next check"), h("dd", null, time(st.next_attempt))] : null), + st.last_error ? h("div", { class: "msg err" }, st.last_error) : null, + st.staging && st.state === "ok" ? h("div", { class: "msg warn" }, "This certificate is from Let's Encrypt's staging service, to prove everything works. Browsers warn about it. Set tls.acme.directory: letsencrypt in steward.yaml and restart stewardd for a trusted one.") : null, + h("div", { class: "actions mt" }, testBtn, renewBtn, + h("span", { class: "muted small" }, "Test runs the check without asking Let's Encrypt; a certificate order always runs it first."))), + checkBox); + if (st.check) mount(checkBox, checkView(st.check)); + mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"), + h("div", { class: "muted" }, "The web UI's certificate, obtained from the CA with Jarl publishing the DNS-01 challenge."))), out); + if (busy) { + const t = setTimeout(rerender, 3000); + onLeave(() => clearTimeout(t)); + } +} + +// days says how long until a time: "in 89 days", "in 5 hours". +function days(iso) { + const ms = new Date(iso) - Date.now(); + if (ms <= 0) return "ended"; + const d = Math.floor(ms / 86400000); + return d >= 2 ? "in " + d + " days" : "in " + Math.max(1, Math.round(ms / 3600000)) + " hours"; +} + +function checkView(c) { + const mark = { ok: ["ok", "ok"], fail: ["failed", "err"], warn: ["warning", "warn"], skip: ["skipped", ""] }; + return h("div", { class: "panel" }, + h("h2", null, "Check, ", time(c.time), " ", c.ok ? badge("passed", "ok") : badge("failed", "err")), + table([ + { label: "Step", render: (s) => h("strong", null, s.name) }, + { label: "", render: (s) => badge(...(mark[s.status] || [s.status, ""])) }, + { label: "What happened", render: (s) => [s.detail, + (s.queries || []).length ? h("details", { class: "small", open: s.status === "fail" }, + h("summary", null, "DNS questions (" + s.queries.length + ")"), + h("pre", { class: "wrap" }, s.queries.join("\n"))) : null] }, + ], c.steps)); +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index ccc6f62..7da027d 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -355,3 +355,4 @@ td.mono, span.mono { font-family: var(--mono); font-size: 13px; } input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; } .panel-head h2 { margin: 0; } +pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }