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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0ee2c8844
commit
7b3f0f764c
32 files changed
+2535
-18
No files matched your search
@@ -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
|
result, saves the objects and a history entry in one transaction, then
|
||||||
hands the new state to the DHCP server. Never write subnets,
|
hands the new state to the DHCP server. Never write subnets,
|
||||||
reservations or settings to the store from a handler.
|
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
|
* **Leases are not configuration.** `dhcpd` owns them: kept in memory, and
|
||||||
written to the store (`PutLease`) before the ACK is sent. They are not
|
written to the store (`PutLease`) before the ACK is sent. They are not
|
||||||
in the history.
|
in the history.
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ and a CLI, built the way Jarl is.
|
|||||||
- **`stewardd`** commands: `init` (asks for the administrator's name),
|
- **`stewardd`** commands: `init` (asks for the administrator's name),
|
||||||
`user add|password|token`, `check`, `backup` (with pruning), `restore`,
|
`user add|password|token`, `check`, `backup` (with pruning), `restore`,
|
||||||
`config --example`.
|
`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`,
|
- **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
|
||||||
Rotate in the web UI): a new secret for a token, with an optional grace
|
Rotate in the web UI): a new secret for a token, with an optional grace
|
||||||
period for the old one, as in Jarl.
|
period for the old one, as in Jarl.
|
||||||
|
|||||||
@@ -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.
|
administrator, or `config`, `leases` and `audit` access, read or write.
|
||||||
On the server itself, root needs no setup: the CLI uses stewardd's local
|
On the server itself, root needs no setup: the CLI uses stewardd's local
|
||||||
socket.
|
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
|
* **One small service.** `stewardd` is one static binary, run by a
|
||||||
sandboxed systemd unit whose only privilege is binding port 67. Debian
|
sandboxed systemd unit whose only privilege is binding port 67. Debian
|
||||||
packages and a daily backup timer are included.
|
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
|
* [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
|
* [options.md](docs/options.md): the options Steward knows and how to write their values
|
||||||
* [kea.md](docs/kea.md): moving from Kea
|
* [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
|
* [cli.md](docs/cli.md): the `steward` command line client
|
||||||
* [api.md](docs/api.md): the REST API
|
* [api.md](docs/api.md): the REST API
|
||||||
* [configuration.md](docs/configuration.md): `/etc/steward/steward.yaml`
|
* [configuration.md](docs/configuration.md): `/etc/steward/steward.yaml`
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
|
||||||
add("changes", historyCmd(), importCmd())
|
add("changes", historyCmd(), importCmd())
|
||||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd())
|
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -23,9 +23,11 @@ import (
|
|||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"steward/internal/auth"
|
"steward/internal/auth"
|
||||||
|
"steward/internal/certs"
|
||||||
"steward/internal/config"
|
"steward/internal/config"
|
||||||
"steward/internal/dhcpd"
|
"steward/internal/dhcpd"
|
||||||
"steward/internal/engine"
|
"steward/internal/engine"
|
||||||
|
"steward/internal/jarl"
|
||||||
"steward/internal/model"
|
"steward/internal/model"
|
||||||
"steward/internal/server"
|
"steward/internal/server"
|
||||||
"steward/internal/store"
|
"steward/internal/store"
|
||||||
@@ -153,10 +155,23 @@ func serveCmd() *cobra.Command {
|
|||||||
for _, p := range eng.Problems() {
|
for _, p := range eng.Problems() {
|
||||||
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
|
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{
|
srv := server.New(server.Config{
|
||||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
|
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),
|
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())
|
}, eng, d, host, a.log, web.FS())
|
||||||
go d.Run(ctx)
|
go d.Run(ctx)
|
||||||
hup := make(chan os.Signal, 1)
|
hup := make(chan os.Signal, 1)
|
||||||
|
|||||||
+13
@@ -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 /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 |
|
| `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
|
### Users, tokens, audit
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
@@ -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.<domain>`, 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.
|
||||||
+10
@@ -129,6 +129,16 @@ sent.
|
|||||||
`steward import kea CONFIG [--leases FILE]... [--interface PREFIX=IFACE]... [--replace]`:
|
`steward import kea CONFIG [--leases FILE]... [--interface PREFIX=IFACE]... [--replace]`:
|
||||||
see [kea.md](kea.md).
|
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
|
### users, tokens, audit
|
||||||
|
|
||||||
| Command | |
|
| Command | |
|
||||||
|
|||||||
@@ -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 |
|
| `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.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 |
|
| `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 |
|
| `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 |
|
| `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
|
## HTTPS
|
||||||
|
|
||||||
Over plain HTTP, passwords and tokens cross the network in the clear;
|
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
|
stewardd warns when it listens on a non-loopback address without TLS. Let
|
||||||
it a certificate:
|
it get its own certificate through Jarl ([certificates.md](certificates.md)),
|
||||||
|
or give it one:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
listen: ["10.0.10.2:8067"]
|
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
|
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.
|
grants `CAP_NET_BIND_SERVICE` for DHCP's port 67.
|
||||||
@@ -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
|
runs systemd (`--privileged /sbin/init`) and pointing a client container
|
||||||
at it.
|
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
|
## The web UI
|
||||||
|
|
||||||
Plain JavaScript modules under `internal/web/static/`, built with the
|
Plain JavaScript modules under `internal/web/static/`, built with the
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
| `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) |
|
| `leases` | leases and DHCP activity | delete a lease (making a reservation from one needs `config:write` too) |
|
||||||
| `audit` | the audit log | – |
|
| `audit` | the audit log | – |
|
||||||
|
|
||||||
|
The certificate (`steward cert`, Server → Certificate) is for administrators only.
|
||||||
| `*` | all of the above | |
|
| `*` | all of the above | |
|
||||||
|
|
||||||
**Roles** are ready-made sets of grants:
|
**Roles** are ready-made sets of grants:
|
||||||
|
|||||||
@@ -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_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_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) |
|
||||||
| `reservation_server_address` | warning, host | the server's own address |
|
| `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 |
|
||||||
@@ -3,21 +3,29 @@ module steward
|
|||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
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/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
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/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/libc v1.77.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.1 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
modernc.org/sqlite v1.60.1 // indirect
|
|
||||||
)
|
)
|
||||||
@@ -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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.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 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
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 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
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 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
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 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
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 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
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=
|
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 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
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 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
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=
|
||||||
@@ -255,3 +255,49 @@ type ImportResult struct {
|
|||||||
Notes []string `json:"notes"`
|
Notes []string `json:"notes"`
|
||||||
Skipped []string `json:"skipped"`
|
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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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+) :: (.*)$`)
|
||||||
+147
-2
@@ -6,7 +6,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,12 +33,86 @@ type Config struct {
|
|||||||
Log Log `yaml:"log"`
|
Log Log `yaml:"log"`
|
||||||
Session Session `yaml:"session"`
|
Session Session `yaml:"session"`
|
||||||
Backup Backup `yaml:"backup"`
|
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 {
|
type TLS struct {
|
||||||
CertFile string `yaml:"cert_file"`
|
CertFile string `yaml:"cert_file"`
|
||||||
KeyFile string `yaml:"key_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).
|
// Log configures logging to stderr (the journal under systemd).
|
||||||
@@ -102,6 +178,7 @@ func Default() *Config {
|
|||||||
Log: Log{Level: "info", Format: "text"},
|
Log: Log{Level: "info", Format: "text"},
|
||||||
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
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},
|
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 == "") {
|
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
|
||||||
return fmt.Errorf("tls: set both cert_file and key_file")
|
return fmt.Errorf("tls: set both cert_file and key_file")
|
||||||
}
|
}
|
||||||
|
if err := c.checkACME(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, p := range c.TrustedProxies {
|
for _, p := range c.TrustedProxies {
|
||||||
if _, err := ParsePrefix(p); err != nil {
|
if _, err := ParsePrefix(p); err != nil {
|
||||||
return fmt.Errorf("trusted_proxies: %v", err)
|
return fmt.Errorf("trusted_proxies: %v", err)
|
||||||
@@ -169,6 +249,44 @@ func (c *Config) check() error {
|
|||||||
return nil
|
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.
|
// ParsePrefix parses an address or a prefix.
|
||||||
func ParsePrefix(s string) (netip.Prefix, error) {
|
func ParsePrefix(s string) (netip.Prefix, error) {
|
||||||
if strings.Contains(s, "/") {
|
if strings.Contains(s, "/") {
|
||||||
@@ -204,10 +322,37 @@ const Example = `# Steward daemon configuration (/etc/steward/steward.yaml).
|
|||||||
listen:
|
listen:
|
||||||
- "127.0.0.1:8067"
|
- "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:
|
# tls:
|
||||||
# cert_file: /etc/steward/tls/fullchain.pem
|
# cert_file: /etc/steward/tls/fullchain.pem
|
||||||
# key_file: /etc/steward/tls/privkey.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
|
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
|
||||||
# believed.
|
# believed.
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ func TestErrors(t *testing.T) {
|
|||||||
"tls: {cert_file: /a}": "tls",
|
"tls: {cert_file: /a}": "tls",
|
||||||
"no_such_setting: 1": "no_such_setting",
|
"no_such_setting: 1": "no_such_setting",
|
||||||
"backup: {retention: 3x}": "duration",
|
"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 {
|
for yml, want := range cases {
|
||||||
path := filepath.Join(t.TempDir(), "s.yaml")
|
path := filepath.Join(t.TempDir(), "s.yaml")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -172,6 +172,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
leases := s.dhcp.Leases()
|
leases := s.dhcp.Leases()
|
||||||
out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{},
|
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()}
|
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 {
|
if out.Problems == nil {
|
||||||
out.Problems = []validate.Problem{}
|
out.Problems = []validate.Problem{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ func (s *Server) routes() {
|
|||||||
// Status and audit
|
// Status and audit
|
||||||
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
|
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
|
||||||
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
|
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) {
|
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)
|
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"steward/internal/auth"
|
"steward/internal/auth"
|
||||||
|
"steward/internal/certs"
|
||||||
"steward/internal/dhcpd"
|
"steward/internal/dhcpd"
|
||||||
"steward/internal/engine"
|
"steward/internal/engine"
|
||||||
"steward/internal/store"
|
"steward/internal/store"
|
||||||
@@ -40,6 +41,9 @@ type Config struct {
|
|||||||
Socket string
|
Socket string
|
||||||
// PingProblem says why the ping check can't run (nil: always fine).
|
// PingProblem says why the ping check can't run (nil: always fine).
|
||||||
PingProblem func() string
|
PingProblem func() string
|
||||||
|
// ACME keeps the certificate stewardd obtains itself (tls.acme; nil
|
||||||
|
// when off).
|
||||||
|
ACME *certs.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interfaces lists the host's interfaces.
|
// 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 {
|
if len(s.cfg.Listen) == 0 {
|
||||||
return errors.New("no listen addresses configured")
|
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
|
var tlsCfg *tls.Config
|
||||||
if s.cfg.TLSCertFile != "" {
|
if s.cfg.TLSCertFile != "" {
|
||||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
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
|
return err
|
||||||
}
|
}
|
||||||
s.certs = cr
|
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)
|
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||||
var listeners []net.Listener
|
var listeners []net.Listener
|
||||||
@@ -131,8 +140,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
listeners = append(listeners, ln)
|
listeners = append(listeners, ln)
|
||||||
}
|
}
|
||||||
for i, ln := range listeners {
|
for i, ln := range listeners {
|
||||||
|
handler := s.Handler()
|
||||||
|
if tlsCfg != nil {
|
||||||
|
handler = s.requireHTTPS(handler)
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: s.Handler(),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 2 * time.Minute,
|
ReadTimeout: 2 * time.Minute,
|
||||||
WriteTimeout: 5 * time.Minute,
|
WriteTimeout: 5 * time.Minute,
|
||||||
@@ -145,13 +158,18 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if tlsCfg != nil {
|
if tlsCfg != nil {
|
||||||
|
if s.httpsAvailable() {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
ln = tls.NewListener(ln, tlsCfg)
|
}
|
||||||
} else if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
|
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() {
|
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.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())
|
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||||
go func(srv *http.Server, ln net.Listener) {
|
go func(srv *http.Server, ln net.Listener) {
|
||||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import * as history from "./pages/history.js";
|
|||||||
import * as audit from "./pages/audit.js";
|
import * as audit from "./pages/audit.js";
|
||||||
import * as users from "./pages/users.js";
|
import * as users from "./pages/users.js";
|
||||||
import * as account from "./pages/account.js";
|
import * as account from "./pages/account.js";
|
||||||
|
import * as certificate from "./pages/certificate.js";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||||
@@ -27,6 +28,7 @@ const routes = [
|
|||||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||||
{ pattern: /^users$/, page: users, nav: "users" },
|
{ pattern: /^users$/, page: users, nav: "users" },
|
||||||
{ pattern: /^account$/, page: account, nav: "account" },
|
{ pattern: /^account$/, page: account, nav: "account" },
|
||||||
|
{ pattern: /^certificate$/, page: certificate, nav: "certificate" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// The top bar. ["key", "#/href", "Label", permission?]
|
// The top bar. ["key", "#/href", "Label", permission?]
|
||||||
@@ -39,6 +41,7 @@ const navItems = [
|
|||||||
["history", "#/history", "History", "config"],
|
["history", "#/history", "History", "config"],
|
||||||
{ menu: "Server", items: [
|
{ menu: "Server", items: [
|
||||||
["settings", "#/settings", "Settings and options", "config"],
|
["settings", "#/settings", "Settings and options", "config"],
|
||||||
|
["certificate", "#/certificate", "Certificate", "admin"],
|
||||||
["audit", "#/audit", "Audit log", "audit"],
|
["audit", "#/audit", "Audit log", "audit"],
|
||||||
] },
|
] },
|
||||||
];
|
];
|
||||||
@@ -116,7 +119,7 @@ function footer(b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNav() {
|
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);
|
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||||
mount(navEl, navItems.map((n) => {
|
mount(navEl, navItems.map((n) => {
|
||||||
if (!n.menu) return allowed(n) ? link(n) : null;
|
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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; }
|
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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||||
.panel-head h2 { margin: 0; }
|
.panel-head h2 { margin: 0; }
|
||||||
|
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||||||
Reference in new issue
Block a user