8 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 abb2ee0fd7 Release 0.1.0-rc.1
release / release (push) Successful in 57s
Steward 0.1.0's first release candidate: a DHCPv4 server with subnets per
interface, reservations, a web UI, REST API and CLI, Kea import, the web
UI's certificate through Jarl, clients' last-seen times, the activity log
by subnet, hidden interfaces, and the address order per subnet and pool.
make test and make e2e (121 checks with udhcpc and dhcpcd) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:15:02 +01:00
abrown38andClaude Opus 5.5 8ee430276c Address order: sequential, reverse or random, per subnet and pool
- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:13:25 +01:00
abrown38andClaude Opus 5.5 29fc991ce1 Reservations page refreshes itself every 15 seconds
Online and last-seen times stay current. A newer draw (typing, a change)
wins over a slower older one; no refresh while the tab is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:03:06 +01:00
abrown38andClaude Opus 5.5 5e6c61cc8e Hidden interfaces, last-seen pings, activity by subnet
- interfaces.exclude in steward.yaml (names or shell patterns) leaves
  interfaces out of GET /interfaces, the web UI's picker and
  'steward interface list'. Subnets on them are still served.
- With the ping check on, active leases are pinged every minute; a
  client that answers gets a last-seen time (leases.last_seen,
  migration 4). Not answering never takes a client offline.
- The activity log filters by subnet (--subnet, the Activity page,
  GET /events?subnet=).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:50:21 +01:00
abrown38andClaude Opus 5.5 6ad1bb9a0d Disable reservations; subnet enable/disable buttons; page-head CSS fix
release / release (push) Successful in 5m53s
- A disabled reservation is kept but ignored: the client gets a pool
  address like any other (steward reservation disable|enable, buttons and
  a checkbox in the web UI). Schema migration 3.
- Subnets get Disable/Enable buttons on their page and in the list.
- A rule left without its selector in style.css broke the page heading's
  layout, so the New ... buttons sat cramped under the description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:02:15 +01:00
abrown38andClaude Opus 5.5 7b3f0f764c 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>
2026-10-14 15:46:36 +01:00
abrown38andClaude Opus 5.5 d0ee2c8844 UI: activity client links, online badges, ended-leases toggle, All activity
- Activity: a client's MAC address opens its leases; "Only this" filters
  the list to that client.
- Reservations: Online is a green online / grey offline badge.
- A subnet's leases: "Include ended leases" is a checkbox.
- Dashboard: "All activity" is a button beside the heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:52:36 +01:00
abrown38andClaude Opus 5.5 8a69038ab5 Admin name at init, token rotation, read-only forms, token role presets
- stewardd init asks for the first administrator's name on a terminal.
- Tokens can be rotated (steward token rotate, POST /tokens/{id}/rotate,
  Rotate in the web UI), with a grace period for the old secret, as in
  Jarl. Schema migration 2 adds the token columns.
- Read-only users see the settings and a subnet's settings locked,
  instead of editable fields without a Save button.
- The New token dialog only offers roles within what the user may do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00
79 changed files with 4164 additions and 218 deletions

No files matched your search

+5
View File
@@ -19,6 +19,11 @@ When in doubt, do it the way Jarl does. Go module `steward`,
result, saves the objects and a history entry in one transaction, then
hands the new state to the DHCP server. Never write subnets,
reservations or settings to the store from a handler.
* **The web UI's certificate** (`internal/certs`, with `internal/jarl` for
Jarl's API) is obtained through lego, with Jarl publishing the
DNS-01 challenge. A preflight check runs before every order. Test it
against Jarl's e2e environment (`KEEP=1 PEBBLE=0 scripts/e2e.sh` in
`../jarl`) with Pebble pointed at its named (docs/development.md).
* **Leases are not configuration.** `dhcpd` owns them: kept in memory, and
written to the store (`PutLease`) before the ACK is sent. They are not
in the history.
+47 -2
View File
@@ -80,8 +80,53 @@ and a CLI, built the way Jarl is.
- It lists what it doesn't support instead of dropping it silently.
- Importing again keeps subnets' and reservations' IDs and Steward's
subnet names.
- **`stewardd`** commands: `init`, `user add|password|token`, `check`,
`backup` (with pruning), `restore`, `config --example`.
- **`stewardd`** commands: `init` (asks for the administrator's name),
`user add|password|token`, `check`, `backup` (with pruning), `restore`,
`config --example`.
- **The web UI's certificate from Let's Encrypt, through Jarl**
(docs/certificates.md, docs/jarl.md):
- `tls.acme` in `steward.yaml` names the certificate, and `jarl:` the
Jarl server and a token that may only write the challenge record.
- Steward gets the certificate with the DNS-01 challenge, writing the
record through Jarl's API into every view that has the zone.
- It renews with a third of the lifetime left, and swaps the new
certificate in without a restart.
- A check runs before every order, and on demand: Jarl, the zone, the
token's permission, a test record, the zone's public name servers and
public resolvers, clean-up. Each step says what to fix.
- It starts with Let's Encrypt's staging service by default.
- `steward cert`, `cert check`, `cert renew`; Server → Certificate in the
web UI; `GET /certificate`, `POST /certificate/check|renew`.
- Dashboard problems: `certificate_failed`, `certificate_expiring`,
`certificate_staging`.
- **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is
redirected to HTTPS, except from the server itself (also for
`tls.cert_file`).
- **Disabled reservations** (`steward reservation disable|enable`, Disable
in the web UI): kept but ignored, so the client gets a pool address like
any other. Subnets get Disable/Enable buttons in the web UI too.
- **Hidden interfaces:** `interfaces.exclude` in `steward.yaml` (names or
shell patterns such as `docker*`) leaves interfaces out of the web UI's
interface picker, `steward interface list` and `GET /interfaces`.
- **Last seen:** with the ping check on, active leases' addresses are
pinged every minute, and a client that answers gets a last-seen time
(Leases page, reservations' online badge, `steward lease list|show`,
`steward reservation list|show`, `last_seen` in the API). A client that
blocks pings stays online for as long as its lease lasts, without one.
- **The Reservations page refreshes itself** every 15 seconds, so online
and last seen stay current.
- **Activity by subnet:** `steward activity --subnet`, a subnet filter on
the Activity page, `GET /events?subnet=` (ID, name or prefix).
- **Address order** (docs/subnets.md): which never-used pool address a
new client gets, `sequential` (the lowest, as before), `reverse` or
`random`, in the settings, a subnet (`--address-order`) or a single
pool (`--pool 10.0.10.200-10.0.10.250:random`). A subnet's pools are
used in the order listed, or one at random (`--pool-order random`),
weighted by its never-used addresses. Pools now keep the order they are
given in. Kea's `allocator: random` is imported.
- **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl.
- **Packaging:**
- a sandboxed systemd unit (`steward.service`, also `stewardd.service`)
whose only privilege is `CAP_NET_BIND_SERVICE`;
+11 -1
View File
@@ -47,6 +47,11 @@ network: it greets it, gives it an address, and tells it the way around
administrator, or `config`, `leases` and `audit` access, read or write.
On the server itself, root needs no setup: the CLI uses stewardd's local
socket.
* **HTTPS on its own.** Give it a name and a Jarl token, and Steward gets its
web UI's certificate from Let's Encrypt and renews it, with Jarl
publishing the DNS-01 challenge. It checks the whole path first, so a
broken setup never wastes Let's Encrypt's rate limits
([docs/certificates.md](docs/certificates.md)).
* **One small service.** `stewardd` is one static binary, run by a
sandboxed systemd unit whose only privilege is binding port 67. Debian
packages and a daily backup timer are included.
@@ -89,6 +94,8 @@ sudo systemctl disable --now kea-dhcp4-server
* [subnets.md](docs/subnets.md): subnets, interfaces, pools, reservations, options, leases, and how addresses are chosen
* [options.md](docs/options.md): the options Steward knows and how to write their values
* [kea.md](docs/kea.md): moving from Kea
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
* [jarl.md](docs/jarl.md): connecting Steward to Jarl, and what its token may do
* [cli.md](docs/cli.md): the `steward` command line client
* [api.md](docs/api.md): the REST API
* [configuration.md](docs/configuration.md): `/etc/steward/steward.yaml`
@@ -100,7 +107,10 @@ sudo systemctl disable --now kea-dhcp4-server
## Status
Steward 0.1.0 is in development.
Steward 0.1.0 is in testing (0.1.0-rc.1): a DHCPv4 server with subnets per
interface, reservations, a web UI, REST API and CLI, Kea import, the web
UI's certificate through Jarl, clients' last-seen times and a choice of
address order per subnet and pool.
## Licence
+1 -1
View File
@@ -1 +1 @@
0.1.0-dev
0.1.0-rc.1
+77
View File
@@ -483,6 +483,83 @@ func tokenCmd() *cobra.Command {
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
f.BoolVar(&save, "save", false, "save it as this CLI's token")
cmd.AddCommand(create)
var grace, rexp string
var current, rsave bool
rotate := &cobra.Command{
Use: "rotate [ID]",
Short: "Give a token a new secret (shown once)",
Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only
the programs that use it need the new secret. The old secret stops at once,
or after --grace (up to 7d) so you can update them without an outage. A
token that expires gets its lifetime again, from now, unless --expires says
otherwise (90d, or never).
steward token rotate --current --save # this CLI's own token`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id := ""
switch {
case current && len(args) == 0:
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
if me.TokenID == 0 {
return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method)
}
id = strconv.FormatInt(me.TokenID, 10)
case len(args) == 1 && !current:
id = args[0]
default:
return fmt.Errorf("give the token's ID, or --current")
}
body := api.TokenRotation{Grace: grace}
switch rexp {
case "":
case "never":
body.NoExpiry = true
default:
body.ExpiresIn = rexp
}
var t api.Token
if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil {
return err
}
if rsave {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
if t.PrevExpiresAt != nil {
fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04"))
} else {
fmt.Fprintln(os.Stderr, "the old secret no longer works")
}
}
return nil
},
}
f = rotate.Flags()
f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)")
f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never")
f.BoolVar(&current, "current", false, "rotate the token this CLI uses")
f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token")
cmd.AddCommand(rotate)
cmd.AddCommand(&cobra.Command{
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
+158
View File
@@ -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))
}
}
}
}
+15 -6
View File
@@ -51,13 +51,13 @@ func leaseCmd() *cobra.Command {
ls = keep
}
return output(ls, func(w io.Writer) {
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tINTERFACE\tSUBNET")
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tLAST SEEN\tINTERFACE\tSUBNET")
for _, l := range ls {
st := l.State
if l.Reservation != 0 {
st += ", reserved"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), l.Interface, orDash(l.SubnetName))
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), fmtSeen(l.LastSeen), l.Interface, orDash(l.SubnetName))
}
})
},
@@ -91,6 +91,9 @@ func leaseCmd() *cobra.Command {
fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End))
fmt.Fprintf(w, "Interface:\t%s\n", l.Interface)
if l.LastSeen != nil {
fmt.Fprintf(w, "Last seen:\t%s (%s, answered a ping)\n", l.LastSeen.Local().Format("2006-01-02 15:04:05"), fmtWhen(*l.LastSeen))
}
if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation)
}
@@ -157,7 +160,7 @@ then on. The host name the client sent becomes the reservation's, unless
}
func activityCmd() *cobra.Command {
var mac, iface string
var mac, subnet, iface string
var limit int
var follow bool
cmd := &cobra.Command{
@@ -193,7 +196,7 @@ func activityCmd() *cobra.Command {
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
@@ -212,6 +215,7 @@ func activityCmd() *cobra.Command {
}
f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's")
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix); messages without an address, such as DISCOVERs, show for every subnet on their interface")
f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
@@ -245,10 +249,11 @@ func settingsCmd() *cobra.Command {
auth = "no (such requests get no answer)"
}
fmt.Fprintf(w, "Authoritative:\t%s\n", auth)
fmt.Fprintf(w, "Address order:\t%s (subnets and pools can set their own)\n", s.EffectiveAddressOrder())
})
},
})
var leaseTime, ping, pingTimeout, hold, authoritative string
var leaseTime, ping, pingTimeout, hold, authoritative, addrOrder string
var opts, unsetOpts []string
set := &cobra.Command{
Use: "set",
@@ -312,6 +317,9 @@ reservation's own option of the same name wins.
not := !*a
in.NotAuthoritative = &not
}
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 || len(unsetOpts) > 0 {
var cur model.Settings
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil {
@@ -336,10 +344,11 @@ reservation's own option of the same name wins.
}
f := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them: on or off")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them, and clients every minute for their last-seen time: on or off")
f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)")
f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
f.StringVar(&addrOrder, "address-order", "", "which never-used pool address a new client gets: sequential (the lowest), reverse (the highest) or random")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
cmd.AddCommand(set)
+9 -1
View File
@@ -175,7 +175,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
}
@@ -424,6 +424,14 @@ func fmtWhen(t time.Time) string {
return "in " + s
}
// fmtSeen is a last-seen time, or "-" for a client never seen.
func fmtSeen(t *time.Time) string {
if t == nil {
return "-"
}
return fmtWhen(*t)
}
func orDash(s string) string {
if s == "" {
return "-"
+98 -12
View File
@@ -17,6 +17,20 @@ func subnetPath(ref string) string {
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1))
}
// orderText says how new addresses are chosen: "pools listed, addresses
// the settings' order".
func orderText(s *model.Subnet) string {
pools := "pools in the order listed"
if s.PoolOrder == model.PoolsRandom {
pools = "a pool at random"
}
addrs := "the settings' order"
if s.AddressOrder != "" {
addrs = s.AddressOrder
}
return pools + ", addresses " + addrs + " (unless a pool has its own)"
}
func poolsText(ps []model.Pool) string {
var out []string
for _, p := range ps {
@@ -78,6 +92,7 @@ func subnetCmd() *cobra.Command {
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface)
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default"
@@ -96,7 +111,7 @@ func subnetCmd() *cobra.Command {
},
})
var name, iface, leaseTime, comment, prefix string
var name, iface, leaseTime, comment, prefix, poolOrder, addrOrder string
var pools, opts, addPools, removePools, unsetOpts []string
add := &cobra.Command{
Use: "add PREFIX --interface IFACE",
@@ -108,7 +123,11 @@ get its addresses.
--pool 10.0.10.100-10.0.10.199 \
--option routers=10.0.10.1 --option domain-name-servers=10.0.10.2
Pools are FIRST-LAST or a prefix; give --pool once per pool. Options are
Pools are FIRST-LAST or a prefix; give --pool once per pool. A new client
gets a never-used address: from the pools in the order given (or a pool at
random: --pool-order random), the lowest first (or --address-order reverse
or random). A pool can have its own order: --pool 10.0.10.200-10.0.10.250:random.
Options are
NAME=VALUE ('steward option list' has the names); a number works for any
other option, with its type for ones Steward doesn't know: 224:string=hi.`,
Args: cobra.ExactArgs(1),
@@ -134,6 +153,12 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`,
}
in.LeaseTime = &n
}
if poolOrder != "" {
in.PoolOrder = &poolOrder
}
if addrOrder != "" {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
@@ -151,7 +176,9 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`,
f := add.Flags()
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
f.StringVar(&name, "name", "", "a name for it")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix (repeat for several)")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix, with :ORDER for its own address order (repeat for several)")
f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed (in the order given, the default) or random")
f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse or random (default: the settings')")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
f.StringVar(&comment, "comment", "", "a comment")
@@ -161,11 +188,13 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`,
update := &cobra.Command{
Use: "update SUBNET",
Short: "Change a subnet",
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools;
--add-pool and --remove-pool change one. --option sets an option (keeping
the others), --unset-option removes one.
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools,
in the order given; --add-pool adds one at the end, or changes the order
of a pool it already has; --remove-pool removes one. --option sets an
option (keeping the others), --unset-option removes one.
steward subnet update office --add-pool 10.0.10.200-10.0.10.220
steward subnet update office --address-order reverse --add-pool 10.0.10.200-10.0.10.220:random
steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -191,6 +220,12 @@ the others), --unset-option removes one.
if fl.Changed("comment") {
in.Comment = &comment
}
if fl.Changed("pool-order") {
in.PoolOrder = &poolOrder
}
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if fl.Changed("lease-time") {
n := 0
if leaseTime != "default" {
@@ -216,7 +251,7 @@ the others), --unset-option removes one.
}
found := false
for i, p := range list {
if pp, _ := model.ParsePool(p); pp == rp {
if pp, _ := model.ParsePool(p); pp.SameRange(rp) {
list = append(list[:i], list[i+1:]...)
found = true
break
@@ -226,7 +261,21 @@ the others), --unset-option removes one.
return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools))
}
}
list = append(list, addPools...)
// A pool it has already changes in place (its order).
adding:
for _, a := range addPools {
ap, err := model.ParsePool(a)
if err != nil {
return err
}
for i, p := range list {
if pp, _ := model.ParsePool(p); pp.SameRange(ap) {
list[i] = a
continue adding
}
}
list = append(list, a)
}
in.Pools = &list
}
if len(opts) > 0 || len(unsetOpts) > 0 {
@@ -252,7 +301,9 @@ the others), --unset-option removes one.
f.StringVar(&iface, "interface", "", "a new interface")
f.StringVar(&prefix, "prefix", "", "a new prefix")
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool (FIRST-LAST[:ORDER]), or set the order of one it has")
f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed or random")
f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse, random or default (the settings')")
f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
@@ -330,7 +381,7 @@ func reservationCmd() *cobra.Command {
return err
}
return output(rs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tCOMMENT")
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tSTATE\tCOMMENT")
for _, r := range rs {
who := r.MAC
if r.ClientID != "" {
@@ -339,8 +390,15 @@ func reservationCmd() *cobra.Command {
lease := "-"
if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
if r.Lease.LastSeen != nil {
lease += ", seen " + fmtWhen(*r.Lease.LastSeen)
}
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, r.Comment)
state := "on"
if r.Disabled {
state = "disabled"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, state, r.Comment)
}
})
},
@@ -370,16 +428,44 @@ func reservationCmd() *cobra.Command {
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
if r.Lease != nil {
fmt.Fprintf(w, "Lease:\tuntil %s, last seen on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
fmt.Fprintf(w, "Lease:\tuntil %s, on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
if r.Lease.LastSeen != nil {
fmt.Fprintf(w, "Last seen:\t%s (answered a ping)\n", fmtWhen(*r.Lease.LastSeen))
}
} else {
fmt.Fprintln(w, "Lease:\tnone now")
}
if r.Disabled {
fmt.Fprintln(w, "State:\tdisabled (ignored: the client gets a pool address like any other)")
}
if r.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
}
})
},
})
for _, on := range []bool{true, false} {
on := on
verb, short := "disable", "Ignore a reservation, keeping it (the client gets a pool address like any other)"
if on {
verb, short = "enable", "Use a disabled reservation again"
}
cmd.AddCommand(&cobra.Command{
Use: verb + " RESERVATION", Short: short, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
disabled := !on
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", "/reservations/"+url.PathEscape(args[0]), nil, api.ReservationInput{Disabled: &disabled}, &resp); err != nil {
return err
}
return report(&resp)
},
})
}
var hostname, clientID, comment, ip, mac string
var opts, unsetOpts []string
+45 -2
View File
@@ -23,9 +23,11 @@ import (
"golang.org/x/term"
"steward/internal/auth"
"steward/internal/certs"
"steward/internal/config"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/jarl"
"steward/internal/model"
"steward/internal/server"
"steward/internal/store"
@@ -153,10 +155,24 @@ func serveCmd() *cobra.Command {
for _, p := range eng.Problems() {
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
}
var acme *certs.Manager
if a.cfg.TLS.ACME.Domain != "" {
tok, err := a.cfg.Jarl.ReadToken()
if err != nil {
return err
}
jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure)
if err != nil {
return err
}
acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log)
go acme.Run(ctx)
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem,
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme,
ExcludeInterfaces: a.cfg.Interfaces.Exclude,
}, eng, d, host, a.log, web.FS())
go d.Run(ctx)
hup := make(chan os.Signal, 1)
@@ -221,6 +237,26 @@ func readPassword(prompt string, fromStdin bool) (string, error) {
return string(b), nil
}
// readLine reads one line a byte at a time, so nothing after it is
// buffered away from the password prompt that follows.
func readLine(r io.Reader) string {
var b []byte
one := make([]byte, 1)
for {
n, err := r.Read(one)
if n == 1 {
if one[0] == '\n' {
break
}
b = append(b, one[0])
}
if err != nil {
break
}
}
return strings.TrimRight(string(b), "\r")
}
func localPrincipal(u *model.User) *auth.Principal {
return &auth.Principal{User: u, Method: "local", IP: "local"}
}
@@ -266,6 +302,13 @@ web UI, or import a Kea configuration with 'steward import kea'.`,
if len(users) > 0 {
return fmt.Errorf("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward user add'", len(users))
}
// Asked for when not given, on a terminal.
if !cmd.Flags().Changed("user") && !stdin && term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, "The first administrator's user name [admin]: ")
if name := strings.TrimSpace(readLine(os.Stdin)); name != "" {
username = name
}
}
if err := server.ValidUsername(username); err != nil {
return err
}
@@ -298,7 +341,7 @@ web UI, or import a Kea configuration with 'steward import kea'.`,
return nil
},
}
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account)")
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account); asked for on a terminal")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI")
return cmd
+32 -9
View File
@@ -62,7 +62,7 @@ Creating answers 201, other changes 200.
|---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication |
| `GET /status` | build, start time, `interfaces` (`listening`, `error`, `addresses`, `subnets`), `subnets` with usage, `problems`, `leases` (counts by state), `messages` (counts by type), `ping_problem` |
| `GET /interfaces` | the host's interfaces, their addresses and subnets |
| `GET /interfaces` | the host's interfaces, their addresses and subnets, leaving out those matching `interfaces.exclude` |
| `GET /catalog` | the option catalog, value types, reserved codes, roles |
### Settings
@@ -71,9 +71,13 @@ Creating answers 201, other changes 200.
```json
{"lease_time": 43200, "ping_check": true, "ping_timeout_ms": 500, "decline_hold": 86400,
"not_authoritative": false, "options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]}
"not_authoritative": false, "address_order": "sequential",
"options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]}
```
`address_order` is `sequential` (left out when it is), `reverse` or
`random` ([subnets.md](subnets.md#address-order)).
`options` replaces the list. Each option is `{"option": NAME or NUMBER,
"value": TEXT}`, with `"type"` for numbers the catalog doesn't know
([options.md](options.md)).
@@ -90,30 +94,35 @@ Creating answers 201, other changes 200.
```json
{"name": "office", "interface": "eth0", "prefix": "10.0.10.0/24",
"pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28"], "lease_time": 0,
"pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28:random"], "pool_order": "listed",
"address_order": "default", "lease_time": 0,
"options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""}
```
`pools` and `options` replace the lists; `lease_time` 0 means the
settings'.
`pools` and `options` replace the lists; the pools keep their order. A
pool's `:ORDER` is its own address order. `pool_order` is `listed` or
`random`; `address_order` is `sequential`, `reverse`, `random` or
`default` (the settings'). Subnets come back with pools as
`{"start", "end", "order"}`, and the defaults (`listed`, `default`) left
out. `lease_time` 0 means the settings'.
### Reservations
| | |
|---|---|
| `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` |
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "comment"}`; `ip` and `mac` or `client_id` needed |
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed |
| `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) |
### Leases and activity
| | |
|---|---|
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation; `q` searches address, MAC and host name |
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name |
| `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` |
| `GET /events[?mac=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory) |
| `GET /events[?mac=&subnet=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory). `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface |
### History and import
@@ -124,14 +133,28 @@ settings'.
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
| `POST /import/kea` | `{"config": TEXT, "leases": [TEXT, ...], "interfaces": {"PREFIX": "IFACE"}, "replace": false}` ([kea.md](kea.md)); answers with `leases`, `notes`, `skipped` too |
### Certificate
Administrators only ([certificates.md](certificates.md)).
| | |
|---|---|
| `GET /certificate` | `{"configured", "source": "acme"\|"file"\|"none", "status": {"domain", "directory", "staging", "state": "none"\|"ok"\|"working"\|"error", "not_before", "not_after", "issuer", "renew_at", "last_error", "last_attempt", "next_attempt", "check"}}` |
| `POST /certificate/check` | run the check now (up to a few minutes); answers `{"time", "domain", "ok", "steps": [{"name", "status", "detail", "queries"}]}` |
| `POST /certificate/renew` | get a new certificate now, in the background (202); follow it with `GET /certificate` |
`GET /status` lists the certificate's problems (`certificate_failed`,
`certificate_expiring`, `certificate_staging`) for administrators.
### Users, tokens, audit
| | |
|---|---|
| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | |
| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | `whoami` has `token_id` when the caller uses a token |
| `POST /me/password` | `{"current", "password"}` |
| `GET /me/tokens`, `POST /me/tokens` | `{"name", "role", "grants", "expires_in"}`; the answer's `secret` is shown once |
| `GET /tokens`, `DELETE /tokens/{id}` | all tokens (administrators), revoke |
| `POST /tokens/{id}/rotate` | a new secret: `{"grace": "1h", "expires_in": "90d", "no_expiry": false}`, all optional; answers with `secret` and `previous_secret_valid_until` |
| `GET`, `POST /users`; `PATCH`, `DELETE /users/{id}` | administrators; `{"username", "password", "admin", "role", "grants", "disabled", "comment"}` |
| `GET`, `POST /users/{id}/tokens` | |
| `GET /audit[?user=&action=&result=&q=&before=&limit=]` | |
+120
View File
@@ -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.
+23 -9
View File
@@ -62,8 +62,8 @@ since the start, and problems.
|---|---|
| `subnet list` | subnets, with interface, pools, use, reservations, lease time |
| `subnet show SUBNET` | one subnet; SUBNET is its ID, name or prefix |
| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B]... [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one |
| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given |
| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B[:ORDER]]... [--pool-order listed\|random] [--address-order sequential\|reverse\|random] [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one |
| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--pool-order] [--address-order ...\|default] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given; `--add-pool` of a pool it has changes that pool's order ([subnets.md](subnets.md#address-order)) |
| `subnet enable\|disable SUBNET` | answer its clients, or not |
| `subnet delete SUBNET [-y]` | delete it and its reservations |
@@ -75,10 +75,11 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
| Command | |
|---|---|
| `reservation list [--subnet SUBNET]` | reservations, and whether each client is online |
| `reservation list [--subnet SUBNET]` | reservations, whether each client is online, and when it last answered a ping |
| `reservation show RES` | RES is its ID, address or MAC address |
| `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` |
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | |
| `reservation enable\|disable RES` | use a reservation, or keep it but ignore it |
| `reservation delete RES` | |
### lease
@@ -92,9 +93,11 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
### activity
`steward activity [--mac MAC] [--interface I] [--limit N] [-f]`: the latest
DHCP messages, received and sent, oldest first. `-f` keeps watching, which
is useful while a device tries to get an address:
`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`:
the latest DHCP messages, received and sent, oldest first. `--subnet`
takes an ID, name or prefix; a message without an address (a DISCOVER,
"no address") shows for every subnet on its interface. `-f` keeps
watching, which is useful while a device tries to get an address:
```
11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
@@ -112,9 +115,9 @@ sent.
| Command | |
|---|---|
| `settings show` | |
| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | |
| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--address-order sequential\|reverse\|random] [--option NAME=VALUE]... [--unset-option NAME]...` | |
| `option list` | the options Steward knows ([options.md](options.md)) |
| `interface list` | the server's interfaces, their addresses and subnets |
| `interface list` | the server's interfaces, their addresses and subnets (not those in `interfaces.exclude`) |
### history
@@ -129,6 +132,16 @@ sent.
`steward import kea CONFIG [--leases FILE]... [--interface PREFIX=IFACE]... [--replace]`:
see [kea.md](kea.md).
### cert
Administrators only ([certificates.md](certificates.md)).
| Command | |
|---|---|
| `cert` | the certificate: name, CA, validity, renewal, the last check |
| `cert check` | check that the challenge can be published through Jarl and seen, without the CA (fails with exit code 1 if it can't) |
| `cert renew [-w]` | get a new certificate now; `-w` waits and shows the result |
### users, tokens, audit
| Command | |
@@ -136,6 +149,7 @@ see [kea.md](kea.md).
| `whoami` | who you are to the server, and what you may do |
| `user list\|add\|update\|delete` | users (administrators); `user add NAME --role operator` |
| `token list [--all]`, `token create NAME [--role R] [--grant G]... [--expires 90d] [--user U] [--save]`, `token revoke ID` | API tokens |
| `token rotate ID\|--current [--grace 1h] [--expires 90d\|never] [--save]` | a new secret for a token, the old one working for the grace period |
| `password` | change your password |
| `audit [--user U] [--action PREFIX] [--search TEXT]` | the audit log |
| `config show`, `config set server\|token\|insecure\|ca_cert [VALUE]` | this CLI's settings |
@@ -150,7 +164,7 @@ The daemon's own commands work on the database directly, as root or the
| Command | |
|---|---|
| `stewardd serve` | run (the systemd unit does) |
| `stewardd init [-u NAME] [--token]` | create the database and the first administrator |
| `stewardd init [-u NAME] [--token]` | create the database and the first administrator (asks for the name on a terminal) |
| `stewardd user add NAME [--admin]`, `user password NAME`, `user token NAME TOKEN-NAME` | when the API can't be used |
| `stewardd check` | check the configuration against this machine's interfaces |
| `stewardd backup [FILE]`, `stewardd restore FILE` | [operations.md](operations.md) |
+9 -3
View File
@@ -12,6 +12,9 @@ the file when it starts.
|---|---|---|
| `listen` | `["127.0.0.1:8067"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network |
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; stewardd rereads it when the file changes, or on `systemctl reload steward` |
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server Steward uses for DNS, and its token ([jarl.md](jarl.md)) |
| `interfaces.exclude` | `[]` | interfaces left out of the web UI's interface picker, `steward interface list` and `GET /interfaces`: names or shell patterns, such as `["lo", "docker*", "veth*"]`. A subnet can still name one, and is still served |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log |
@@ -29,8 +32,9 @@ Durations are written `15s`, `12h` or `30d`.
## HTTPS
Over plain HTTP, passwords and tokens cross the network in the clear;
stewardd warns when it listens on a non-loopback address without TLS. Give
it a certificate:
stewardd warns when it listens on a non-loopback address without TLS. Let
it get its own certificate through Jarl ([certificates.md](certificates.md)),
or give it one:
```yaml
listen: ["10.0.10.2:8067"]
@@ -40,5 +44,7 @@ tls:
```
Or keep it on `127.0.0.1` behind a reverse proxy that terminates TLS, and
list the proxy in `trusted_proxies`. Ports below 1024 work too: the unit
list the proxy in `trusted_proxies`. With a certificate, the same port
answers HTTPS and redirects plain HTTP to it, except from the server
itself. Ports below 1024 work too: the unit
grants `CAP_NET_BIND_SERVICE` for DHCP's port 67.
+3 -1
View File
@@ -25,7 +25,9 @@ check or reload.
* **Leases** in SQLite, written before the client is answered, so a crash
or restart never hands out an address twice.
* **Conflict checks**: an address is pinged before it is offered; one that
answers, or that a client declines, is held back for a while.
answers, or that a client declines, is held back for a while. Active
leases are pinged every minute too, for a "last seen" time that never
takes a client offline ([subnets.md](subnets.md#online-and-last-seen)).
* **The Jarl way of working**: an API used by both the CLI and the web UI;
users, sessions and API tokens; every configuration change validated as a
whole before it is saved and used; a history of changes that can be
+28
View File
@@ -55,6 +55,7 @@ It checks:
* addresses and options per interface, and reservations;
* the ping check, against a container that squats on a pool address;
* last seen: a client that stays up answers pings, one that left has none;
* init-reboot, renewal and release with dhcpcd;
* a client moved to another VLAN: NAK, then that VLAN's address;
* a full pool and a disabled subnet;
@@ -71,6 +72,33 @@ sandbox is only tested by installing the package in a container that
runs systemd (`--privileged /sbin/init`) and pointing a client container
at it.
## Certificates
`internal/certs` has unit tests with a fake Jarl and an in-process DNS
server, covering every failure the check reports. To test a real order:
1. Run Jarl's e2e and keep its jarld and named:
`KEEP=1 PEBBLE=0 JARL_BIND_ROOT=... WORK=DIR scripts/e2e.sh` in `../jarl`.
jarld is then on `127.0.0.1:28053` and named on `127.0.0.1:25353`, with
the zone `adbraun.test` in two views.
2. Make the restricted user and token there, as docs/jarl.md says, with
`DIR/jarl --config DIR/cli.yaml`.
3. Start Pebble against that named:
`docker run -d --network host ghcr.io/letsencrypt/pebble -config test/config/pebble-config.json -dnsserver 127.0.0.1:25353`,
and copy its `/test/certs/pebble.minica.pem` out.
4. Point a scratch stewardd at both:
```yaml
tls:
acme: {domain: dhcp.adbraun.test, email: t@adbraun.test, agree_tos: true,
directory: https://localhost:14000/dir, ca_bundle: pebble.minica.pem,
resolvers: ["127.0.0.1:25353"], check_propagation: false}
jarl: {server: http://127.0.0.1:28053, token_file: jarl.token}
```
`check_propagation` has to be off here: the test named isn't on port 53.
The unit tests cover that step.
## The web UI
Plain JavaScript modules under `internal/web/static/`, built with the
+4
View File
@@ -57,6 +57,10 @@ sudo chgrp steward /etc/steward /etc/steward/steward.yaml && sudo systemctl daem
```sh
sudo -u steward stewardd init
```
It asks for the administrator's name (`admin` if you just press Enter;
your own name is better, since each person should have an account) and
password. `-u NAME` gives the name without asking.
3. **Stop any other DHCP server** on the interfaces Steward will serve
(Kea, isc-dhcp-server, dnsmasq): only one program can use an
interface's port 67. Moving from Kea: [kea.md](kea.md).
+85
View File
@@ -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.
+1
View File
@@ -51,6 +51,7 @@ pings before it offers an address, and clients renew or rebind.
| `shared-networks` | their subnets, all on the shared network's interface |
| `reservations` with `hw-address` or `client-id` and `ip-address`, `hostname` | reservations |
| `decline-probation-period` | the decline hold |
| `allocator` (global, shared network, subnet) | `random`: the subnet's pool and address order random. `iterative` and `flq` become the default order, the lowest never-used address (with a note for `flq`) |
| `user-context.comment` or `comment` of a subnet | the subnet's comment |
| the lease file: active and declined leases, and released ones | leases (the last line of each address wins) |
+21 -2
View File
@@ -7,9 +7,11 @@ else gets **grants**, `resource:access`:
| Resource | read | write |
|---|---|---|
| `config` | settings, subnets, reservations, history, interfaces | 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) |
| `audit` | the audit log | – |
The certificate (`steward cert`, Server → Certificate) is for administrators only.
| `*` | all of the above | |
**Roles** are ready-made sets of grants:
@@ -38,7 +40,24 @@ steward token create backup-script --role leases --expires 365d
```
The secret is shown once; only its hash is stored. A restricted token can
only create tokens with a subset of its own grants. `steward token list`
only create tokens with a subset of its own grants. The web UI only offers
roles within what your account may do; a token with more would get no
more than its user anyway.
**Rotating** gives a token a new secret, keeping its ID, name and
permissions, so only the programs that use it need updating:
```sh
steward token rotate 7 --grace 1h # the old secret works for another hour
steward token rotate --current --save # this CLI's own token
```
A token that expires gets its lifetime again, from now (`--expires 90d`
or `never` to change it). Without `--grace` the old secret stops at once:
use that when it may have leaked. Using an old secret during its grace
period is logged, naming the token and the address, so you can find the
programs still to update. A restricted token may rotate itself and tokens
no wider than itself. `steward token list`
shows when each token was last used, and from where. `steward token revoke
ID` stops one at once.
+61 -4
View File
@@ -20,7 +20,9 @@ For VLANs, give the server an interface on each, such as `eth0.20`, or a
bridge such as `br-iot`, each with an address in its subnet. That address
is the server identifier clients see, and the one they renew their lease
with. `steward interface list` shows the server's interfaces with their
addresses and subnets.
addresses and subnets. To keep interfaces such as `lo`, `docker0` or
`veth*` out of that list and the web UI's picker, name them in
`interfaces.exclude` in steward.yaml ([configuration.md](configuration.md)).
An interface that doesn't exist yet is fine. The subnet gets a warning
(`interface_unknown`), and stewardd starts serving it within 30 seconds of
@@ -34,7 +36,8 @@ answered from the interface's address in their own subnet, or its first
address.
A subnet can be **disabled**: its configuration and leases stay, but its
clients get no answers (`steward subnet disable NAME`).
clients get no answers (`steward subnet disable NAME`, or Disable in the
web UI).
## Pools
@@ -67,6 +70,14 @@ steward lease reserve 10.0.10.57 # the address a client has now
* **A device on two VLANs** can have a reservation in each subnet. Within
one subnet, a client has at most one.
A reservation can be **disabled** (`steward reservation disable RES`, or
Disable in the web UI). It is kept but ignored: the client gets a pool
address like any other, and the reserved address, if it is inside a pool,
may go to someone else. The client keeps the address it has until its
lease runs out. While it is disabled, another reservation may use its
address or client; enabling it again is then refused until one of them
moves.
A client with a reservation always gets that address, not a pool address.
If it asks for another one, for example after the reservation was added,
it gets a NAK that names its reserved address, and then the reservation.
@@ -110,8 +121,9 @@ For a DISCOVER, Steward offers, in this order:
ended, as long as it is still free and in a pool;
3. the address the client asks for (option 50), if it is free and in a
pool;
4. a free pool address: one never handed out before, else the one that has
been free the longest.
4. a free pool address: one never handed out before, in the subnet's
[address order](#address-order), else the one that has been free the
longest.
An address is free when no other client has an active lease on it, it
isn't held back, and it isn't offered to someone else in the last minute.
@@ -122,6 +134,40 @@ tried.
When the client confirms with a REQUEST, the lease is saved, and only
then is the ACK sent.
## Address order
Which never-used address a new client gets is set at three levels, each
overriding the one above:
| Level | Setting | Values |
|---|---|---|
| settings | `steward settings set --address-order` | `sequential` (the lowest free address, the default), `reverse` (the highest), `random` |
| subnet | `--address-order` | the same, or `default` (the settings') |
| pool | `:ORDER` after the pool: `--pool 10.0.10.200-10.0.10.250:random` | the same, or `default` (the subnet's) |
A subnet with several pools also has a **pool order**, `--pool-order`:
* `listed` (the default): the pools are used in the order they are
listed; the next one only when the one before has no never-used address
left. `--pool` gives the order; in the web UI, ↑ moves a pool up.
* `random`: a pool at random, weighted by how many never-used addresses
each has, so addresses spread over all of them. With `random` addresses
too, every never-used address in the subnet is as likely as any other.
```
steward subnet update office --pool-order random --address-order reverse
steward subnet update office --add-pool 10.0.10.200-10.0.10.250:random # one pool's own order
steward subnet update office --add-pool 10.0.10.200-10.0.10.250:default # back to the subnet's
```
The order only picks among addresses **never handed out** (or whose lease
was deleted, or ended over 30 days ago). Once every free address has been
used before, the one free the longest is given, whatever the order: an
address that just came free isn't handed to the next device, so stale ARP
caches and DNS records don't point at the wrong one. Returning clients
keep their address (steps 2 and 3), so changing the order doesn't move
anyone.
## Leases
| State | Meaning |
@@ -134,6 +180,17 @@ then is the ACK sent.
`steward lease list` shows active and held-back leases; `--all` shows
the others too. Leases that ended more than 30 days ago are deleted.
### Online and last seen
A client is **online** while its lease is active. With the ping check on
(the default), Steward also pings every active lease's address once a
minute, and a client that answers gets a **last seen** time: on the
Leases page, next to a reservation's online badge, and in `steward lease
list`. Many devices block pings (Windows' firewall does by default), so a
client that doesn't answer stays online and simply has no last-seen time,
or keeps the one it had. The time stays with the lease when it is
renewed, and is kept across restarts.
`steward lease delete IP` frees an address at once. A client still using
it gets a NAK when it next renews, then asks for a new address.
+13
View File
@@ -17,6 +17,7 @@ interfaces. They come from stewardd, not from a configuration on its own.
| `lease_time_range` | error | a lease time (settings or subnet) under 1 minute or over 365 days |
| `ping_timeout_range` | error | the ping timeout isn't 50 to 5000 ms |
| `decline_hold_range` | error | the decline hold isn't 1 minute to 30 days |
| `address_order` | error | an address order (settings, subnet or pool) that isn't `sequential`, `reverse` or `random` |
## Options
@@ -45,6 +46,7 @@ These apply to options anywhere (settings, subnets, reservations).
| `pool_outside_subnet` | error | a pool not inside the subnet |
| `pool_network_address` | error | a pool with the network or broadcast address |
| `pool_overlap` | error | two pools overlap |
| `pool_order` | error | a pool order that isn't `listed` or `random` |
| `router_outside_subnet` | warning | a `routers` address outside the subnet: clients can't reach it |
| `router_in_pool` | warning | a `routers` address inside a pool. The ping check keeps it from being handed out while it answers, but take it out of the pool |
| `interface_unknown` | warning, host | the interface doesn't exist on this machine. It is served once it appears |
@@ -63,3 +65,14 @@ These apply to options anywhere (settings, subnets, reservations).
| `reservation_client_taken` | error | the client has a reservation in this subnet already (one per subnet; a device on two VLANs may have one in each) |
| `reservation_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) |
| `reservation_server_address` | warning, host | the server's own address |
## Certificate
Not configuration problems, but shown with them on the dashboard and in
`steward status`, to administrators ([certificates.md](certificates.md)):
| Code | | When |
|---|---|---|
| `certificate_failed` | warning | the last attempt to get the web UI's certificate failed (the message says where) |
| `certificate_expiring` | warning | the certificate ends in less than 14 days and hasn't been renewed |
| `certificate_staging` | warning | the certificate is from Let's Encrypt's staging service, which browsers don't trust |
+14 -6
View File
@@ -3,21 +3,29 @@ module steward
go 1.27.1
require (
github.com/go-acme/lego/v4 v4.35.2
github.com/miekg/dns v1.1.73
github.com/spf13/cobra v1.10.2
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/term v0.46.0
modernc.org/sqlite v1.60.1
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
+44
View File
@@ -1,12 +1,26 @@
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -16,23 +30,53 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+79 -15
View File
@@ -30,12 +30,14 @@ type ErrorInfo struct {
// Whoami describes the caller.
type Whoami struct {
User *model.User `json:"user"`
Admin bool `json:"admin"`
Method string `json:"method"`
Permissions []string `json:"permissions"`
CSRFToken string `json:"csrf_token,omitempty"`
Build version.Build `json:"build"`
User *model.User `json:"user"`
Admin bool `json:"admin"`
Method string `json:"method"`
Permissions []string `json:"permissions"`
// TokenID is the token the caller authenticated with (0: none).
TokenID int64 `json:"token_id,omitempty"`
CSRFToken string `json:"csrf_token,omitempty"`
Build version.Build `json:"build"`
}
// Login is the login request.
@@ -66,19 +68,25 @@ type SettingsInput struct {
PingTimeout *int `json:"ping_timeout_ms,omitempty"`
DeclineHold *int `json:"decline_hold,omitempty"`
NotAuthoritative *bool `json:"not_authoritative,omitempty"`
// AddressOrder is sequential, reverse or random.
AddressOrder *string `json:"address_order,omitempty"`
}
// SubnetInput creates or changes a subnet (unset fields stay). Pools are
// "FIRST-LAST" or prefixes.
// "FIRST-LAST" or prefixes, with ":ORDER" for a pool's own address order.
type SubnetInput struct {
Name *string `json:"name,omitempty"`
Interface *string `json:"interface,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Pools *[]string `json:"pools,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
Name *string `json:"name,omitempty"`
Interface *string `json:"interface,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Pools *[]string `json:"pools,omitempty"`
// PoolOrder is listed or random; AddressOrder sequential, reverse,
// random or "" (the settings').
PoolOrder *string `json:"pool_order,omitempty"`
AddressOrder *string `json:"address_order,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ReservationInput creates or changes a reservation (unset fields stay).
@@ -88,6 +96,7 @@ type ReservationInput struct {
IP *string `json:"ip,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
@@ -184,6 +193,15 @@ type TokenInput struct {
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// TokenRotation gives a token a new secret. Grace keeps the old secret
// working for a while (up to 7d). The token gets its lifetime again,
// counted from now, unless ExpiresIn or NoExpiry says otherwise.
type TokenRotation struct {
Grace string `json:"grace,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"`
NoExpiry bool `json:"no_expiry,omitempty"`
}
// Token is a token; Secret is only set when it is created.
type Token struct {
*model.Token
@@ -244,3 +262,49 @@ type ImportResult struct {
Notes []string `json:"notes"`
Skipped []string `json:"skipped"`
}
// CertStep is one step of the preflight check.
type CertStep struct {
Name string `json:"name"`
Status string `json:"status"` // ok, fail, warn, skip
Detail string `json:"detail"`
// Queries are the DNS questions asked, as dig commands.
Queries []string `json:"queries,omitempty"`
}
// CertCheck is the result of a preflight check: can Steward publish its
// challenge through Jarl, and does the internet see it?
type CertCheck struct {
Time time.Time `json:"time"`
Domain string `json:"domain"`
OK bool `json:"ok"`
Steps []CertStep `json:"steps"`
}
// CertStatus describes the certificate, for the API and the dashboard.
type CertStatus struct {
Domain string `json:"domain"`
Directory string `json:"directory"`
Staging bool `json:"staging"`
State string `json:"state"` // none, ok, working, error
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
Issuer string `json:"issuer,omitempty"`
// IssuedBy is the directory the current certificate came from (it may
// differ from Directory until the next renewal).
IssuedBy string `json:"issued_by,omitempty"`
RenewAt *time.Time `json:"renew_at,omitempty"`
LastError string `json:"last_error,omitempty"`
LastAttempt *time.Time `json:"last_attempt,omitempty"`
NextAttempt *time.Time `json:"next_attempt,omitempty"`
Check *CertCheck `json:"check,omitempty"`
}
// CertificateResponse is GET /certificate.
type CertificateResponse struct {
// Configured is false when tls.acme is off.
Configured bool `json:"configured"`
// Source is acme, file or none.
Source string `json:"source"`
Status *CertStatus `json:"status,omitempty"`
}
+6
View File
@@ -108,6 +108,12 @@ func NewToken() (token, publicID, secretHash string) {
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
}
// RotateToken makes a new secret for a token's public ID.
func RotateToken(publicID string) (token, secretHash string) {
secret := RandomString(20)
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
}
// ParseToken splits a token into its public ID and secret.
func ParseToken(token string) (publicID, secret string, err error) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
+379
View File
@@ -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
}
+266
View File
@@ -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)
}
}
+484
View File
@@ -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 = &registration.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+) :: (.*)$`)
+171 -2
View File
@@ -6,8 +6,11 @@ package config
import (
"bytes"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -31,12 +34,97 @@ type Config struct {
Log Log `yaml:"log"`
Session Session `yaml:"session"`
Backup Backup `yaml:"backup"`
// Jarl is the Jarl server Steward uses for DNS (docs/jarl.md).
Jarl Jarl `yaml:"jarl"`
// Interfaces says which of the host's interfaces are offered when
// choosing a subnet's interface.
Interfaces Interfaces `yaml:"interfaces"`
}
// TLS is a certificate for HTTPS.
// Interfaces filters the host's interfaces listed by the API, the web UI's
// interface picker and 'steward interface list'.
type Interfaces struct {
// Exclude are interface names or shell patterns ("docker*", "veth*")
// left out of the list. It doesn't stop a subnet from naming one.
Exclude []string `yaml:"exclude"`
}
// TLS is the web UI's certificate: from files, or obtained through ACME
// with Jarl publishing the DNS-01 challenge (docs/certificates.md).
type TLS struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
ACME ACME `yaml:"acme"`
}
// ACME obtains the web UI's certificate (docs/certificates.md).
type ACME struct {
// Domain is the certificate's name ("" turns ACME off).
Domain string `yaml:"domain"`
Email string `yaml:"email"`
// Directory is letsencrypt, letsencrypt-staging (the default, for a
// first certificate browsers don't trust) or an ACME directory URL.
Directory string `yaml:"directory"`
// AgreeTOS says you accept the CA's terms of service; no account is
// registered without it.
AgreeTOS bool `yaml:"agree_tos"`
// Resolvers are public recursive resolvers, to see the challenge as
// the CA would.
Resolvers []string `yaml:"resolvers"`
// CheckPropagation also asks each of the zone's public name servers
// directly before the CA is told to look.
CheckPropagation bool `yaml:"check_propagation"`
// CABundle is a PEM file of extra CAs to trust for the ACME server
// (a private CA such as step-ca, or Pebble in tests).
CABundle string `yaml:"ca_bundle"`
}
// Directory URLs for the names tls.acme.directory accepts.
const (
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
// DirectoryURL is the ACME directory's URL.
func (a ACME) DirectoryURL() string {
switch a.Directory {
case "", "letsencrypt-staging":
return LetsEncryptStaging
case "letsencrypt":
return LetsEncrypt
}
return a.Directory
}
// Staging reports whether the directory is Let's Encrypt's staging one.
func (a ACME) Staging() bool { return a.DirectoryURL() == LetsEncryptStaging }
// Jarl is a Jarl server and an API token for it.
type Jarl struct {
// Server is Jarl's URL ("" when Steward doesn't use Jarl).
Server string `yaml:"server"`
// Token, or TokenFile holding it (better: the file can be readable by
// the steward user alone).
Token string `yaml:"token"`
TokenFile string `yaml:"token_file"`
// CACert is a PEM file with a CA to trust for Jarl's certificate.
CACert string `yaml:"ca_cert"`
Insecure bool `yaml:"insecure"`
}
// Enabled reports whether a Jarl server is configured.
func (j Jarl) Enabled() bool { return j.Server != "" }
// ReadToken returns the token, from the setting or the file.
func (j Jarl) ReadToken() (string, error) {
if j.TokenFile == "" {
return j.Token, nil
}
b, err := os.ReadFile(j.TokenFile)
if err != nil {
return "", fmt.Errorf("jarl.token_file: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
// Log configures logging to stderr (the journal under systemd).
@@ -97,11 +185,13 @@ func Default() *Config {
return &Config{
Listen: []string{"127.0.0.1:8067"},
TrustedProxies: []string{},
Interfaces: Interfaces{Exclude: []string{}},
Socket: "/run/steward/steward.sock",
Database: "/var/lib/steward/steward.db",
Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
Backup: Backup{Dir: "/var/backups/steward", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
}
}
@@ -140,11 +230,19 @@ func (c *Config) check() error {
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return fmt.Errorf("tls: set both cert_file and key_file")
}
if err := c.checkACME(); err != nil {
return err
}
for _, p := range c.TrustedProxies {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("trusted_proxies: %v", err)
}
}
for _, p := range c.Interfaces.Exclude {
if _, err := path.Match(p, ""); err != nil || p == "" {
return fmt.Errorf("interfaces.exclude: %q is not an interface name or pattern (e.g. lo, docker*, veth*)", p)
}
}
if c.Database == "" || !filepath.IsAbs(c.Database) {
return fmt.Errorf("database must be an absolute path")
}
@@ -169,6 +267,44 @@ func (c *Config) check() error {
return nil
}
func (c *Config) checkACME() error {
j := c.Jarl
if j.Server != "" {
if u, err := url.Parse(j.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("jarl.server %q: give Jarl's URL, e.g. https://dns.example.com:8053", j.Server)
}
if j.Token == "" && j.TokenFile == "" {
return fmt.Errorf("jarl: set token or token_file (a Jarl API token, docs/jarl.md)")
}
if j.Token != "" && j.TokenFile != "" {
return fmt.Errorf("jarl: set token or token_file, not both")
}
}
a := c.TLS.ACME
if a.Domain == "" {
return nil
}
if c.TLS.CertFile != "" {
return fmt.Errorf("tls: cert_file and acme can't both be set; choose one")
}
if !j.Enabled() {
return fmt.Errorf("tls.acme needs Jarl to publish the challenge: set jarl.server and its token (docs/certificates.md)")
}
d := strings.TrimSuffix(strings.ToLower(a.Domain), ".")
if strings.HasPrefix(d, "*.") || strings.Count(d, ".") < 1 || strings.ContainsAny(d, " /:") {
return fmt.Errorf("tls.acme.domain %q: give the full name the web UI is reached by, e.g. dhcp.example.com", a.Domain)
}
if u := a.DirectoryURL(); !strings.HasPrefix(u, "https://") {
return fmt.Errorf("tls.acme.directory %q: use letsencrypt, letsencrypt-staging or an https:// ACME directory URL", a.Directory)
}
for _, r := range a.Resolvers {
if _, _, err := net.SplitHostPort(r); err != nil {
return fmt.Errorf("tls.acme.resolvers: %q must be HOST:PORT, e.g. 1.1.1.1:53", r)
}
}
return nil
}
// ParsePrefix parses an address or a prefix.
func ParsePrefix(s string) (netip.Prefix, error) {
if strings.Contains(s, "/") {
@@ -204,10 +340,43 @@ const Example = `# Steward daemon configuration (/etc/steward/steward.yaml).
listen:
- "127.0.0.1:8067"
# HTTPS from a certificate you renew yourself (reread when it changes).
# HTTPS for the web UI and API: a certificate from files you renew yourself
# (reread when they change)...
# tls:
# cert_file: /etc/steward/tls/fullchain.pem
# key_file: /etc/steward/tls/privkey.pem
#
# ...or one Steward obtains and renews itself from Let's Encrypt, with Jarl
# publishing the DNS-01 challenge (needs jarl: below; docs/certificates.md).
# Start with the staging directory: its certificate isn't trusted by
# browsers, but it proves everything works without Let's Encrypt's rate
# limits. Then set directory: letsencrypt.
tls:
acme:
domain: "" # e.g. dhcp.example.com; "" is off
email: ""
directory: letsencrypt-staging # letsencrypt, or an ACME directory URL
agree_tos: false # you accept the CA's terms of service
# Public resolvers, to see the challenge as the CA does.
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
# Also ask the zone's public name servers directly.
check_propagation: true
ca_bundle: "" # extra CA for a private ACME server
# Jarl, the BIND manager, for DNS records (docs/jarl.md). Give Steward a
# token that may only write its challenge record; keep it in a file only
# the steward user can read.
# jarl:
# server: https://dns.example.com:8053
# token_file: /etc/steward/jarl.token
# ca_cert: "" # a CA to trust for Jarl's certificate
# insecure: false
# Interfaces left out when choosing a subnet's interface (the web UI's
# picker, 'steward interface list'): names or shell patterns. Subnets
# already on one are still served.
interfaces:
exclude: [] # e.g. ["lo", "docker*", "veth*", "br-*"]
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
# believed.
+13 -6
View File
@@ -22,12 +22,19 @@ func TestExampleIsTheDefaults(t *testing.T) {
func TestErrors(t *testing.T) {
cases := map[string]string{
"listen: []": "listen",
"database: relative.db": "database",
"session: {cookie_secure: x}": "cookie_secure",
"tls: {cert_file: /a}": "tls",
"no_such_setting: 1": "no_such_setting",
"backup: {retention: 3x}": "duration",
"listen: []": "listen",
"database: relative.db": "database",
"session: {cookie_secure: x}": "cookie_secure",
"tls: {cert_file: /a}": "tls",
"no_such_setting: 1": "no_such_setting",
"backup: {retention: 3x}": "duration",
"tls: {acme: {domain: dhcp.example.com}}": "needs Jarl",
"jarl: {server: https://dns.example.com}": "token or token_file",
"jarl: {server: dns.example.com, token: x}": "Jarl's URL",
"{tls: {cert_file: /a, key_file: /b, acme: {domain: d.example.com}}, jarl: {server: https://j, token: x}}": "can't both",
"{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name",
"{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://",
"interfaces: {exclude: ['eth[']}": "interfaces.exclude",
}
for yml, want := range cases {
path := filepath.Join(t.TempDir(), "s.yaml")
+3
View File
@@ -52,6 +52,9 @@ func compile(st *model.State) *config {
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
}
for _, r := range st.Reservations {
if r.Disabled {
continue
}
c.resByIP[r.IP] = r
s := st.SubnetFor(r.IP)
if s == nil {
+14 -17
View File
@@ -156,29 +156,24 @@ func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, re
return hold(requested, sub, true)
}
}
// 4. A free address: one never handed out, else the one free the
// longest.
// 4. A free address: one never handed out, in the subnet's order
// (order.go), else the one free the longest.
for _, sub := range subs {
if ip, ok := s.neverUsed(cfg, own, sub, cl, now); ok {
return hold(ip, sub, true)
}
}
var oldest netip.Addr
var oldestSub *subnet
var oldestEnd time.Time
for _, sub := range subs {
for _, pool := range sub.Pools {
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ {
ip := model.FromU32(v)
if !s.usable(cfg, own, sub, ip, cl, now) {
continue
}
l := s.leases[ip]
if l == nil {
return hold(ip, sub, true)
}
if !oldest.IsValid() || l.End.Before(oldestEnd) {
eachIn(pool, false, func(ip netip.Addr) bool {
if l := s.leases[ip]; l != nil && s.usable(cfg, own, sub, ip, cl, now) && (!oldest.IsValid() || l.End.Before(oldestEnd)) {
oldest, oldestSub, oldestEnd = ip, sub, l.End
}
if v == model.U32(pool.End) {
break
}
}
return false
})
}
}
if oldest.IsValid() {
@@ -290,7 +285,9 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
return s.nak(cfg, iface, p, sub, why)
}
start, host := now, p.HostName()
var seen *time.Time
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
seen = l.LastSeen
if l.ActiveAt(now) {
start = l.Start
}
@@ -300,7 +297,7 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
}
}
l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive,
Start: start, End: now.Add(sub.leaseTime), Interface: iface}
Start: start, End: now.Add(sub.leaseTime), Interface: iface, LastSeen: seen}
err := s.putLease(ctx, l)
delete(s.offers, ip)
s.mu.Unlock()
+136
View File
@@ -444,3 +444,139 @@ func TestPrune(t *testing.T) {
t.Errorf("old lease still saved: %+v", ls)
}
}
func TestDisabledReservation(t *testing.T) {
// The printer's reservation (10.0.10.101, inside the pool) is
// disabled: the printer gets a pool address like anyone, and its
// reserved address can go to others.
h := newHarness(t, func(st *model.State) { st.Reservations[1].Disabled = true })
got := map[string]bool{}
for i := byte(1); i <= 4; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if !got["10.0.10.101"] {
t.Errorf("the disabled reservation's address wasn't handed out: %v", got)
}
if out := h.discover("eth0", mac(0x98)); out != nil {
t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr)
}
}
func TestLastSeen(t *testing.T) {
h := newHarness(t, nil)
up := h.lease("eth0", mac(1)).YIAddr
quiet := h.lease("eth0", mac(2)).YIAddr
h.ping.answers[up] = true
h.s.CheckSeen(context.Background())
if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(h.clock) {
t.Errorf("answering client: %+v", l)
}
// One that never answers has no last-seen time, and stays active.
if l := h.s.Lease(quiet); l.LastSeen != nil || !l.ActiveAt(h.clock) {
t.Errorf("quiet client: %+v", l)
}
// Later, the time stays when it stops answering, and survives a
// renewal and a restart.
seen := h.clock
h.clock = h.clock.Add(10 * time.Minute)
h.ping.answers[up] = false
h.s.CheckSeen(context.Background())
h.lease("eth0", mac(1))
if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(seen) {
t.Errorf("after a renewal: %+v", l)
}
ls, _ := h.st.LoadLeases(context.Background())
for _, l := range ls {
if l.IP == up && (l.LastSeen == nil || !l.LastSeen.Equal(seen)) {
t.Errorf("stored: %+v", l)
}
}
// Expired leases aren't pinged.
h.clock = h.clock.Add(48 * time.Hour)
h.ping.pinged = nil
h.s.CheckSeen(context.Background())
if len(h.ping.pinged) != 0 {
t.Errorf("pinged %v", h.ping.pinged)
}
}
func TestAddressOrder(t *testing.T) {
ip := func(o *dhcp4.Packet) string { return o.YIAddr.String() }
twoPools := func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.10-10.0.20.19"), pool("10.0.20.100-10.0.20.109")}
}
// Sequential (the default): the lowest of the first pool listed.
h := newHarness(t, twoPools)
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.10" {
t.Errorf("sequential: %s", got)
}
// Never-used addresses first: one that came free isn't the next.
h.clock = h.clock.Add(2 * time.Hour)
if got := ip(h.lease("eth0.20", mac(2))); got != "10.0.20.11" {
t.Errorf("after an expiry: %s", got)
}
// But the client that had it gets it back, whatever the order.
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.10" {
t.Errorf("returning client: %s", got)
}
// Reverse from the settings.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Settings.AddressOrder = model.OrderReverse })
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.19" {
t.Errorf("reverse: %s", got)
}
// The pools in the order listed; a pool's own order wins over the
// subnet's, which wins over the settings'.
h = newHarness(t, func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{{Start: netip.MustParseAddr("10.0.20.100"), End: netip.MustParseAddr("10.0.20.109"), Order: model.OrderSequential}, pool("10.0.20.10-10.0.20.19")}
st.Subnets[1].AddressOrder = model.OrderReverse
st.Settings.AddressOrder = model.OrderRandom
})
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.100" {
t.Errorf("pool's own order: %s", got)
}
// Full first pool: the next one, in the subnet's order (reverse).
h = newHarness(t, func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.10-10.0.20.11"), pool("10.0.20.100-10.0.20.109")}
st.Subnets[1].AddressOrder = model.OrderReverse
})
got := []string{ip(h.lease("eth0.20", mac(1))), ip(h.lease("eth0.20", mac(2))), ip(h.lease("eth0.20", mac(3)))}
if strings.Join(got, " ") != "10.0.20.11 10.0.20.10 10.0.20.109" {
t.Errorf("pool by pool: %v", got)
}
// Random inside a pool: the n-th never-used address.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Subnets[1].AddressOrder = model.OrderRandom })
h.s.randN = func(n int) int { return 3 }
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.13" {
t.Errorf("random: %s", got)
}
if got := ip(h.lease("eth0.20", mac(2))); got != "10.0.20.14" {
t.Errorf("random, skipping the used one: %s", got)
}
// Random pools, weighted by never-used addresses: 10 + 10, so 12 is
// in the second pool, which then uses its own order.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Subnets[1].PoolOrder = model.PoolsRandom })
var asked []int
h.s.randN = func(n int) int { asked = append(asked, n); return 12 }
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.100" || asked[0] != 20 {
t.Errorf("random pools: %s %v", got, asked)
}
// Real randomness stays inside the pools and never repeats.
h = newHarness(t, func(st *model.State) {
twoPools(st)
st.Subnets[1].PoolOrder = model.PoolsRandom
st.Subnets[1].AddressOrder = model.OrderRandom
})
seen := map[string]bool{}
for i := byte(1); i <= 20; i++ {
a := ip(h.lease("eth0.20", mac(i)))
if seen[a] || !(strings.HasPrefix(a, "10.0.20.1") && len(a) >= 10) {
t.Fatalf("random address %s (have %v)", a, seen)
}
seen[a] = true
}
if out := h.discover("eth0.20", mac(21)); out != nil {
t.Errorf("a full subnet offered %s", out.Packet.YIAddr)
}
}
+1
View File
@@ -46,6 +46,7 @@ func (s *Server) Run(ctx context.Context) {
defer t.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
go s.watchSeen(ctx)
for {
s.reconcile(ctx)
select {
+117
View File
@@ -0,0 +1,117 @@
package dhcpd
import (
"net/netip"
"time"
"steward/internal/model"
)
// neverUsed picks a pool address that was never handed out (or whose
// lease was deleted), in the subnet's order (s.mu held): the pools listed
// or at random, and inside a pool sequential, reverse or random
// (docs/subnets.md, "Address order").
func (s *Server) neverUsed(cfg *config, own map[netip.Addr]bool, sub *subnet, cl client, now time.Time) (netip.Addr, bool) {
ok := func(ip netip.Addr) bool { return s.leases[ip] == nil && s.usable(cfg, own, sub, ip, cl, now) }
order := func(p model.Pool) string {
switch {
case p.Order != "":
return p.Order
case sub.AddressOrder != "":
return sub.AddressOrder
}
return cfg.settings.EffectiveAddressOrder()
}
if sub.PoolOrder != model.PoolsRandom {
for _, p := range sub.Pools {
if ip, found := s.pickIn(p, order(p), ok); found {
return ip, true
}
}
return netip.Addr{}, false
}
// A pool at random, weighted by its never-used addresses.
counts := make([]int, len(sub.Pools))
total := 0
for i, p := range sub.Pools {
counts[i] = countIn(p, ok)
total += counts[i]
}
if total == 0 {
return netip.Addr{}, false
}
n := s.randN(total)
for i, p := range sub.Pools {
if n < counts[i] {
return s.pickIn(p, order(p), ok)
}
n -= counts[i]
}
return netip.Addr{}, false
}
// pickIn picks an address of the pool that ok accepts, in an order.
func (s *Server) pickIn(p model.Pool, order string, ok func(netip.Addr) bool) (netip.Addr, bool) {
var found netip.Addr
switch order {
case model.OrderRandom:
n := countIn(p, ok)
if n == 0 {
return netip.Addr{}, false
}
k := s.randN(n)
eachIn(p, false, func(ip netip.Addr) bool {
if !ok(ip) {
return false
}
if k == 0 {
found = ip
return true
}
k--
return false
})
default:
eachIn(p, order == model.OrderReverse, func(ip netip.Addr) bool {
if ok(ip) {
found = ip
return true
}
return false
})
}
return found, found.IsValid()
}
// countIn counts the pool's addresses that ok accepts.
func countIn(p model.Pool, ok func(netip.Addr) bool) int {
n := 0
eachIn(p, false, func(ip netip.Addr) bool {
if ok(ip) {
n++
}
return false
})
return n
}
// eachIn calls fn for the pool's addresses, lowest first (highest first
// when reverse), until it returns true.
func eachIn(p model.Pool, reverse bool, fn func(netip.Addr) bool) {
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
return
}
lo, hi := model.U32(p.Start), model.U32(p.End)
if reverse {
for v := hi; ; v-- {
if fn(model.FromU32(v)) || v == lo {
return
}
}
}
for v := lo; ; v++ {
if fn(model.FromU32(v)) || v == hi {
return
}
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
p.once.Do(func() {
msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)"
msg := "can't send pings, so addresses aren't checked before they are offered and clients' last-seen times aren't known: " + err.Error() + " (check net.ipv4.ping_group_range)"
p.Unavailable.Store(msg)
if p.Log != nil {
p.Log.Warn(msg)
@@ -41,7 +41,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
}
defer conn.Close()
seq := int(p.seq.Add(1) & 0xffff)
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}}
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward")}}
b, err := msg.Marshal(nil)
if err != nil {
return false
+95
View File
@@ -0,0 +1,95 @@
package dhcpd
import (
"context"
"net/netip"
"sync"
"time"
"steward/internal/model"
)
// How often active leases' addresses are pinged for their last-seen time,
// and how many pings run at once.
const (
seenEvery = time.Minute
seenWorkers = 16
)
// watchSeen pings the active leases' addresses every seenEvery.
func (s *Server) watchSeen(ctx context.Context) {
t := time.NewTicker(seenEvery)
defer t.Stop()
for {
s.CheckSeen(ctx)
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// CheckSeen pings every active lease's address once (when the ping check
// is on), and records when each that answered was seen. A client that
// doesn't answer keeps its last time, or none: many block pings, and stay
// online while their lease lasts.
func (s *Server) CheckSeen(ctx context.Context) {
cfg := s.cfg.Load()
if s.pinger == nil || !cfg.settings.PingCheck {
return
}
now := s.now()
var active []model.Lease
s.mu.Lock()
for _, l := range s.leases {
if l.ActiveAt(now) {
active = append(active, *l)
}
}
s.mu.Unlock()
timeout := cfg.settings.EffectivePingTimeout()
var (
mu sync.Mutex
seen = map[netip.Addr]time.Time{}
wg sync.WaitGroup
sem = make(chan struct{}, seenWorkers)
)
for _, l := range active {
if ctx.Err() != nil {
break
}
sem <- struct{}{}
wg.Add(1)
go func() {
defer func() { <-sem; wg.Done() }()
if s.pinger.Ping(ctx, l.IP, timeout) {
mu.Lock()
seen[l.IP] = s.now()
mu.Unlock()
}
}()
}
wg.Wait()
if len(seen) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, was := range active {
at, ok := seen[was.IP]
if !ok {
continue
}
// The address may have gone to another client meanwhile.
l := s.leases[was.IP]
if l == nil || l.MAC != was.MAC || l.ClientID != was.ClientID || !l.ActiveAt(at) {
delete(seen, was.IP)
continue
}
l.LastSeen = &at
}
if err := s.store.SetLastSeen(ctx, seen); err != nil {
s.warnOnce("last seen", time.Hour, "saving clients' last-seen times failed", "err", err)
}
}
+11 -7
View File
@@ -7,10 +7,10 @@ import (
"context"
"errors"
"log/slog"
"math/rand/v2"
"net"
"net/netip"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -24,6 +24,7 @@ type LeaseStore interface {
PutLease(ctx context.Context, l *model.Lease) error
DeleteLease(ctx context.Context, ip netip.Addr) error
PruneLeases(ctx context.Context, before time.Time) (int64, error)
SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) error
}
// Interfaces reports a network interface's IPv4 addresses with their
@@ -32,7 +33,8 @@ type Interfaces interface {
Addrs(name string) ([]netip.Prefix, error)
}
// Pinger checks whether an address answers (conflict detection).
// Pinger checks whether an address answers (conflict detection, and
// clients' last-seen times).
type Pinger interface {
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool
}
@@ -51,6 +53,8 @@ type Server struct {
ifaces Interfaces
pinger Pinger
now func() time.Time
// randN returns a random number in [0, n) (address order random).
randN func(n int) int
// ServerPort and ClientPort are 67 and 68 (tests use others).
ServerPort, ClientPort int
@@ -81,7 +85,7 @@ func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Ser
if ifaces == nil {
ifaces = &HostInterfaces{}
}
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68,
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, randN: rand.IntN, ServerPort: 67, ClientPort: 68,
leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)}
s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
s.events.max = 1000
@@ -342,15 +346,15 @@ func (e *events) add(ev Event) {
e.counts[ev.Type]++
}
// Events returns the latest events, newest first, optionally only those of
// one MAC address or interface.
func (s *Server) Events(limit int, mac, iface string) []Event {
// Events returns the latest events, newest first, optionally only those
// keep accepts.
func (s *Server) Events(limit int, keep func(Event) bool) []Event {
s.events.mu.Lock()
defer s.events.mu.Unlock()
out := []Event{}
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
ev := s.events.list[i]
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface {
if keep != nil && !keep(ev) {
continue
}
out = append(out, ev)
+7 -1
View File
@@ -415,6 +415,9 @@ func put(st *model.State, kind string, id int64, data json.RawMessage) error {
// lists in their canonical form, so equal configurations compare equal.
func Normalize(st *model.State) {
st.Settings.Options = normOptions(st.Settings.Options)
if st.Settings.AddressOrder == model.OrderSequential {
st.Settings.AddressOrder = "" // the default
}
if st.Subnets == nil {
st.Subnets = []*model.Subnet{}
}
@@ -429,7 +432,10 @@ func Normalize(st *model.State) {
if s.Prefix.IsValid() && s.Prefix.Addr().Is4() {
s.Prefix = s.Prefix.Masked()
}
sort.SliceStable(s.Pools, func(i, j int) bool { return s.Pools[i].Start.Less(s.Pools[j].Start) })
// The pools' order counts (pool_order listed): it is kept.
if s.PoolOrder == model.PoolsListed {
s.PoolOrder = ""
}
}
for _, r := range st.Reservations {
r.Options = normOptions(r.Options)
+249
View File
@@ -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)
}
+28 -1
View File
@@ -90,6 +90,9 @@ type importer struct {
opts Options
res *Result
globals []string // interfaces-config interfaces
// allocator is Dhcp4's allocator (iterative, random or flq).
allocator string
noted map[string]bool
}
// ParseConfig reads a kea-dhcp4.conf.
@@ -131,6 +134,17 @@ func jsonError(b []byte, err error) error {
return err
}
// noteOnce adds a note once per key.
func (im *importer) noteOnce(key, format string, args ...any) {
if im.noted == nil {
im.noted = map[string]bool{}
}
if !im.noted[key] {
im.noted[key] = true
im.note(format, args...)
}
}
func (im *importer) note(format string, args ...any) {
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...))
}
@@ -154,7 +168,7 @@ func get[T any](o obj, key string, into *T) (bool, error) {
// note.
var (
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true,
"reservations": true, "authoritative": true, "decline-probation-period": true}
"reservations": true, "authoritative": true, "decline-probation-period": true, "allocator": true}
ignoredKeys = map[string]string{
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API",
"loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time",
@@ -195,6 +209,7 @@ func (im *importer) dhcp4(d obj) error {
im.globals = append(im.globals, name)
}
}
get(d, "allocator", &im.allocator)
set := &im.res.State.Settings
var vl int
if ok, err := get(d, "valid-lifetime", &vl); err != nil {
@@ -346,6 +361,18 @@ func (im *importer) subnet(s obj, shared obj) error {
}
sn.Options = so
}
alloc := im.allocator
if shared != nil {
get(shared, "allocator", &alloc)
}
get(s, "allocator", &alloc)
switch alloc {
case "random":
// Kea picks among all the subnet's free addresses.
sn.PoolOrder, sn.AddressOrder = model.PoolsRandom, model.OrderRandom
case "flq":
im.noteOnce("flq", "the allocator flq (free lease queue) became Steward's sequential order: a new client gets the lowest address never handed out, else the one free the longest")
}
if ok, _ := get(s, "interface", &iface); ok && iface == "" {
iface = ""
}
+21
View File
@@ -168,3 +168,24 @@ func TestParseLeases(t *testing.T) {
t.Error("a file without Kea's columns was accepted")
}
}
func TestAllocator(t *testing.T) {
cfg := `{"Dhcp4": {"allocator": "random", "subnet4": [
{"subnet": "192.168.5.0/24", "interface": "eth0", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]},
{"subnet": "192.168.6.0/24", "interface": "eth1", "allocator": "iterative", "pools": [{"pool": "192.168.6.10-192.168.6.20"}]}]}}`
res, err := ParseConfig([]byte(cfg), Options{})
if err != nil {
t.Fatal(err)
}
if s := res.State.Subnets[0]; s.PoolOrder != model.PoolsRandom || s.AddressOrder != model.OrderRandom {
t.Errorf("random: %+v", s)
}
if s := res.State.Subnets[1]; s.PoolOrder != "" || s.AddressOrder != "" {
t.Errorf("iterative: %+v", s)
}
for _, sk := range res.Skipped {
if strings.Contains(sk, "allocator") {
t.Errorf("skipped %q", sk)
}
}
}
+15
View File
@@ -43,6 +43,21 @@ type Token struct {
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
// RotatedAt is when the token last got a new secret (nil: never).
RotatedAt *time.Time `json:"rotated_at,omitempty"`
// After a rotation with a grace period, the previous secret keeps
// working until PrevExpiresAt.
PrevSecretHash string `json:"-"`
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
}
// IssuedAt is when the token's current secret was made: its last
// rotation, or its creation.
func (t *Token) IssuedAt() time.Time {
if t.RotatedAt != nil {
return *t.RotatedAt
}
return t.CreatedAt
}
// Session is a web UI login.
+79 -3
View File
@@ -48,6 +48,45 @@ type Settings struct {
// addresses that don't belong on the interface, for networks where
// another DHCP server shares the segment. Off by default.
NotAuthoritative bool `json:"not_authoritative"`
// AddressOrder is which never-used pool address a new client gets:
// sequential (the lowest, the default), reverse (the highest) or
// random. Subnets and pools can set their own.
AddressOrder string `json:"address_order,omitempty"`
}
// Address orders: which never-used pool address a new client gets. When
// every free address has been used before, the one free the longest is
// given out, whatever the order.
const (
OrderSequential = "sequential"
OrderReverse = "reverse"
OrderRandom = "random"
)
// Pool orders: which of a subnet's pools a new address comes from.
const (
// PoolsListed tries the pools in the order they are listed.
PoolsListed = "listed"
// PoolsRandom picks a pool at random, weighted by its never-used
// addresses, so addresses spread over the whole subnet.
PoolsRandom = "random"
)
// AddressOrders are the address orders, for messages and help.
var AddressOrders = []string{OrderSequential, OrderReverse, OrderRandom}
// ValidAddressOrder reports whether s is an address order ("" too: the
// default or inherited one).
func ValidAddressOrder(s string) bool {
return s == "" || s == OrderSequential || s == OrderReverse || s == OrderRandom
}
// EffectiveAddressOrder is the settings' address order with its default.
func (s Settings) EffectiveAddressOrder() string {
if s.AddressOrder == "" {
return OrderSequential
}
return s.AddressOrder
}
// DefaultSettings are the settings of a new database.
@@ -101,6 +140,12 @@ type Subnet struct {
Interface string `json:"interface"`
Prefix netip.Prefix `json:"prefix"`
Pools []Pool `json:"pools"`
// PoolOrder is which pool a new address comes from: listed (the
// default, "") or random.
PoolOrder string `json:"pool_order,omitempty"`
// AddressOrder overrides the settings' address order inside the
// subnet's pools ("": inherit); a pool can override it in turn.
AddressOrder string `json:"address_order,omitempty"`
// LeaseTime overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"`
@@ -122,9 +167,25 @@ func (s *Subnet) Label() string {
type Pool struct {
Start netip.Addr `json:"start"`
End netip.Addr `json:"end"`
// Order overrides the subnet's address order in this pool ("":
// inherit).
Order string `json:"order,omitempty"`
}
func (p Pool) String() string { return p.Start.String() + "-" + p.End.String() }
// String is the pool as ParsePool reads it: FIRST-LAST, with :ORDER when
// it has its own.
func (p Pool) String() string {
if p.Order != "" {
return p.Range() + ":" + p.Order
}
return p.Range()
}
// Range is FIRST-LAST.
func (p Pool) Range() string { return p.Start.String() + "-" + p.End.String() }
// SameRange reports whether two pools cover the same addresses.
func (p Pool) SameRange(o Pool) bool { return p.Start == o.Start && p.End == o.End }
// Contains reports whether a is in the pool.
func (p Pool) Contains(a netip.Addr) bool {
@@ -140,9 +201,21 @@ func (p Pool) Size() int {
}
// ParsePool reads "10.0.10.100-10.0.10.199" (spaces around the dash are
// fine) or a prefix, "10.0.10.128/25".
// fine) or a prefix, "10.0.10.128/25", optionally followed by the pool's
// own address order: "10.0.10.100-10.0.10.199:random".
func ParsePool(s string) (Pool, error) {
s = strings.TrimSpace(s)
if r, order, ok := strings.Cut(s, ":"); ok {
order = strings.ToLower(strings.TrimSpace(order))
if order == "default" {
order = "" // the subnet's
} else if order == "" || !ValidAddressOrder(order) {
return Pool{}, fmt.Errorf("pool %q: the order after the colon is %s or default (the subnet's)", s, strings.Join(AddressOrders, ", "))
}
p, err := ParsePool(r)
p.Order = order
return p, err
}
if a, b, ok := strings.Cut(s, "-"); ok {
start, err1 := netip.ParseAddr(strings.TrimSpace(a))
end, err2 := netip.ParseAddr(strings.TrimSpace(b))
@@ -195,7 +268,10 @@ type Reservation struct {
// Hostname is sent to the client (option 12).
Hostname string `json:"hostname"`
Options []OptionValue `json:"options"`
Comment string `json:"comment"`
// Disabled reservations are kept but ignored: the client is treated
// like any other, and an address inside a pool may go to others.
Disabled bool `json:"disabled"`
Comment string `json:"comment"`
}
// Label names the reservation in messages.
+22
View File
@@ -0,0 +1,22 @@
package model
import "testing"
func TestParsePoolOrder(t *testing.T) {
for in, want := range map[string]string{
"10.0.0.1-10.0.0.50": "10.0.0.1-10.0.0.50",
"10.0.0.1 - 10.0.0.50:Random": "10.0.0.1-10.0.0.50:random",
"10.0.0.0/26:reverse": "10.0.0.0-10.0.0.63:reverse",
"10.0.0.1-10.0.0.50:default": "10.0.0.1-10.0.0.50",
} {
p, err := ParsePool(in)
if err != nil || p.String() != want {
t.Errorf("%q: %v %v", in, p, err)
}
}
for _, in := range []string{"10.0.0.1-10.0.0.50:", "10.0.0.1-10.0.0.50:lowest"} {
if _, err := ParsePool(in); err == nil {
t.Errorf("%q accepted", in)
}
}
}
+3
View File
@@ -34,6 +34,9 @@ type Lease struct {
// Interface is where the client was last seen.
Interface string `json:"interface"`
Updated time.Time `json:"updated"`
// LastSeen is when the client last answered a ping (nil: never).
// Clients that block pings stay online while their lease lasts.
LastSeen *time.Time `json:"last_seen,omitempty"`
}
// StateAt is the lease's state at a time (an active lease past its end
+13 -2
View File
@@ -150,13 +150,21 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if err != nil {
return nil, err
}
now := time.Now()
if !auth.CheckSecret(secret, t.SecretHash) {
return nil, errors.New("wrong token secret")
// After a rotation with a grace period the old secret works until
// the grace period ends.
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
return nil, errors.New("wrong token secret")
}
if now.After(*t.PrevExpiresAt) {
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
}
s.log.Warn("token used with its old secret; give this client the new one before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
if t.RevokedAt != nil {
return nil, errors.New("the token was revoked")
}
now := time.Now()
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
return nil, errors.New("the token has expired")
}
@@ -271,6 +279,9 @@ func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
if se != nil {
out.CSRFToken = se.CSRFToken
}
if p.Token != nil {
out.TokenID = p.Token.ID
}
return out
}
+94
View File
@@ -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
}
+27 -2
View File
@@ -118,6 +118,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
if in.NotAuthoritative != nil {
set.NotAuthoritative = *in.NotAuthoritative
}
if in.AddressOrder != nil {
set.AddressOrder = orderInput(*in.AddressOrder)
}
return nil
}, func(st *model.State) any { return st.Settings })
}
@@ -132,7 +135,7 @@ func (s *Server) presentSubnet(sn *model.Subnet, st *model.State, leases []*mode
}
reserved := map[netip.Addr]bool{}
for _, res := range st.Reservations {
if sn.Prefix.Contains(res.IP) {
if sn.Prefix.Contains(res.IP) && !res.Disabled {
out.Usage.Reservations++
reserved[res.IP] = true
for _, p := range sn.Pools {
@@ -194,6 +197,16 @@ func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Princ
return nil
}
// orderInput reads an address or pool order: "default" is "" (inherited,
// or the default); validation checks the rest.
func orderInput(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "default" {
return ""
}
return s
}
// applySubnetInput sets the given fields.
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.Name != nil {
@@ -219,6 +232,12 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
sn.Pools = append(sn.Pools, pool)
}
}
if in.PoolOrder != nil {
sn.PoolOrder = orderInput(*in.PoolOrder)
}
if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder)
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
@@ -421,6 +440,9 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err
}
res.Options = opts
}
if in.Disabled != nil {
res.Disabled = *in.Disabled
}
if in.Comment != nil {
res.Comment = *in.Comment
}
@@ -569,6 +591,9 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin
st := s.eng.State()
out := []api.HostInterface{}
for name, ps := range all {
if s.excludedInterface(name) {
continue
}
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String())
@@ -582,7 +607,7 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin
}
// Subnets may name interfaces that don't exist (yet).
for _, sn := range st.Subnets {
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" {
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" && !s.excludedInterface(sn.Interface) {
found := false
for i := range out {
if out[i].Name == sn.Interface {
+28 -2
View File
@@ -29,7 +29,7 @@ func (s *Server) presentLease(l *model.Lease, st *model.State) *api.Lease {
}
}
for _, r := range st.Reservations {
if r.IP == l.IP {
if r.IP == l.IP && !r.Disabled {
out.Reservation, out.ReservedHostname = r.ID, r.Hostname
}
}
@@ -153,7 +153,30 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
evs := s.dhcp.Events(queryInt(r, "limit", 200), r.URL.Query().Get("mac"), r.URL.Query().Get("interface"))
q := r.URL.Query()
var only *model.Subnet
if ref := q.Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(s.eng.State(), ref); err != nil {
return err
}
}
mac, iface := q.Get("mac"), q.Get("interface")
evs := s.dhcp.Events(queryInt(r, "limit", 200), func(e dhcpd.Event) bool {
if mac != "" && !strings.EqualFold(e.MAC, mac) || iface != "" && e.Interface != iface {
return false
}
if only != nil {
// A message with an address belongs to the subnet that has
// it; one without (a DISCOVER) to every subnet on its
// interface.
if ip, err := netip.ParseAddr(e.IP); err == nil {
return only.Prefix.Contains(ip)
}
return e.Interface == only.Interface
}
return true
})
out := make([]api.Event, len(evs))
for i, e := range evs {
out[i] = api.Event(e)
@@ -172,6 +195,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
leases := s.dhcp.Leases()
out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{},
Problems: s.eng.Problems(), Leases: map[string]int{}, Messages: s.dhcp.Counts(), Database: s.store.Path()}
if p.IsAdmin() {
out.Problems = append(out.Problems, s.certificateProblems()...)
}
if out.Problems == nil {
out.Problems = []validate.Problem{}
}
+80
View File
@@ -200,6 +200,86 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. It
// keeps its ID, name, user and grants, so only the programs that use it
// need the new secret. The old secret stops at once, or after the grace
// period. A token that expires gets its lifetime again, from now.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
if err := decodeOptional(r, &in); err != nil {
return err
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil || t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
// A restricted token may rotate itself and tokens no wider than
// itself, but not a wider one: it would learn that token's secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !auth.Narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; make a new one", id)
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "":
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in")
}
d, err := config.ParseDuration(in.ExpiresIn)
if err != nil || d <= 0 {
return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
}
e := now.Add(d)
exp = &e
case in.NoExpiry:
case t.ExpiresAt != nil:
if life := t.ExpiresAt.Sub(t.IssuedAt()); life > 0 {
e := now.Add(life)
exp = &e
}
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := config.ParseDuration(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
return err
}
if t, err = s.store.GetToken(r.Context(), id); err != nil {
return err
}
detail := "the old secret stops now"
if prevUntil != nil {
detail = "the old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token "+t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
return nil
}
func presentUser(u *model.User) api.User {
return api.User{User: u, GrantStrings: grantStrings(u.Grants)}
}
+4
View File
@@ -28,6 +28,7 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
@@ -62,6 +63,9 @@ func (s *Server) routes() {
// Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
m.HandleFunc("GET "+p+"/certificate", s.authed(s.certificate))
m.HandleFunc("POST "+p+"/certificate/check", s.authed(s.certificateCheck))
m.HandleFunc("POST "+p+"/certificate/renew", s.authed(s.certificateRenew))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
})
+40 -7
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"net/netip"
"os"
"path"
"strconv"
"strings"
"sync"
@@ -19,6 +20,7 @@ import (
"time"
"steward/internal/auth"
"steward/internal/certs"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/store"
@@ -40,6 +42,23 @@ type Config struct {
Socket string
// PingProblem says why the ping check can't run (nil: always fine).
PingProblem func() string
// ACME keeps the certificate stewardd obtains itself (tls.acme; nil
// when off).
ACME *certs.Manager
// ExcludeInterfaces are interface names or path.Match patterns left
// out of GET /interfaces (interfaces.exclude).
ExcludeInterfaces []string
}
// excludedInterface reports whether interfaces.exclude hides the
// interface name.
func (s *Server) excludedInterface(name string) bool {
for _, p := range s.cfg.ExcludeInterfaces {
if ok, _ := path.Match(p, name); ok {
return true
}
}
return false
}
// Interfaces lists the host's interfaces.
@@ -106,6 +125,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
if len(s.cfg.Listen) == 0 {
return errors.New("no listen addresses configured")
}
// With a certificate (tls.cert_file, or one stewardd obtains, which
// may turn up later), each listener serves HTTPS and plain HTTP side
// by side (sniff.go); plain HTTP is redirected once there is one.
var tlsCfg *tls.Config
if s.cfg.TLSCertFile != "" {
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
@@ -113,7 +135,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
return err
}
s.certs = cr
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get, NextProtos: []string{"h2", "http/1.1"}}
}
if s.certs != nil || s.cfg.ACME != nil {
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
}
errc := make(chan error, len(s.cfg.Listen)+1)
var listeners []net.Listener
@@ -131,8 +155,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
listeners = append(listeners, ln)
}
for i, ln := range listeners {
handler := s.Handler()
if tlsCfg != nil {
handler = s.requireHTTPS(handler)
}
srv := &http.Server{
Handler: s.Handler(),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute,
WriteTimeout: 5 * time.Minute,
@@ -145,11 +173,16 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
s.mu.Unlock()
scheme := "http"
if tlsCfg != nil {
scheme = "https"
ln = tls.NewListener(ln, tlsCfg)
} else if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
if s.httpsAvailable() {
scheme = "https"
}
ln = newSniffListener(ln, tlsCfg)
}
if !s.httpsAvailable() && s.cfg.ACME == nil {
if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
}
}
}
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
+99
View File
@@ -11,6 +11,7 @@ import (
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
@@ -246,6 +247,16 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
// By subnet: its addresses, and DISCOVERs on its interface.
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, nil)
for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} {
if code := e.call(e.token, "GET", "/events?subnet="+url.QueryEscape(ref), nil, &evs); code != 200 || len(evs) != want {
t.Errorf("events of subnet %s: %d %+v", ref, code, evs)
}
}
if code := e.call(e.token, "GET", "/events?subnet=nope", nil, nil); code != 404 && code != 400 {
t.Errorf("unknown subnet: %d", code)
}
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
t.Errorf("delete lease: %d", code)
}
@@ -335,3 +346,91 @@ func TestImportKea(t *testing.T) {
t.Errorf("import over a named subnet: %d %+v", code, third)
}
}
func TestTokenRotation(t *testing.T) {
e := newEnv(t)
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok)
var rot api.Token
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil {
t.Fatalf("rotate: %d %+v", code, rot)
}
if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 {
t.Errorf("rotated token %+v", rot)
}
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("new secret: %d", code)
}
if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("old secret in its grace period: %d", code)
}
var me api.Whoami
e.call(rot.Secret, "GET", "/auth/whoami", nil, &me)
if me.TokenID != tok.ID {
t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID)
}
// Without a grace period the old secret stops at once.
var rot2 api.Token
e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2)
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 {
t.Errorf("old secret without grace: %d", code)
}
if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("newest secret: %d", code)
}
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 {
t.Errorf("a grace over 7 days: %d", code)
}
// A restricted token can't rotate a wider one.
var narrow api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "leases"}, &narrow)
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 {
t.Errorf("narrow token rotating a wider one: %d", code)
}
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 {
t.Errorf("a token rotating itself: %d", code)
}
}
func TestInterfacesExclude(t *testing.T) {
e := newEnv(t)
e.srv.cfg.ExcludeInterfaces = []string{"eth0.*", "docker*"}
// A subnet on an excluded interface the host doesn't have.
if c := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("172.17.0.0/16"), Interface: ptr("docker0")}, nil); c != 201 {
t.Fatalf("create: %d", c)
}
var ifs []api.HostInterface
e.call(e.token, "GET", "/interfaces", nil, &ifs)
if len(ifs) != 1 || ifs[0].Name != "eth0" {
t.Errorf("interfaces: %+v", ifs)
}
}
func TestAddressOrderAPI(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
in := api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), PoolOrder: ptr("random"), AddressOrder: ptr("Reverse"),
Pools: &[]string{"10.0.20.100-10.0.20.110:random", "10.0.20.10-10.0.20.20"}}
if code := e.call(e.token, "POST", "/subnets", in, &mr); code != 201 {
t.Fatalf("create: %d %+v", code, mr)
}
sn := e.eng.State().Subnets[0]
// The pools keep the order given.
if sn.PoolOrder != "random" || sn.AddressOrder != "reverse" || sn.Pools[0].String() != "10.0.20.100-10.0.20.110:random" || sn.Pools[1].Order != "" {
t.Errorf("subnet %+v", sn)
}
// "listed" and "default" are the defaults, stored as "".
if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{PoolOrder: ptr("listed"), AddressOrder: ptr("default")}, nil); code != 200 {
t.Errorf("patch: %d", code)
}
if sn = e.eng.State().Subnets[0]; sn.PoolOrder != "" || sn.AddressOrder != "" {
t.Errorf("defaults %+v", sn)
}
var eb api.ErrorBody
if code := e.call(e.token, "PATCH", "/settings", api.SettingsInput{AddressOrder: ptr("lowest")}, &eb); code != 422 || eb.Error.Problems[0].Code != "address_order" {
t.Errorf("bad order: %d %+v", code, eb)
}
if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{Pools: &[]string{"10.0.20.10-10.0.20.20:lowest"}}, nil); code != 400 {
t.Errorf("bad pool order: %d", code)
}
}
+147
View File
@@ -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)
})
}
+58
View File
@@ -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)
}
}
}
+24 -5
View File
@@ -2,13 +2,14 @@ package store
import (
"context"
"database/sql"
"net/netip"
"time"
"steward/internal/model"
)
const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated`
const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated, last_seen`
// LoadLeases reads every lease.
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
@@ -21,11 +22,13 @@ func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
for rows.Next() {
l := &model.Lease{}
var ip, start, end, updated string
if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated); err != nil {
var seen sql.NullString
if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated, &seen); err != nil {
return nil, err
}
l.IP, _ = netip.ParseAddr(ip)
l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated)
l.LastSeen = parseTSPtr(seen)
out = append(out, l)
}
return out, rows.Err()
@@ -46,10 +49,26 @@ func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
}
defer tx.Rollback()
for _, l := range ls {
if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)
if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ip) DO UPDATE SET subnet_id = excluded.subnet_id, mac = excluded.mac, client_id = excluded.client_id, hostname = excluded.hostname,
state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated`,
l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated)); err != nil {
state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated, last_seen = excluded.last_seen`,
l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated), tsPtr(l.LastSeen)); err != nil {
return err
}
}
return tx.Commit()
}
// SetLastSeen records when leases' clients answered a ping, in one
// transaction.
func (s *Store) SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for ip, t := range seen {
if _, err := tx.ExecContext(ctx, `UPDATE leases SET last_seen = ? WHERE ip = ?`, ts(t), ip.String()); err != nil {
return err
}
}
+12 -10
View File
@@ -25,7 +25,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{}
}
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, lease_time, options, disabled, comment FROM subnets ORDER BY id`)
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment FROM subnets ORDER BY id`)
if err != nil {
return nil, err
}
@@ -34,7 +34,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
sn := &model.Subnet{}
var prefix, pools, opts string
var disabled int
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
return nil, err
}
sn.Prefix, _ = netip.ParsePrefix(prefix)
@@ -50,7 +50,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, comment FROM reservations ORDER BY id`)
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, disabled, comment FROM reservations ORDER BY id`)
if err != nil {
return nil, err
}
@@ -58,10 +58,12 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
for rows.Next() {
r := &model.Reservation{}
var ip, opts string
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.Comment); err != nil {
var disabled int
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &disabled, &r.Comment); err != nil {
return nil, err
}
r.IP, _ = netip.ParseAddr(ip)
r.Disabled = disabled != 0
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
}
@@ -144,10 +146,10 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if err := json.Unmarshal(c.After, &sn); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?)
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
return err
case model.KindReservation:
if c.After == nil {
@@ -158,10 +160,10 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if err := json.Unmarshal(c.After, &r); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, comment) VALUES (?,?,?,?,?,?,?)
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname,
options = excluded.options, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.Comment)
options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), boolInt(r.Disabled), r.Comment)
return err
}
return fmt.Errorf("unknown kind %q", c.Kind)
+19
View File
@@ -187,6 +187,25 @@ CREATE TABLE audit (
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX audit_time ON audit (time);
`,
// 2: token rotation: a new secret, the old one working for a grace period.
`
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
`,
// 3: disabled reservations.
`
ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
// 4: when a lease's client last answered a ping.
`
ALTER TABLE leases ADD COLUMN last_seen TEXT;
`,
// 5: the order addresses are handed out in (a pool's own is in pools).
`
ALTER TABLE subnets ADD COLUMN pool_order TEXT NOT NULL DEFAULT '';
ALTER TABLE subnets ADD COLUMN address_order TEXT NOT NULL DEFAULT '';
`,
}
+2 -2
View File
@@ -35,7 +35,7 @@ func TestStateHistoryAndLeases(t *testing.T) {
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"}
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Disabled: true}
set := st.Settings
set.LeaseTime = 3600
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
@@ -51,7 +51,7 @@ func TestStateHistoryAndLeases(t *testing.T) {
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
t.Errorf("subnets %+v", st.Subnets)
}
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || st.Settings.LeaseTime != 3600 {
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || !st.Reservations[0].Disabled || st.Settings.LeaseTime != 3600 {
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
}
// Undo: the inverse changes, marked as a revert.
+23 -3
View File
@@ -132,13 +132,13 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
return nil
}
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
t := &model.Token{}
var grants, exp, lu, rev sql.NullString
var grants, exp, lu, rev, rot, pexp sql.NullString
var ca string
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
return nil, err
}
if grants.Valid {
@@ -150,6 +150,7 @@ func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
}
}
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
t.CreatedAt = parseTS(ca)
return t, nil
}
@@ -228,6 +229,25 @@ func (s *Store) RevokeToken(ctx context.Context, id int64) error {
return nil
}
// RotateToken gives a token a new secret and expiry. With prevUntil set,
// the old secret keeps working until then; otherwise it stops at once. A
// revoked token can't be rotated (ErrNotFound).
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
prevHash := `''`
if prevUntil != nil {
prevHash = `secret_hash`
}
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchToken records a token's use.
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
+33 -17
View File
@@ -84,6 +84,9 @@ func (c *checker) settings(s model.Settings) {
if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) {
c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold)
}
if !model.ValidAddressOrder(s.AddressOrder) {
c.add(Error, "address_order", "settings", "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", "))
}
c.options(s.Options, "settings")
}
@@ -173,22 +176,31 @@ func (c *checker) pools(s *model.Subnet, obj string) {
if len(s.Pools) == 0 && !s.Disabled {
c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address")
}
if s.PoolOrder != "" && s.PoolOrder != model.PoolsListed && s.PoolOrder != model.PoolsRandom {
c.add(Error, "pool_order", obj, "the pool order %q is not listed or random", s.PoolOrder)
}
if !model.ValidAddressOrder(s.AddressOrder) {
c.add(Error, "address_order", obj, "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", "))
}
network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
for i, p := range s.Pools {
if !model.ValidAddressOrder(p.Order) {
c.add(Error, "address_order", obj, "pool %s: the address order %q is not one of %s", p.Range(), p.Order, strings.Join(model.AddressOrders, ", "))
}
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p)
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p.Range())
continue
}
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) {
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p, s.Prefix)
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p.Range(), s.Prefix)
continue
}
if p.Contains(network) || p.Contains(broadcast) {
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p, network, broadcast, network.Next(), broadcast.Prev())
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p.Range(), network, broadcast, network.Next(), broadcast.Prev())
}
for _, o := range s.Pools[:i] {
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) {
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o, p)
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o.Range(), p.Range())
}
}
}
@@ -221,7 +233,7 @@ func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj strin
}
for _, p := range s.Pools {
if p.Contains(a) {
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p)
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p.Range())
}
}
}
@@ -242,7 +254,7 @@ func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) {
if s.Prefix.Contains(a.Addr()) {
for _, p := range s.Pools {
if p.Contains(a.Addr()) {
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p, a.Addr())
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p.Range(), a.Addr())
}
}
return
@@ -275,18 +287,22 @@ func (c *checker) reservations(st *model.State, env *Env) {
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
}
if o := byIP[r.IP]; o != nil {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
// A disabled reservation is ignored, so it takes no address or
// client from another; enabling it again is checked.
if !r.Disabled {
if o := byIP[r.IP]; o != nil {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
}
byIP[r.IP] = r
k := key{s.ID, "mac " + r.MAC}
if r.ClientID != "" {
k.id = "id " + r.ClientID
}
if o := byClient[k]; o != nil {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
}
byClient[k] = r
}
byIP[r.IP] = r
k := key{s.ID, "mac " + r.MAC}
if r.ClientID != "" {
k.id = "id " + r.ClientID
}
if o := byClient[k]; o != nil {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
}
byClient[k] = r
if r.Hostname != "" && !validHostname(r.Hostname) {
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
}
+16
View File
@@ -98,6 +98,22 @@ func TestProblems(t *testing.T) {
}
}
func TestDisabledReservationsDontConflict(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Reservations[0].Disabled = true
// The same client and address again, enabled: fine while the first is
// disabled.
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20")})
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("problems: %v", ps)
}
st.Reservations[0].Disabled = false
if got := codes(Check(st, nil)); !strings.Contains(got, "reservation_ip_taken") {
t.Errorf("enabling it again: %q", got)
}
}
func TestSameMACInTwoSubnets(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
+4 -1
View File
@@ -13,6 +13,7 @@ import * as history from "./pages/history.js";
import * as audit from "./pages/audit.js";
import * as users from "./pages/users.js";
import * as account from "./pages/account.js";
import * as certificate from "./pages/certificate.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
@@ -27,6 +28,7 @@ const routes = [
{ pattern: /^audit$/, page: audit, nav: "audit" },
{ pattern: /^users$/, page: users, nav: "users" },
{ pattern: /^account$/, page: account, nav: "account" },
{ pattern: /^certificate$/, page: certificate, nav: "certificate" },
];
// The top bar. ["key", "#/href", "Label", permission?]
@@ -39,6 +41,7 @@ const navItems = [
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and options", "config"],
["certificate", "#/certificate", "Certificate", "admin"],
["audit", "#/audit", "Audit log", "audit"],
] },
];
@@ -116,7 +119,7 @@ function footer(b) {
}
function renderNav() {
const allowed = (n) => !n[3] || can(n[3], false);
const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false));
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
+12 -4
View File
@@ -1,8 +1,8 @@
// Your account: password and API tokens.
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
import { get, api, state, catalog } from "../api.js";
import { get, api, state, catalog, can } from "../api.js";
import { rerender } from "../app.js";
import { revoke } from "./users.js";
import { revoke, rotateDialog } from "./users.js";
export async function render(el) {
const me = state.me;
@@ -36,13 +36,21 @@ export async function render(el) {
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
{ label: "", tdClass: "actions-cell", render: (t) => [
h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ",
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
], tokens, { empty: "No tokens." })));
}
async function newToken(cat) {
const name = input({ placeholder: "laptop, backup script…" });
const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
// Only roles within what the user may do: a token can never do more
// than its user, so offering more would only mislead.
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
const [res, acc] = g.split(":");
return can(res, acc === "write");
}));
const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
const err = h("div");
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
+11 -6
View File
@@ -2,16 +2,18 @@
import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js";
import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.js";
export async function render(el, params, query) {
const ifaces = await get("/interfaces");
const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]);
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div");
let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => {
const evs = await get("/events", { mac: mac.value.trim(), interface: iface.value, limit: 300 });
const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 });
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("tbody", null, evs.map((e) => {
@@ -20,18 +22,21 @@ export async function render(el, params, query) {
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", { class: "mono" }, e.interface),
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)),
h("td", { class: "mono" }, e.mac ? h("a", { href: "#", onclick: (ev) => { ev.preventDefault(); mac.value = e.mac; draw(); } }, e.mac) : ""),
h("td", { class: "mono nowrap" }, e.mac ? [
h("a", { href: "#/leases?q=" + encodeURIComponent(e.mac), title: "This client's leases" }, e.mac), " ",
mac.value.trim() ? null : h("button", { class: "small reveal", title: "Show only this client's messages", onclick: () => { mac.value = e.mac; draw(); } }, "Only this")] : ""),
h("td", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || ""));
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : ".")));
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : ".")));
};
mac.addEventListener("input", debounce(draw, 300));
subnetSel.addEventListener("change", draw);
iface.addEventListener("change", draw);
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"),
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. Click a MAC address to follow one client."))),
h("div", { class: "filter-row" }, mac, iface, pause),
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. A MAC address leads to that client's leases; Only this follows just that client."))),
h("div", { class: "filter-row" }, mac, subnetSel, iface, pause),
h("div", { class: "panel" }, out));
await draw();
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
@@ -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));
}
@@ -50,7 +50,7 @@ export async function render(el) {
problemsList(st.problems),
st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null,
events.length ? h("div", { class: "panel" },
h("h2", null, "Latest DHCP messages ", h("a", { href: "#/activity", class: "small" }, "all")),
h("div", { class: "panel-head" }, h("h2", null, "Latest DHCP messages"), h("a", { class: "button small", href: "#/activity" }, "All activity")),
table([
{ label: "When", render: (e) => time(e.time) },
{ label: "Interface", render: (e) => e.interface },
@@ -20,6 +20,7 @@ export function leasesTable(leases, done, opts = {}) {
{ label: "Host name", render: leaseHost },
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
{ label: "Ends", render: (l) => time(l.end) },
{ label: "Last seen", render: (l) => l.last_seen ? time(l.last_seen) : "" },
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
{ label: "", tdClass: "actions-cell", render: (l) => [
@@ -1,24 +1,37 @@
// Reservations: fixed addresses for clients.
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce } from "../ui.js";
import { h, mount, table, time, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.js";
import { onLeave } from "../app.js";
// reservationsTable lists reservations with Edit and Delete.
export function reservationsTable(rs, done, opts = {}) {
const write = can("config", true);
return table([
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.ip) },
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] },
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
{ label: "Online", render: (r) => r.lease ? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, "yes, on " + r.lease.interface) : h("span", { class: "muted" }, "no") },
{ label: "Online", render: (r) => r.lease
? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface),
r.lease.last_seen ? h("span", { class: "muted small", title: "last answered a ping" }, ", seen ", time(r.lease.last_seen)) : null)
: badge("offline") },
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" },
{ label: "Comment", render: (r) => r.comment },
write ? { label: "", tdClass: "actions-cell", render: (r) => [
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
h("button", { class: "small reveal", title: r.disabled ? "Use this reservation again" : "Keep it, but ignore it: the client gets a pool address like any other",
onclick: () => toggle(r, done) }, r.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
] } : null,
].filter(Boolean), rs, { empty: opts.empty || "No reservations." });
].filter(Boolean), rs, { empty: opts.empty || "No reservations.", rowClass: (r) => r.disabled ? "disabled" : "" });
}
async function toggle(r, done) {
try {
await change("PATCH", "/reservations/" + r.id, { disabled: !r.disabled });
done();
} catch (e) { modal(r.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
}
async function del(r, done) {
@@ -42,6 +55,7 @@ export async function reservationDialog(r, done, preset = {}) {
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
const opts = await optionsEditor(r ? r.options : [], inherited);
const comment = input({ value: r ? r.comment : "" });
const disabled = checkbox("Disabled: keep it, but ignore it (the client gets a pool address like any other)", r ? r.disabled : false);
const err = h("div");
const body = h("div", null, h("div", { class: "form-grid" },
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
@@ -49,12 +63,14 @@ export async function reservationDialog(r, done, preset = {}) {
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
field("Host name", host, "Sent to the client."),
field("Options", opts, "Only for this client; they win over the subnet's."),
field("Comment", comment)), err);
field("Comment", comment),
r ? [h("label"), disabled] : null), err);
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
{ label: "Cancel", value: null },
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
mount(err);
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
if (r) v.disabled = disabled.input.checked;
try {
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
done();
@@ -72,19 +88,25 @@ export async function render(el, params, query) {
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div");
let gen = 0;
const draw = async () => {
const my = ++gen;
const q = search.value.trim().toLowerCase();
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) =>
!q || r.ip.includes(q) || (r.mac || "").includes(q) || (r.hostname || "").toLowerCase().includes(q) || (r.client_id || "").includes(q));
if (my !== gen) return; // a newer draw (typing, a change) won
mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
};
subnetSel.addEventListener("change", draw);
search.addEventListener("input", debounce(draw, 200));
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier.")),
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier. Refreshed every 15 seconds.")),
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => reservationDialog(null, draw) }, "New reservation")) : null),
h("div", { class: "filter-row" }, subnetSel, search),
h("div", { class: "panel" }, out));
await draw();
// Online and last seen change as clients come and go.
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
onLeave(() => clearInterval(t));
}
+8 -4
View File
@@ -1,19 +1,20 @@
// Settings: the default lease time, conflict checks, authority, and the
// options every subnet gets.
import { h, mount, input, field, checkbox, errorBox } from "../ui.js";
import { h, mount, input, field, checkbox, errorBox, readOnly, select } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
import { optionsEditor, fmtSeconds, parseSeconds, ADDRESS_ORDERS } from "../widgets.js";
import { rerender } from "../app.js";
export async function render(el) {
const s = await get("/settings");
const write = can("config", true);
const lease = input({ value: fmtSeconds(s.lease_time || 43200) });
const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check);
const ping = checkbox("Ping an address before offering it, and skip it if something answers; also ping clients every minute for their last-seen time", s.ping_check);
const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
timeout.style.width = "7em";
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative);
const order = select(ADDRESS_ORDERS, s.address_order || "sequential");
const opts = await optionsEditor(s.options);
const err = h("div");
const save = async (dry) => {
@@ -22,11 +23,12 @@ export async function render(el) {
if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
try {
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0,
decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
decline_hold: dh, not_authoritative: !auth.input.checked, address_order: order.value, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
mount(el,
write ? null : h("div", { class: "msg info" }, "You can see the settings but not change them (that needs config:write)."),
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"),
h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))),
h("div", { class: "panel" },
@@ -34,10 +36,12 @@ export async function render(el) {
field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."),
field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."),
field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))),
field("Address order", order, "Which address of a pool a new client gets: sequential (the lowest), reverse (the highest) or random. Only addresses never handed out count; when there are none left, the one free the longest. Subnets and pools can set their own."),
field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."),
field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
err,
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
if (!write) readOnly(el.querySelector(".panel"));
}
+23 -10
View File
@@ -1,7 +1,7 @@
// A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js";
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js";
import { leasesTable } from "./leases.js";
import { reservationsTable, reservationDialog } from "./reservations.js";
import { rerender } from "../app.js";
@@ -19,9 +19,14 @@ export async function render(el, params) {
h("div", { class: "grow" },
h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null),
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none",
orderText(s) ? " · " + orderText(s) : "",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", {
title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay",
onclick: () => toggleSubnet(s) }, s.disabled ? "Enable subnet" : "Disable subnet")) : null),
s.disabled ? h("div", { class: "msg warn" }, "This subnet is disabled: its clients get no answers. Its configuration and leases are kept.") : null,
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
body);
if (tab === "leases") await leasesTab(body, s);
@@ -29,15 +34,23 @@ export async function render(el, params) {
else await settingsTab(body, s);
}
export async function toggleSubnet(s) {
if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return;
try {
await change("PATCH", "/subnets/" + s.id, { disabled: !s.disabled });
rerender();
} catch (e) { modal(s.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
}
async function leasesTab(el, s) {
const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div");
let all = false;
const toggle = h("a", { href: "#", class: "small", onclick: (e) => { e.preventDefault(); all = !all; toggle.textContent = all ? "Only current leases" : "Also expired and released"; draw(); } }, "Also expired and released");
const toggle = checkbox("Include ended leases (expired and released)", false);
toggle.input.addEventListener("change", () => draw());
const draw = async () => {
let ls = await get("/leases", { subnet: s.id, q: search.value.trim() });
if (!all) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
mount(out, leasesTable(ls, rerender, { subnet: true, empty: all ? "No leases." : "No current leases." }));
if (!toggle.input.checked) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
mount(out, leasesTable(ls, rerender, { subnet: true, empty: toggle.input.checked ? "No leases." : "No current leases." }));
};
search.addEventListener("input", debounce(draw, 300));
mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out));
@@ -53,11 +66,11 @@ async function reservationsTab(el, s) {
async function settingsTab(el, s) {
const settings = await get("/settings");
const form = await subnetForm(s, settings);
if (!can("config", true)) {
mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2))));
mount(el, h("div", { class: "msg info" }, "You can see the subnet's settings but not change them (that needs config:write)."), readOnly(h("div", { class: "panel" }, form)));
return;
}
const form = await subnetForm(s, settings);
const err = h("div");
const save = async (dry) => {
mount(err);
+6 -4
View File
@@ -1,11 +1,12 @@
// The subnets, and the New subnet dialog.
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText } from "../widgets.js";
import { toggleSubnet } from "./subnet.js";
export async function render(el, params, query) {
const subnets = await get("/subnets");
const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–";
const poolsText = (s) => s.pools.length ? s.pools.map(poolText).join(", ") : "–";
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Subnets"),
@@ -18,8 +19,9 @@ export async function render(el, params, query) {
{ label: "In use", render: (s) => usageBar(s.usage) },
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null },
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." })));
{ label: "", tdClass: "actions-cell", render: (s) => [s.disabled ? [badge("disabled", "off"), " "] : null,
can("config", true) ? h("button", { class: "small reveal", onclick: () => toggleSubnet(s) }, s.disabled ? "Enable" : "Disable") : null] },
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve.", rowClass: (s) => s.disabled ? "disabled" : "" })));
if (query.get("new") === "1" && can("config", true)) newSubnet();
}
+38 -2
View File
@@ -1,5 +1,5 @@
// Users and everyone's tokens (administrators).
import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js";
import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } from "../ui.js";
import { get, api, catalog } from "../api.js";
import { rerender } from "../app.js";
@@ -26,10 +26,46 @@ export async function render(el) {
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" },
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
{ label: "", tdClass: "actions-cell", render: (t) => [
h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ",
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
], tokens, { empty: "No tokens." })));
}
// rotateDialog gives a token a new secret. It keeps its name, ID and
// permissions; only the programs that use it need the new secret.
export async function rotateDialog(t) {
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
if (t.expires_at) expOpts.push(["never", "never expires"]);
const exp = select(expOpts, "");
const err = h("div");
const box = h("div");
const go = h("button", { class: "primary", onclick: async () => {
mount(err);
const body = {};
if (grace.value) body.grace = grace.value;
if (exp.value === "never") body.no_expiry = true;
else if (exp.value) body.expires_in = exp.value;
go.disabled = true;
try {
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
mount(box, h("div", { class: "msg ok" },
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
copyBox(r.secret),
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), "."] : "The old secret no longer works.")));
rerender();
} catch (ex) { go.disabled = false; mount(err, errorBox(ex)); }
} }, "Rotate");
mount(box,
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
h("div", { class: "form-grid" },
field("Old secret", grace, "A grace period lets you update the programs without an outage. If the token may have leaked, let it stop now."),
field("Expires", exp)),
err, h("div", { class: "actions mt" }, go));
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
export async function revoke(t) {
if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return;
try {
+9 -5
View File
@@ -110,11 +110,9 @@ header.top .spacer { flex: 1; }
header.top form.search input { width: 220px; }
header.top .user { color: var(--muted); }
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.page-head > .actions { padding: 4px 0; }
.filter-row + .panel, .actions + .panel { margin-top: 4px; }
.page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
@@ -344,6 +342,7 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
.pool-rows { display: flex; flex-direction: column; gap: 6px; }
.pool-row { display: flex; gap: 6px; align-items: center; }
.pool-row input { font-family: var(--mono); width: 18em; }
.pool-row select { width: auto; }
/* Activity: one line per message. */
table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); }
@@ -351,3 +350,8 @@ table.activity td.detail { white-space: normal; color: var(--muted); }
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
.type-RELEASE { color: var(--muted); }
td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
.mt { margin-top: 10px; }
input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
+8
View File
@@ -226,3 +226,11 @@ export function backLink(label, href) {
export function safeBack(v) {
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
}
// readOnly locks a form for someone who may look but not change: every
// control is disabled, and the editors' add and remove buttons hidden.
export function readOnly(el) {
for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true;
for (const b of el.querySelectorAll("button")) b.classList.add("hidden");
return el;
}
+42 -12
View File
@@ -114,25 +114,51 @@ const examples = {
// ---- pools
// poolsEditor edits a subnet's pools; value() is ["first-last", ...].
// Each pool has its own address order, or the subnet's; ↑ moves a pool up
// (with pools in the order listed, the first is used first).
export function poolsEditor(pools, placeholder) {
const rows = h("div", { class: "pool-rows" });
const box = h("div", null, rows);
const list = [];
const addRow = (v) => {
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" });
const row = h("div", { class: "pool-row" }, inp,
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕"));
list.push(inp);
rows.append(row);
return inp;
let list = [];
const addRow = (v, order) => {
const it = { inp: input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }) };
it.sel = select([["", "subnet's order"], ...ADDRESS_ORDERS.map((o) => [o, o])], order || "", { title: "The order addresses of this pool are handed out in" });
it.row = h("div", { class: "pool-row" }, it.inp, it.sel,
h("button", { type: "button", class: "small", title: "Move this pool up: it is used before the ones below", onclick: () => {
const i = list.indexOf(it);
if (i > 0) { list.splice(i, 1); list.splice(i - 1, 0, it); rows.insertBefore(it.row, list[i].row); }
} }, "↑"),
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
list.push(it);
rows.append(it.row);
return it.inp;
};
for (const p of pools || []) addRow(p.start + "-" + p.end);
for (const p of pools || []) addRow(p.start + "-" + p.end, p.order);
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; };
box.value = () => list.map((it) => {
const v = it.inp.value.trim();
return v && it.sel.value && !v.includes(":") ? v + ":" + it.sel.value : v;
}).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].inp.value) list[0].inp.value = v; };
return box;
}
export const ADDRESS_ORDERS = ["sequential", "reverse", "random"];
// poolText shows a pool, with its own address order.
export function poolText(p) {
return p.start + "-" + p.end + (p.order ? " (" + p.order + ")" : "");
}
// orderText says how a subnet hands out new addresses, or "" for the
// defaults.
export function orderText(s) {
const parts = [];
if (s.pool_order === "random") parts.push("a pool at random");
if (s.address_order) parts.push(s.address_order + " addresses");
return parts.join(", ");
}
// ---- the subnet form
// v4 helpers for suggestions.
@@ -163,6 +189,8 @@ export async function subnetForm(sn, settings) {
const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" });
const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
const pools = poolsEditor(sn ? sn.pools : []);
const poolOrder = select([["", "in the order listed"], ["random", "at random (weighted by free addresses)"]], sn ? sn.pool_order || "" : "");
const addrOrder = select([["", "the settings' (" + (settings.address_order || "sequential") + ")"], ...ADDRESS_ORDERS.map((o) => [o, o])], sn ? sn.address_order || "" : "");
const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) });
const opts = await optionsEditor(sn ? sn.options : [], settings.options);
const comment = input({ value: sn ? sn.comment : "" });
@@ -192,6 +220,8 @@ export async function subnetForm(sn, settings) {
field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."),
field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."),
field("Pool order", poolOrder, "Which pool a new client's address comes from."),
field("Address order", addrOrder, "Which address of a pool: sequential (the lowest), reverse (the highest) or random. A pool can set its own. New clients get an address never handed out before; when there are none, the one free the longest. Returning clients keep theirs."),
field("Lease time", lease, "12h, 1d, 30m; empty for the default."),
field("Options", opts),
field("Comment", comment),
@@ -199,7 +229,7 @@ export async function subnetForm(sn, settings) {
grid.values = () => {
const lt = parseSeconds(lease.value);
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m");
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), lease_time: lt, options: opts.value(), comment: comment.value };
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), pool_order: poolOrder.value || "listed", address_order: addrOrder.value || "default", lease_time: lt, options: opts.value(), comment: comment.value };
if (sn) v.disabled = disabled.input.checked;
return v;
};
+43
View File
@@ -106,6 +106,15 @@ run "client on eth1 gets the other subnet" 0 udhcpc b 02:00:00:00:00:02 -x hostn
contains "BOUND ip=$NB.100 mask=255.255.255.0 router=$NB.1 dns= domain=lan lease=3600 serverid=$NB.2"
run "reserved client gets its address, host name and option" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*hostname=printer ntp=$NA.2"
run "disable the reservation" 0 S reservation disable $NA.20
run "a disabled reservation's client gets a pool address" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.10"
not_contains "hostname=printer"
POOLIP=$(echo "$OUT" | sed -n 's/^BOUND ip=\([0-9.]*\) .*/\1/p')
run "enable it again" 0 S reservation enable $NA.20
run "the reserved address again" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*hostname=printer"
run "free the pool address it had meanwhile" 0 S lease delete "$POOLIP" --yes
run "the same device on the other VLAN gets that VLAN's address" 0 udhcpc b 02:00:00:00:00:20
contains "BOUND ip=$NB.101 "
run "leases" 0 S lease list
@@ -169,12 +178,24 @@ run "undo the lease time change" 0 S history undo $(S history list -o json | gre
run "lease time back" 0 S subnet show iot
contains "Lease time: *1h"
# ---- last seen: a client that stays up answers pings
# busybox's default script puts the leased address on the interface.
docker run -d --name ${P}-live --network ${P}-b --mac-address 02:00:00:00:00:30 --cap-add NET_ADMIN steward-e2e-client \
udhcpc -i eth0 -f -x hostname:live >/dev/null
for i in $(seq 1 20); do S lease list | grep -q " live " && break; sleep 0.5; done
# ---- restart: leases survive
docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1
run "restart stewardd" 0 start
run "leases after the restart" 0 S lease list
contains "$NA.101 *02:00:00:00:00:01 *laptop *active"
# A ping round runs at start (then every minute).
sleep 2
run "last seen" 0 S lease list
contains "live *active *in [0-9hm]* *now *eth1"
contains "laptop *active *in [0-9hm]* *- *eth0"
run "the client still gets its address" 0 udhcpc a 02:00:00:00:00:01
contains "BOUND ip=$NA.101 "
@@ -185,12 +206,34 @@ run "a token" 0 test -n "$TOKEN"
run "CLI over HTTP with the token" 0 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token "$TOKEN" lease list
contains "laptop"
run "without a token: refused" 3 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb status
H="$WORK/steward --config $WORK/cli.yaml --server http://$NA.2:8067"
NEW=$($H --token "$TOKEN" token rotate --current --grace 1h 2>/dev/null)
run "rotate the CLI's own token" 0 test -n "$NEW"
run "the new secret works" 0 $H --token "$NEW" whoami
run "the old one too, during its grace period" 0 $H --token "$TOKEN" whoami
NEWER=$($H --token "$NEW" token rotate --current 2>/dev/null)
run "rotated without grace: the old secret stops" 3 $H --token "$NEW" whoami
TOKEN=$NEWER
LEASES=$(S token create readonly --user admin --role leases 2>/dev/null)
run "a leases-only token can't change subnets" 3 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token "$LEASES" subnet disable office
run "delete a lease" 0 S lease delete $NA.101 --yes
run "audit log" 0 S audit --action lease.
contains "lease.delete"
# ---- address order
run "reverse address order" 0 S subnet update iot --address-order reverse
run "the subnet shows its order" 0 S subnet show iot
contains "addresses reverse"
run "a new client gets the highest never-used address" 0 udhcpc b 02:00:00:00:00:40
contains "BOUND ip=$NB.110 "
run "a pool's own order wins" 0 S subnet update iot --add-pool $NB.100-$NB.110:sequential
run "the pool shows it" 0 S subnet show iot
contains "$NB.100-$NB.110:sequential"
run "now the lowest never-used one" 0 udhcpc b 02:00:00:00:00:41
contains "BOUND ip=$NB.10[0-8] "
run "an unknown order is refused" 1 S subnet update iot --add-pool $NB.100-$NB.110:lowest
# ---- backup
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup