Compare commits
12
Commits
main
..
v0.2.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c995ae819d | ||
|
|
3ad2f981d4 | ||
|
|
b3c8329ae8 | ||
|
|
4fd72998ec | ||
|
|
abb2ee0fd7 | ||
|
|
8ee430276c | ||
|
|
29fc991ce1 | ||
|
|
5e6c61cc8e | ||
|
|
6ad1bb9a0d | ||
|
|
7b3f0f764c | ||
|
|
d0ee2c8844 | ||
|
|
8a69038ab5 |
No files matched your search
@@ -19,6 +19,11 @@ When in doubt, do it the way Jarl does. Go module `steward`,
|
||||
result, saves the objects and a history entry in one transaction, then
|
||||
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.
|
||||
|
||||
+76
-2
@@ -6,6 +6,29 @@ All notable changes to Steward are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Allow and deny lists per subnet** (docs/subnets.md): a deny-list
|
||||
subnet (the default) answers everyone not denied; an allow-list subnet
|
||||
only clients allowed or with a reservation. Entries are MAC addresses,
|
||||
prefixes (`aa:bb:cc:*`) or client IDs. A refused client gets no answer
|
||||
(DENIED in the activity log, with an Allow button), or a NAK when it
|
||||
next renews. `steward access`, the subnet's Access tab, `access`,
|
||||
`allow` and `deny` in the API.
|
||||
- **Every option of a DHCP message, in words:** ▸ on an Activity row,
|
||||
`steward activity show N` or `--details`, and `GET /events/{id}`. What
|
||||
the client asked for (its parameter request list, by name), its client
|
||||
ID and host name, and what Steward sent back.
|
||||
|
||||
### Upgrading from 0.1
|
||||
|
||||
- The database gets a new version (7), applied when stewardd starts; a
|
||||
0.1 stewardd can't open it afterwards, so take a backup first (`sudo
|
||||
stewardd backup`). Every subnet stays a deny-list subnet with empty
|
||||
lists, so nothing changes until you add entries.
|
||||
|
||||
## [0.1.0] - 2026-10-18
|
||||
|
||||
The first version of Steward, a DHCPv4 server with a web UI, a REST API
|
||||
and a CLI, built the way Jarl is.
|
||||
|
||||
@@ -80,8 +103,59 @@ 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.
|
||||
- **A reservation's own lease time** (`--lease-time 7d`, the reservation
|
||||
dialog), over the subnet's.
|
||||
- **Reservations by MAC address and client ID together**: either one gets
|
||||
the client its address (a dual-boot machine sends two client IDs from
|
||||
one MAC). The Leases page shows the client ID a client sent, and
|
||||
reserving a lease uses both (`steward lease reserve --by` for one).
|
||||
- **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`;
|
||||
|
||||
@@ -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,13 @@ sudo systemctl disable --now kea-dhcp4-server
|
||||
|
||||
## Status
|
||||
|
||||
Steward 0.1.0 is in development.
|
||||
Version 0.1.0 is the current release: a DHCPv4 server with subnets per
|
||||
interface, reservations (by MAC address, client ID or either, with their
|
||||
own lease time), 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. 0.2.0 is in testing (0.2.0-rc.1):
|
||||
allow and deny lists per subnet, and every option of a DHCP message in
|
||||
words in the activity log.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func accessCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "access",
|
||||
Short: "Who a subnet answers: its allow and deny lists",
|
||||
Long: `A subnet answers every client except those on its deny list (a
|
||||
deny-list subnet, the default), or, as an allow-list subnet, only clients
|
||||
on its allow list or with a reservation in it. The deny list counts in
|
||||
both. A refused client gets no answer (DENIED in 'steward activity'); one
|
||||
that has a lease gets a NAK when it next renews.
|
||||
|
||||
Entries are a MAC address (aa:bb:cc:dd:ee:ff), a prefix for one maker's
|
||||
devices (aa:bb:cc:*) or a client identifier (id 01aabbccddeeff).
|
||||
|
||||
steward access mode iot allow-list
|
||||
steward access allow iot aa:bb:cc:* --comment "Sonos speakers"
|
||||
steward access deny office 3c:22:fb:11:22:33 --comment "old laptop"`,
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show SUBNET", Short: "Show a subnet's access mode and lists", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var s api.Subnet
|
||||
if err := c.Get(ctx(), subnetPath(args[0]), nil, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(s.Subnet, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Access:\t%s\n", accessText(s.Subnet))
|
||||
for _, l := range []struct {
|
||||
name string
|
||||
entries []model.ClientMatch
|
||||
}{{"Allow", s.Allow}, {"Deny", s.Deny}} {
|
||||
if len(l.entries) == 0 {
|
||||
fmt.Fprintf(w, "%s:\t-\n", l.name)
|
||||
}
|
||||
for i, m := range l.entries {
|
||||
label := l.name + ":"
|
||||
if i > 0 {
|
||||
label = ""
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", label, m.Match, m.Comment)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "mode SUBNET deny-list|allow-list", Short: "Answer everyone not denied, or only the allowed", Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return patchAccess(args[0], func(s *model.Subnet, in *api.SubnetInput) error {
|
||||
in.Access = &args[1]
|
||||
return nil
|
||||
})
|
||||
},
|
||||
})
|
||||
var comment string
|
||||
for _, allow := range []bool{true, false} {
|
||||
use, short := "deny SUBNET ENTRY...", "Put clients on a subnet's deny list"
|
||||
if allow {
|
||||
use, short = "allow SUBNET ENTRY...", "Put clients on a subnet's allow list"
|
||||
}
|
||||
c := &cobra.Command{
|
||||
Use: use, Short: short, Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return patchAccess(args[0], func(s *model.Subnet, in *api.SubnetInput) error {
|
||||
list := append([]model.ClientMatch{}, s.Deny...)
|
||||
if allow {
|
||||
list = append([]model.ClientMatch{}, s.Allow...)
|
||||
}
|
||||
entries:
|
||||
for _, a := range args[1:] {
|
||||
m, err := model.ParseClientMatch(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].Match == m {
|
||||
if cmd.Flags().Changed("comment") {
|
||||
list[i].Comment = comment
|
||||
}
|
||||
continue entries
|
||||
}
|
||||
}
|
||||
list = append(list, model.ClientMatch{Match: m, Comment: comment})
|
||||
}
|
||||
if allow {
|
||||
in.Allow = &list
|
||||
} else {
|
||||
in.Deny = &list
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&comment, "comment", "", "what the entry is (\"Sonos speakers\")")
|
||||
cmd.AddCommand(c)
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "remove SUBNET ENTRY...", Short: "Take clients off a subnet's allow and deny lists", Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return patchAccess(args[0], func(s *model.Subnet, in *api.SubnetInput) error {
|
||||
drop := map[string]bool{}
|
||||
for _, a := range args[1:] {
|
||||
m, err := model.ParseClientMatch(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drop[m] = true
|
||||
}
|
||||
allow, deny := []model.ClientMatch{}, []model.ClientMatch{}
|
||||
for _, m := range s.Allow {
|
||||
if !drop[m.Match] {
|
||||
allow = append(allow, m)
|
||||
}
|
||||
}
|
||||
for _, m := range s.Deny {
|
||||
if !drop[m.Match] {
|
||||
deny = append(deny, m)
|
||||
}
|
||||
}
|
||||
if len(allow) == len(s.Allow) && len(deny) == len(s.Deny) {
|
||||
return fmt.Errorf("none of them is on the lists of %s", s.Label())
|
||||
}
|
||||
in.Allow, in.Deny = &allow, &deny
|
||||
return nil
|
||||
})
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// patchAccess changes a subnet's access settings from its current ones.
|
||||
func patchAccess(ref string, edit func(s *model.Subnet, in *api.SubnetInput) error) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cur api.Subnet
|
||||
if err := c.Get(ctx(), subnetPath(ref), nil, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.SubnetInput{}
|
||||
if err := edit(cur.Subnet, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "PATCH", subnetPath(ref), nil, in, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
}
|
||||
|
||||
// accessText sums up who a subnet answers.
|
||||
func accessText(s *model.Subnet) string {
|
||||
t := fmt.Sprintf("deny-list: everyone but %d denied", len(s.Deny))
|
||||
if s.Access == model.AccessAllowList {
|
||||
t = fmt.Sprintf("allow-list: %d allowed, clients with a reservation, and %d denied", len(s.Allow), len(s.Deny))
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -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(¤t, "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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-15
@@ -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)
|
||||
}
|
||||
@@ -123,13 +126,17 @@ gets a NAK when it next renews, and then asks for a new address.`,
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
cmd.AddCommand(del)
|
||||
var hostname, comment string
|
||||
var hostname, comment, by string
|
||||
reserve := &cobra.Command{
|
||||
Use: "reserve IP",
|
||||
Short: "Make a lease's address its client's reservation",
|
||||
Long: `Makes the address a client has now its reservation: it keeps it from
|
||||
then on. The host name the client sent becomes the reservation's, unless
|
||||
--hostname gives another.`,
|
||||
--hostname gives another.
|
||||
|
||||
The reservation knows the client by its MAC address and, if it sent one,
|
||||
its client ID (option 61): either one gets it the address. --by mac or
|
||||
--by client-id uses just one.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -143,6 +150,9 @@ then on. The host name the client sent becomes the reservation's, unless
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
if by != "" {
|
||||
body["by"] = by
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
|
||||
return err
|
||||
@@ -152,35 +162,45 @@ then on. The host name the client sent becomes the reservation's, unless
|
||||
}
|
||||
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
|
||||
reserve.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)")
|
||||
cmd.AddCommand(reserve)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func activityCmd() *cobra.Command {
|
||||
var mac, iface string
|
||||
var mac, subnet, iface string
|
||||
var limit int
|
||||
var follow bool
|
||||
var follow, details bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "activity",
|
||||
Short: "Show recent DHCP messages (requests and answers)",
|
||||
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
|
||||
-f keeps watching. Handy to see why a device doesn't get an address:
|
||||
|
||||
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
|
||||
steward activity --mac aa:bb:cc:dd:ee:01 -f
|
||||
|
||||
--details (-d) shows every option of each message in words: what the
|
||||
client asked for (its parameter request list), its client ID, and what
|
||||
Steward sent back. 'steward activity show N' shows one message (N is the
|
||||
number at the start of its line).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var last time.Time
|
||||
detailsQ := ""
|
||||
if details {
|
||||
detailsQ = "1"
|
||||
}
|
||||
var last uint64
|
||||
tw := func(evs []api.Event) {
|
||||
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
|
||||
sort.Slice(evs, func(i, j int) bool { return evs[i].ID < evs[j].ID })
|
||||
for _, e := range evs {
|
||||
if !e.Time.After(last) {
|
||||
if e.ID <= last {
|
||||
continue
|
||||
}
|
||||
last = e.Time
|
||||
last = e.ID
|
||||
detail := ""
|
||||
if e.Hostname != "" {
|
||||
detail = " " + e.Hostname
|
||||
@@ -188,12 +208,16 @@ func activityCmd() *cobra.Command {
|
||||
if e.Detail != "" {
|
||||
detail += " (" + e.Detail + ")"
|
||||
}
|
||||
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
|
||||
fmt.Printf("%-6s %s %-8s %-13s %-17s %-15s%s\n", fmt.Sprintf("#%d", e.ID), e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
|
||||
if details {
|
||||
printEventDetails(os.Stdout, e, " ")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
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), "details", detailsQ), &evs); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
@@ -212,12 +236,49 @@ 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")
|
||||
f.BoolVarP(&details, "details", "d", false, "show each message's fields and options")
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show N", Short: "Show one DHCP message with all its options", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var e api.Event
|
||||
if err := c.Get(ctx(), "/events/"+strings.TrimPrefix(args[0], "#"), nil, &e); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(e, func(w io.Writer) {
|
||||
dir := map[string]string{"in": "received from the client", "out": "sent by Steward"}[e.Direction]
|
||||
fmt.Fprintf(w, "#%d %s on %s at %s", e.ID, e.Type, e.Interface, e.Time.Local().Format("2006-01-02 15:04:05.000"))
|
||||
if dir != "" {
|
||||
fmt.Fprintf(w, ", %s", dir)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if e.Detail != "" {
|
||||
fmt.Fprintf(w, "%s\n", e.Detail)
|
||||
}
|
||||
printEventDetails(w, e, " ")
|
||||
})
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printEventDetails writes a message's fields and options, one per line.
|
||||
func printEventDetails(w io.Writer, e api.Event, indent string) {
|
||||
for _, f := range e.Fields {
|
||||
fmt.Fprintf(w, "%s%s: %s\n", indent, f.Name, f.Value)
|
||||
}
|
||||
for _, o := range e.Options {
|
||||
fmt.Fprintf(w, "%soption %d %s: %s\n", indent, o.Code, o.Name, o.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func settingsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
@@ -245,10 +306,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 +374,9 @@ reservation's own option of the same name wins.
|
||||
not := !*a
|
||||
in.NotAuthoritative = ¬
|
||||
}
|
||||
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 +401,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)
|
||||
|
||||
+10
-2
@@ -173,9 +173,9 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
root.AddCommand(c)
|
||||
}
|
||||
}
|
||||
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
|
||||
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), accessCmd(), 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 "-"
|
||||
|
||||
+131
-20
@@ -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,8 @@ 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, "Access:\t%s\n", accessText(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 +112,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 +124,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 +154,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 +177,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 +189,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 +221,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 +252,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 +262,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 +302,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,17 +382,24 @@ 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 != "" {
|
||||
who = "id " + r.ClientID
|
||||
who = strings.TrimPrefix(r.MAC+" or id "+r.ClientID, " or ")
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -369,29 +428,63 @@ func reservationCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
|
||||
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
|
||||
lt := "the subnet's"
|
||||
if r.LeaseTime > 0 {
|
||||
lt = fmtSeconds(r.LeaseTime)
|
||||
}
|
||||
fmt.Fprintf(w, "Lease time:\t%s\n", lt)
|
||||
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 hostname, clientID, comment, ip, mac, leaseTime string
|
||||
var opts, unsetOpts []string
|
||||
add := &cobra.Command{
|
||||
Use: "add IP MAC",
|
||||
Short: "Reserve an address for a client",
|
||||
Long: `Reserves an address for a client, known by its MAC address (or by its
|
||||
client identifier: give "-" for the MAC and --client-id). The address picks
|
||||
the subnet. An address inside a pool is fine: it is never handed out to
|
||||
anyone else.
|
||||
Long: `Reserves an address for a client, known by its MAC address, its client
|
||||
identifier (option 61: give "-" for the MAC and --client-id), or both:
|
||||
then either one gets it the address, as for a machine that boots two
|
||||
systems. The address picks the subnet. An address inside a pool is fine:
|
||||
it is never handed out to anyone else.
|
||||
|
||||
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas`,
|
||||
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas --lease-time 7d`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -411,6 +504,13 @@ anyone else.
|
||||
if comment != "" {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if leaseTime != "" {
|
||||
n, err := parseSeconds(leaseTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.LeaseTime = &n
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
o, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
@@ -427,7 +527,8 @@ anyone else.
|
||||
}
|
||||
f := add.Flags()
|
||||
f.StringVar(&hostname, "hostname", "", "the host name to send it")
|
||||
f.StringVar(&clientID, "client-id", "", "match the client identifier (hex) instead of the MAC address")
|
||||
f.StringVar(&clientID, "client-id", "", "the client identifier (hex): with a MAC address, either one matches")
|
||||
f.StringVar(&leaseTime, "lease-time", "", "this client's lease time (12h, 7d; default: the subnet's)")
|
||||
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
@@ -461,6 +562,15 @@ anyone else.
|
||||
if fl.Changed("comment") {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if fl.Changed("lease-time") {
|
||||
n := 0
|
||||
if leaseTime != "default" {
|
||||
if n, err = parseSeconds(leaseTime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in.LeaseTime = &n
|
||||
}
|
||||
if len(opts) > 0 || len(unsetOpts) > 0 {
|
||||
set, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
@@ -484,6 +594,7 @@ anyone else.
|
||||
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
|
||||
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
|
||||
f.StringVar(&hostname, "hostname", "", "a new host name")
|
||||
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 7d, or \"default\": the subnet's)")
|
||||
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
|
||||
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
|
||||
+45
-2
@@ -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
|
||||
|
||||
+38
-10
@@ -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,40 @@ 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'.
|
||||
`access` is `deny-list` (the default, left out) or `allow-list`; `allow`
|
||||
and `deny` replace the lists, each `[{"match": "aa:bb:cc:*", "comment":
|
||||
"Sonos"}]` with a MAC address, a prefix or `"id HEX"`
|
||||
([subnets.md](subnets.md#access-allow-and-deny-lists)).
|
||||
`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", "lease_time", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed (with both, either matches); `lease_time` 0 is the subnet's |
|
||||
| `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) |
|
||||
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment", "by"}`, `by` being `mac`, `client_id` or `both` (the default) |
|
||||
| `GET /events[?mac=&subnet=&interface=&limit=&details=1]` | the latest DHCP messages, newest first (the last 1000 are kept in memory), each with an `id`, `direction` (`in` from the client, `out` from Steward) and `client_id`. `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface. `details=1` adds `fields` and `options` |
|
||||
| `GET /events/{id}` | one message with `fields` (header fields that are set) and `options`, each `{"code", "name", "value"}` in words |
|
||||
|
||||
### History and import
|
||||
|
||||
@@ -124,14 +138,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=]` | |
|
||||
@@ -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.
|
||||
+62
-16
@@ -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,34 +75,69 @@ 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 add IP MAC [--hostname H] [--client-id HEX] [--lease-time 7d] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id`; with both, either matches |
|
||||
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--lease-time 7d\|default] [--option]... [--unset-option]... [--comment]` | |
|
||||
| `reservation enable\|disable RES` | use a reservation, or keep it but ignore it |
|
||||
| `reservation delete RES` | |
|
||||
|
||||
### access
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `access show SUBNET` | the subnet's mode and its allow and deny lists |
|
||||
| `access mode SUBNET deny-list\|allow-list` | answer every client not denied, or only the allowed (and reserved) |
|
||||
| `access allow SUBNET ENTRY... [--comment C]` | add to the allow list (`aa:bb:cc:dd:ee:ff`, `aa:bb:cc:*` or `id HEX`); an entry it has gets the new comment |
|
||||
| `access deny SUBNET ENTRY... [--comment C]` | add to the deny list |
|
||||
| `access remove SUBNET ENTRY...` | take off both lists |
|
||||
|
||||
See [subnets.md](subnets.md#access-allow-and-deny-lists).
|
||||
|
||||
### lease
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
|
||||
| `lease show IP` | one lease |
|
||||
| `lease reserve IP [--hostname H]` | make the address its client's reservation |
|
||||
| `lease reserve IP [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one |
|
||||
| `lease delete IP [-y]` | free the address at once |
|
||||
|
||||
### 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] [-d] [-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
|
||||
11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
|
||||
11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
|
||||
11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
|
||||
#1042 11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
|
||||
#1043 11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
|
||||
#1044 11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
|
||||
#1045 11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
|
||||
```
|
||||
|
||||
Each line starts with the message's number. `-d` (`--details`) adds every
|
||||
field and option of each message in words, and `steward activity show N`
|
||||
shows one: what the client asked for (its parameter request list, by
|
||||
name), its client identifier and host name, and what Steward sent back
|
||||
(lease time, routers, DNS servers...):
|
||||
|
||||
```
|
||||
$ steward activity show 1042
|
||||
#1042 DHCPDISCOVER on eth0 at 2026-10-18 11:02:13.120, received from the client
|
||||
transaction ID: 0x3a1c99f0
|
||||
client hardware address: 3c:22:fb:11:22:33
|
||||
flags: broadcast (the client wants replies broadcast)
|
||||
option 53 message-type: DHCPDISCOVER
|
||||
option 61 client-identifier: 01:3c:22:fb:11:22:33 (type 1: the MAC address 3c:22:fb:11:22:33)
|
||||
option 55 parameter-request-list: subnet-mask (1), routers (3), domain-name-servers (6), domain-name (15), v4-captive-portal (114)
|
||||
option 12 host-name: adams-macbook
|
||||
```
|
||||
|
||||
In the web UI, ▸ on an Activity row opens the same.
|
||||
|
||||
`NO ADDRESS` means the pools on that interface are full, and `CONFLICT`
|
||||
an address that answered the ping check. A `DHCPNAK` says why it was
|
||||
sent.
|
||||
@@ -112,9 +147,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 +164,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 +181,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 +196,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) |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+112
-9
@@ -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
|
||||
|
||||
@@ -55,18 +58,31 @@ steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas
|
||||
steward lease reserve 10.0.10.57 # the address a client has now
|
||||
```
|
||||
|
||||
* **The client** is known by its MAC address, or by its client identifier
|
||||
(option 61) when the reservation has one (`--client-id`; give `-` as the
|
||||
MAC). Some devices change their identifier but not their MAC address,
|
||||
and others the other way round. When unsure, use the MAC address.
|
||||
* **The client** is known by its MAC address, its client identifier
|
||||
(option 61, `--client-id`; give `-` as the MAC for an identifier alone),
|
||||
or both. With both, **either one** gets the client its address: a
|
||||
machine that boots two systems sends different identifiers from one MAC
|
||||
address, and a device with a randomised MAC may keep its identifier.
|
||||
The Leases page and `steward lease show` show the identifier a client
|
||||
sent; `steward lease reserve IP` uses both when it sent one (`--by mac`
|
||||
or `--by client-id` for just one).
|
||||
* **The address picks the subnet**: the subnet whose network holds it. It
|
||||
may be inside a pool or outside one; either way, nobody else gets it.
|
||||
* **The host name** is sent to the client (option 12).
|
||||
* **Options** set on the reservation are for that client only, and win
|
||||
over the subnet's and the settings'.
|
||||
* **A lease time** of its own (`--lease-time 7d`) wins over the subnet's.
|
||||
* **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.
|
||||
@@ -97,7 +113,9 @@ a list, it gets every option set for it.
|
||||
## Lease times
|
||||
|
||||
The settings have the default (12 hours, `steward settings set
|
||||
--lease-time 1d`); a subnet can have its own (`--lease-time 1h`). Clients
|
||||
--lease-time 1d`); a subnet can have its own (`--lease-time 1h`), and a
|
||||
reservation its own over that (`steward reservation update nas
|
||||
--lease-time 7d`). Clients
|
||||
renew halfway through. Short times let changes reach clients sooner;
|
||||
long ones keep clients working longer if Steward is down.
|
||||
|
||||
@@ -110,8 +128,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 +141,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,9 +187,59 @@ 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.
|
||||
|
||||
## Access: allow and deny lists
|
||||
|
||||
A subnet decides who it answers:
|
||||
|
||||
* **deny-list** (the default): every client except those on its deny
|
||||
list;
|
||||
* **allow-list**: only clients on its allow list or with a reservation in
|
||||
it, and not on its deny list.
|
||||
|
||||
The deny list counts in both modes, and wins over a reservation; so an
|
||||
allow-list subnet can allow a maker's devices by prefix and still deny one
|
||||
of them. Switching the mode keeps both lists.
|
||||
|
||||
Entries are a MAC address (`aa:bb:cc:dd:ee:ff`), a prefix for one maker's
|
||||
devices (`aa:bb:cc:*`, 1 to 5 bytes) or a client identifier
|
||||
(`id 01aabbccddeeff`), each with an optional comment:
|
||||
|
||||
```sh
|
||||
steward access mode iot allow-list
|
||||
steward access allow iot aa:bb:cc:* --comment "Sonos speakers"
|
||||
steward access deny office 3c:22:fb:11:22:33 --comment "old laptop"
|
||||
steward access remove office 3c:22:fb:11:22:33
|
||||
steward access show iot
|
||||
```
|
||||
|
||||
In the web UI it is a subnet's **Access** tab; a DENIED line in Activity
|
||||
has an **Allow** button that puts that client on the subnet's allow list.
|
||||
|
||||
A refused client gets **no answer**: its DISCOVER shows as `DENIED` in the
|
||||
activity log, with the reason. A client refused after it got its lease
|
||||
gets a **NAK** when it next renews, so it stops using the address then
|
||||
(within half its lease time).
|
||||
|
||||
This is a control, not security: anyone can copy an allowed MAC address.
|
||||
Phones and laptops also use a **private MAC address** per network by
|
||||
default (iOS, Android, Windows), different from the one on their label;
|
||||
on an allow-list subnet, turn that off for the network on the device, or
|
||||
allow the private address it shows (Activity lists it).
|
||||
|
||||
## Authority
|
||||
|
||||
Steward is **authoritative** for its interfaces. A client that asks to
|
||||
|
||||
@@ -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 |
|
||||
@@ -61,5 +63,21 @@ These apply to options anywhere (settings, subnets, reservations).
|
||||
| `reservation_network_address` | error | the network or broadcast address of its subnet |
|
||||
| `reservation_ip_taken` | error | the address is reserved already |
|
||||
| `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_denied` | warning | a reservation's client is on its subnet's deny list, so it gets no address |
|
||||
| `access_mode` | error | a subnet's access is not `deny-list` or `allow-list` |
|
||||
| `access_entry` | error | an allow or deny list entry that isn't a MAC address, a prefix (`aa:bb:cc:*`) or `id HEX` |
|
||||
| `access_entry_duplicate` | error | an entry is on the same list twice |
|
||||
| `allow_list_empty` | warning | an allow-list subnet with no allow list entries and no reservations: nobody gets an address |
|
||||
| `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 |
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
+100
-16
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
"steward/internal/validate"
|
||||
"steward/internal/version"
|
||||
@@ -30,12 +31,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 +69,30 @@ 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"`
|
||||
// Access is deny-list or allow-list; Allow and Deny replace the
|
||||
// lists (entries: a MAC address, aa:bb:cc:* or "id HEX").
|
||||
Access *string `json:"access,omitempty"`
|
||||
Allow *[]model.ClientMatch `json:"allow,omitempty"`
|
||||
Deny *[]model.ClientMatch `json:"deny,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,7 +102,10 @@ type ReservationInput struct {
|
||||
IP *string `json:"ip,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Options *[]OptionInput `json:"options,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// LeaseTime in seconds; 0 for the subnet's.
|
||||
LeaseTime *int `json:"lease_time,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Subnet is a subnet with its usage.
|
||||
@@ -184,6 +201,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
|
||||
@@ -235,6 +261,18 @@ type Event struct {
|
||||
IP string `json:"ip,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// ID numbers the events since stewardd started.
|
||||
ID uint64 `json:"id"`
|
||||
// Direction is "in" for a client's message, "out" for Steward's
|
||||
// answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS).
|
||||
Direction string `json:"direction,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
// SubnetID is the subnet that refused a client (DENIED).
|
||||
SubnetID int64 `json:"subnet_id,omitempty"`
|
||||
// Fields and Options are the message, in words (GET /events/{id}, or
|
||||
// ?details=1).
|
||||
Fields []dhcp4.Field `json:"fields,omitempty"`
|
||||
Options []dhcp4.Field `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// ImportResult reports a Kea import.
|
||||
@@ -244,3 +282,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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/jarl"
|
||||
)
|
||||
|
||||
// Step and Check are api.CertStep and api.CertCheck.
|
||||
type (
|
||||
Step = api.CertStep
|
||||
Check = api.CertCheck
|
||||
)
|
||||
|
||||
// note records a query and its answer, replacing an earlier answer to
|
||||
// the same question (the checks ask again until the record shows up).
|
||||
func note(s *Step, q string) {
|
||||
cmd, _, _ := strings.Cut(q, " → ")
|
||||
for i, old := range s.Queries {
|
||||
if prev, _, _ := strings.Cut(old, " → "); prev == cmd {
|
||||
s.Queries[i] = q
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Queries = append(s.Queries, q)
|
||||
}
|
||||
|
||||
func add(c *Check, name, status, format string, args ...any) *Step {
|
||||
c.Steps = append(c.Steps, Step{Name: name, Status: status, Detail: fmt.Sprintf(format, args...)})
|
||||
return &c.Steps[len(c.Steps)-1]
|
||||
}
|
||||
|
||||
// How long the check waits for the record to show up, and the name
|
||||
// servers' port (tests change them).
|
||||
var (
|
||||
propagationWait = 90 * time.Second
|
||||
pollEvery = 2 * time.Second
|
||||
nsPort = "53"
|
||||
)
|
||||
|
||||
// Preflight checks, without the CA, that a certificate can be obtained:
|
||||
// Jarl answers and knows the token, a zone holds the challenge name, the
|
||||
// token may write it, a test record can be written, the zone's name
|
||||
// servers and public resolvers serve it, and it can be removed again.
|
||||
func (m *Manager) Preflight(ctx context.Context) *Check {
|
||||
c := &Check{Time: time.Now().UTC(), Domain: m.cfg.ACME.Domain}
|
||||
defer func() {
|
||||
c.OK = true
|
||||
for _, s := range c.Steps {
|
||||
if s.Status == "fail" {
|
||||
c.OK = false
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.lastCheck = c
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
name := jarl.ChallengeName(m.cfg.ACME.Domain)
|
||||
// A CNAME at the challenge name sends the challenge elsewhere (into a
|
||||
// zone kept for challenges); the CA and lego follow it, so the check
|
||||
// does too.
|
||||
if target := m.cname(ctx, name); target != "" {
|
||||
name = target
|
||||
}
|
||||
shown := strings.TrimSuffix(name, ".")
|
||||
|
||||
who, err := m.jarl.Whoami(ctx)
|
||||
if err != nil {
|
||||
add(c, "Jarl", "fail", "%s", explainJarl(err))
|
||||
return c
|
||||
}
|
||||
add(c, "Jarl", "ok", "the token is %s's, on Jarl %s", who.User.Username, who.Build.Label)
|
||||
|
||||
all, err := m.jarl.Zones(ctx)
|
||||
if err != nil {
|
||||
add(c, "Zone", "fail", "listing the zones: %s", explainJarl(err))
|
||||
return c
|
||||
}
|
||||
zones := jarl.ZonesFor(name, all, "")
|
||||
if len(zones) == 0 {
|
||||
add(c, "Zone", "fail", "no primary zone the token can see holds %s: add the zone to Jarl, or give the token's user access to it (zones=...)", shown)
|
||||
return c
|
||||
}
|
||||
zone := strings.TrimSuffix(zones[0].Name, ".")
|
||||
// Every view with the zone gets the record (linked zones through
|
||||
// their link), so name them all.
|
||||
var inViews []jarl.Zone
|
||||
for _, z := range all {
|
||||
if strings.EqualFold(z.Name, zones[0].Name) {
|
||||
inViews = append(inViews, z)
|
||||
}
|
||||
}
|
||||
add(c, "Zone", "ok", "%s%s", zone, viewsText(inViews))
|
||||
|
||||
value := "steward-check-" + auth.RandomString(8)
|
||||
if err := m.jarl.SetTXT(ctx, name, value, zones, true, true, "Steward's certificate check"); err != nil {
|
||||
add(c, "Permission", "fail", "%s", explainWrite(err, zone, shown))
|
||||
return c
|
||||
}
|
||||
add(c, "Permission", "ok", "the token may write TXT records at %s", shown)
|
||||
|
||||
if err := m.jarl.SetTXT(ctx, name, value, zones, true, false, "Steward's certificate check"); err != nil {
|
||||
add(c, "Write", "fail", "writing a test record: %s", explainWrite(err, zone, shown))
|
||||
return c
|
||||
}
|
||||
add(c, "Write", "ok", "wrote TXT %q at %s; Jarl has it in named", value, shown)
|
||||
defer func() {
|
||||
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
if err := m.jarl.SetTXT(clean, name, value, zones, false, false, "Steward's certificate check"); err != nil {
|
||||
add(c, "Clean up", "fail", "removing the test record: %s (remove it in Jarl by hand)", explainJarl(err))
|
||||
return
|
||||
}
|
||||
add(c, "Clean up", "ok", "removed the test record")
|
||||
}()
|
||||
|
||||
authOK := true
|
||||
if m.cfg.ACME.CheckPropagation {
|
||||
authOK = m.checkNameServers(ctx, c, zone, name, value)
|
||||
} else {
|
||||
add(c, "Name servers", "skip", "tls.acme.check_propagation is off")
|
||||
}
|
||||
m.checkResolvers(ctx, c, name, value, authOK && m.cfg.ACME.CheckPropagation)
|
||||
return c
|
||||
}
|
||||
|
||||
func viewsText(zs []jarl.Zone) string {
|
||||
var views []string
|
||||
for _, z := range zs {
|
||||
if z.View != "" {
|
||||
views = append(views, z.View)
|
||||
}
|
||||
}
|
||||
switch len(views) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return " in view " + views[0]
|
||||
}
|
||||
return " in views " + strings.Join(views, ", ")
|
||||
}
|
||||
|
||||
func explainJarl(err error) string {
|
||||
var je *jarl.Error
|
||||
if errors.As(err, &je) && je.Status == 401 {
|
||||
return "Jarl refused the token (" + je.Message + "): check jarl.token or jarl.token_file"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func explainWrite(err error, zone, name string) string {
|
||||
var je *jarl.Error
|
||||
if errors.As(err, &je) && je.Status == 403 {
|
||||
return fmt.Sprintf("the token may not write the challenge record (%s); give it the grant: records:write zones=%s names=%s types=TXT", je.Message, zone, name)
|
||||
}
|
||||
return explainJarl(err)
|
||||
}
|
||||
|
||||
// checkNameServers asks each of the zone's public name servers directly
|
||||
// (as the CA's resolvers will) until all serve the value.
|
||||
func (m *Manager) checkNameServers(ctx context.Context, c *Check, zone, name, value string) bool {
|
||||
st := add(c, "Name servers", "fail", "")
|
||||
ns, q, err := m.lookupNS(ctx, zone)
|
||||
st.Queries = append(st.Queries, q...)
|
||||
if err != nil || len(ns) == 0 {
|
||||
st.Detail = fmt.Sprintf("the public resolvers know no name servers for %s, so the CA can't see it: is the zone delegated to your name servers? (%v)", zone, err)
|
||||
return false
|
||||
}
|
||||
type server struct {
|
||||
host string
|
||||
addrs []string
|
||||
}
|
||||
var servers []server
|
||||
for _, h := range ns {
|
||||
addrs, q := m.lookupAddrs(ctx, h)
|
||||
st.Queries = append(st.Queries, q...)
|
||||
if len(addrs) == 0 {
|
||||
st.Detail = fmt.Sprintf("name server %s has no address the public resolvers know", strings.TrimSuffix(h, "."))
|
||||
return false
|
||||
}
|
||||
servers = append(servers, server{h, addrs})
|
||||
}
|
||||
// A server passes when any of its addresses serves the value (this
|
||||
// machine may not reach its IPv6 address, for one).
|
||||
deadline := time.Now().Add(propagationWait)
|
||||
missing := servers
|
||||
for {
|
||||
var still []server
|
||||
for _, s := range missing {
|
||||
found := false
|
||||
for _, a := range s.addrs {
|
||||
ok, q := m.hasTXT(ctx, net.JoinHostPort(a, nsPort), name, value, false)
|
||||
note(st, q)
|
||||
if ok {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
still = append(still, s)
|
||||
}
|
||||
}
|
||||
missing = still
|
||||
if len(missing) == 0 {
|
||||
st.Status, st.Detail = "ok", fmt.Sprintf("all %d name servers of %s serve it", len(servers), zone)
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
var names []string
|
||||
for _, s := range missing {
|
||||
names = append(names, strings.TrimSuffix(s.host, ".")+" ("+strings.Join(s.addrs, ", ")+")")
|
||||
}
|
||||
st.Detail = fmt.Sprintf("after %s, %s still don't serve it: a secondary that doesn't get the zone's updates (check NOTIFY and transfers), or an address that doesn't reach your name server", propagationWait, strings.Join(names, ", "))
|
||||
return false
|
||||
}
|
||||
sleep(ctx, pollEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// checkResolvers asks the public resolvers until each returns the value.
|
||||
// When the name servers already serve it, a resolver that doesn't is only
|
||||
// a warning: it may be holding an earlier answer.
|
||||
func (m *Manager) checkResolvers(ctx context.Context, c *Check, name, value string, soft bool) {
|
||||
st := add(c, "Public resolvers", "fail", "")
|
||||
if len(m.cfg.ACME.Resolvers) == 0 {
|
||||
st.Status, st.Detail = "skip", "no tls.acme.resolvers"
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(propagationWait)
|
||||
missing := append([]string(nil), m.cfg.ACME.Resolvers...)
|
||||
for {
|
||||
var still []string
|
||||
for _, r := range missing {
|
||||
ok, q := m.hasTXT(ctx, r, name, value, true)
|
||||
note(st, q)
|
||||
if !ok {
|
||||
still = append(still, r)
|
||||
}
|
||||
}
|
||||
missing = still
|
||||
if len(missing) == 0 {
|
||||
st.Status, st.Detail = "ok", "every resolver sees it"
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
st.Detail = fmt.Sprintf("after %s, %s don't see it", propagationWait, strings.Join(missing, ", "))
|
||||
if soft {
|
||||
st.Status = "warn"
|
||||
st.Detail += "; the name servers do, so they are probably holding an earlier answer"
|
||||
}
|
||||
return
|
||||
}
|
||||
sleep(ctx, pollEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
// exchange asks server (HOST:PORT) one question.
|
||||
func exchange(ctx context.Context, server, name string, qtype uint16, recurse bool) (*dns.Msg, string, error) {
|
||||
if _, _, err := net.SplitHostPort(server); err != nil {
|
||||
server = net.JoinHostPort(server, "53")
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||
m.RecursionDesired = recurse
|
||||
host, port, _ := net.SplitHostPort(server)
|
||||
q := fmt.Sprintf("dig @%s -p %s %s %s", host, port, dns.TypeToString[qtype], strings.TrimSuffix(name, "."))
|
||||
if !recurse {
|
||||
q += " +norec"
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
cl := &dns.Client{Timeout: 5 * time.Second}
|
||||
r, _, err := cl.ExchangeContext(c, m, server)
|
||||
if err == nil && r.Truncated {
|
||||
cl.Net = "tcp"
|
||||
r, _, err = cl.ExchangeContext(c, m, server)
|
||||
}
|
||||
return r, q, err
|
||||
}
|
||||
|
||||
func (m *Manager) hasTXT(ctx context.Context, server, name, value string, recurse bool) (bool, string) {
|
||||
r, q, err := exchange(ctx, server, name, dns.TypeTXT, recurse)
|
||||
if err != nil {
|
||||
return false, q + " → " + err.Error()
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if t, ok := rr.(*dns.TXT); ok && strings.Join(t.Txt, "") == value {
|
||||
return true, q + " → found"
|
||||
}
|
||||
}
|
||||
return false, q + " → " + dns.RcodeToString[r.Rcode] + ", not there (yet)"
|
||||
}
|
||||
|
||||
// cname returns the target of a CNAME at name ("" when there is none).
|
||||
func (m *Manager) cname(ctx context.Context, name string) string {
|
||||
for _, res := range m.cfg.ACME.Resolvers {
|
||||
r, _, err := exchange(ctx, res, name, dns.TypeCNAME, true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if c, ok := rr.(*dns.CNAME); ok {
|
||||
return strings.ToLower(c.Target)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lookupNS asks the resolvers for a zone's name servers.
|
||||
func (m *Manager) lookupNS(ctx context.Context, zone string) ([]string, []string, error) {
|
||||
var queries []string
|
||||
var last error = errors.New("no resolvers")
|
||||
for _, res := range m.cfg.ACME.Resolvers {
|
||||
r, q, err := exchange(ctx, res, zone, dns.TypeNS, true)
|
||||
queries = append(queries, q)
|
||||
if err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range r.Answer {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
out = append(out, strings.ToLower(ns.Ns))
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
sort.Strings(out)
|
||||
return out, queries, nil
|
||||
}
|
||||
last = fmt.Errorf("%s answered %s", res, dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
return nil, queries, last
|
||||
}
|
||||
|
||||
// lookupAddrs asks the resolvers for a host's addresses.
|
||||
func (m *Manager) lookupAddrs(ctx context.Context, host string) ([]string, []string) {
|
||||
var queries []string
|
||||
for _, res := range m.cfg.ACME.Resolvers {
|
||||
var out []string
|
||||
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
r, q, err := exchange(ctx, res, host, t, true)
|
||||
queries = append(queries, q)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
switch a := rr.(type) {
|
||||
case *dns.A:
|
||||
out = append(out, a.A.String())
|
||||
case *dns.AAAA:
|
||||
out = append(out, a.AAAA.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, queries
|
||||
}
|
||||
}
|
||||
return nil, queries
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"steward/internal/config"
|
||||
"steward/internal/jarl"
|
||||
)
|
||||
|
||||
// fakeJarl is Jarl's API as far as Steward uses it, and the DNS server
|
||||
// that serves what it writes (both the "public resolver" and the zone's
|
||||
// name server).
|
||||
type fakeJarl struct {
|
||||
mu sync.Mutex
|
||||
txt map[string][]string // name -> values
|
||||
canWrite bool
|
||||
token string
|
||||
served bool // the DNS server serves what is written
|
||||
zones []jarl.Zone
|
||||
writes int
|
||||
dryRuns int
|
||||
nsHasIPv6 bool
|
||||
}
|
||||
|
||||
func (f *fakeJarl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+f.token {
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(`{"error":{"code":"unauthenticated","message":"invalid API token: unknown token"}}`))
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/whoami":
|
||||
w.Write([]byte(`{"user":{"username":"steward"},"build":{"label":"v0.6.1"}}`))
|
||||
case "/api/v1/zones":
|
||||
json.NewEncoder(w).Encode(f.zones)
|
||||
case "/api/v1/apply":
|
||||
var b struct {
|
||||
Ops []struct {
|
||||
Op string `json:"op"`
|
||||
Data map[string]any `json:"data"`
|
||||
} `json:"ops"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
if !f.canWrite {
|
||||
w.WriteHeader(403)
|
||||
w.Write([]byte(`{"error":{"code":"forbidden","message":"permission denied: this needs records:write"}}`))
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if b.DryRun {
|
||||
f.dryRuns++
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
f.writes++
|
||||
for _, op := range b.Ops {
|
||||
name, v := op.Data["name"].(string), op.Data["data"].(string)
|
||||
var keep []string
|
||||
for _, x := range f.txt[name] {
|
||||
if x != v {
|
||||
keep = append(keep, x)
|
||||
}
|
||||
}
|
||||
if op.Op == "record.create" {
|
||||
keep = append(keep, v)
|
||||
}
|
||||
f.txt[name] = keep
|
||||
}
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeJarl) dns(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
q := r.Question[0]
|
||||
hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 60}
|
||||
switch q.Qtype {
|
||||
case dns.TypeNS:
|
||||
if q.Name == "adbraun.test." {
|
||||
hdr.Rrtype = dns.TypeNS
|
||||
m.Answer = append(m.Answer, &dns.NS{Hdr: hdr, Ns: "ns1.adbraun.test."})
|
||||
} else {
|
||||
m.Rcode = dns.RcodeNameError
|
||||
}
|
||||
case dns.TypeA:
|
||||
hdr.Rrtype = dns.TypeA
|
||||
m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("127.0.0.1")})
|
||||
case dns.TypeAAAA:
|
||||
// An IPv6 address nothing answers on (the check must not fail
|
||||
// because of it).
|
||||
if f.nsHasIPv6 {
|
||||
hdr.Rrtype = dns.TypeAAAA
|
||||
m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("100::1")})
|
||||
}
|
||||
case dns.TypeCNAME:
|
||||
if q.Name == "_acme-challenge.dhcp.other.test." {
|
||||
hdr.Rrtype = dns.TypeCNAME
|
||||
m.Answer = append(m.Answer, &dns.CNAME{Hdr: hdr, Target: "dhcp.acme.adbraun.test."})
|
||||
}
|
||||
case dns.TypeTXT:
|
||||
f.mu.Lock()
|
||||
if f.served {
|
||||
for _, v := range f.txt[q.Name] {
|
||||
hdr.Rrtype = dns.TypeTXT
|
||||
m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{v}})
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func setup(t *testing.T, f *fakeJarl, edit func(a *config.ACME)) (*Manager, string) {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(f.dns)}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
hs := httptest.NewServer(f)
|
||||
t.Cleanup(hs.Close)
|
||||
addr := pc.LocalAddr().String()
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
oldPort, oldWait, oldPoll := nsPort, propagationWait, pollEvery
|
||||
nsPort, propagationWait, pollEvery = port, 2*time.Second, 100*time.Millisecond
|
||||
t.Cleanup(func() { nsPort, propagationWait, pollEvery = oldPort, oldWait, oldPoll })
|
||||
a := config.ACME{Domain: "dhcp.adbraun.test", Resolvers: []string{addr}, CheckPropagation: true}
|
||||
if edit != nil {
|
||||
edit(&a)
|
||||
}
|
||||
jc, _ := jarl.New(hs.URL, "steward_good", "", false)
|
||||
m := &Manager{cfg: Config{ACME: a}, jarl: jc, now: time.Now}
|
||||
return m, addr
|
||||
}
|
||||
|
||||
func steps(c *Check) string {
|
||||
var out []string
|
||||
for _, s := range c.Steps {
|
||||
out = append(out, s.Name+"="+s.Status)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func newFake() *fakeJarl {
|
||||
return &fakeJarl{txt: map[string][]string{}, canWrite: true, token: "steward_good", served: true,
|
||||
zones: []jarl.Zone{{Name: "adbraun.test.", View: "internal", LinkGroup: 7}, {Name: "adbraun.test.", View: "external", LinkGroup: 7}, {Name: "other.test.", View: "internal"}}}
|
||||
}
|
||||
|
||||
func TestPreflightOK(t *testing.T) {
|
||||
f := newFake()
|
||||
f.nsHasIPv6 = true
|
||||
m, _ := setup(t, f, nil)
|
||||
c := m.Preflight(context.Background())
|
||||
want := "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=ok Public resolvers=ok Clean up=ok"
|
||||
if !c.OK || steps(c) != want {
|
||||
t.Fatalf("got %s\n%+v", steps(c), c.Steps)
|
||||
}
|
||||
if !strings.Contains(c.Steps[1].Detail, "views internal, external") {
|
||||
t.Errorf("zone step: %s", c.Steps[1].Detail)
|
||||
}
|
||||
// Linked zones are written once; the test record is gone afterwards.
|
||||
if f.dryRuns != 1 || f.writes != 2 || len(f.txt["_acme-challenge.dhcp.adbraun.test."]) != 0 {
|
||||
t.Errorf("dry runs %d writes %d records %v", f.dryRuns, f.writes, f.txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightFailures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(f *fakeJarl, a *config.ACME)
|
||||
want string
|
||||
text string
|
||||
}{
|
||||
{"bad token", func(f *fakeJarl, a *config.ACME) { f.token = "other" }, "Jarl=fail", "check jarl.token"},
|
||||
{"no zone", func(f *fakeJarl, a *config.ACME) { a.Domain = "dhcp.example.net" }, "Jarl=ok Zone=fail", "no primary zone the token can see"},
|
||||
{"no permission", func(f *fakeJarl, a *config.ACME) { f.canWrite = false }, "Jarl=ok Zone=ok Permission=fail",
|
||||
"records:write zones=adbraun.test names=_acme-challenge.dhcp.adbraun.test types=TXT"},
|
||||
{"not served", func(f *fakeJarl, a *config.ACME) { f.served = false },
|
||||
"Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=fail Clean up=ok", "still don't serve it"},
|
||||
{"not delegated", func(f *fakeJarl, a *config.ACME) { f.zones[0].Name, f.zones[1].Name = "test.", "test." },
|
||||
"Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=ok Clean up=ok", "know no name servers for test"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newFake()
|
||||
var am *config.ACME
|
||||
m, _ := setup(t, f, func(a *config.ACME) { am = a })
|
||||
tc.edit(f, am)
|
||||
m.cfg.ACME = *am
|
||||
c := m.Preflight(context.Background())
|
||||
if c.OK || steps(c) != tc.want {
|
||||
t.Fatalf("got %s, want %s\n%+v", steps(c), tc.want, c.Steps)
|
||||
}
|
||||
all := ""
|
||||
for _, s := range c.Steps {
|
||||
all += s.Detail + "\n"
|
||||
}
|
||||
if !strings.Contains(all, tc.text) {
|
||||
t.Errorf("details lack %q:\n%s", tc.text, all)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolversOnlyWarnWhenNameServersServe(t *testing.T) {
|
||||
f := newFake()
|
||||
m, _ := setup(t, f, nil)
|
||||
// The "public resolver" is another server that never sees the record.
|
||||
pc, _ := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
mm := new(dns.Msg)
|
||||
mm.SetReply(r)
|
||||
if r.Question[0].Qtype == dns.TypeTXT {
|
||||
mm.Rcode = dns.RcodeNameError
|
||||
} else {
|
||||
f.dns(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteMsg(mm)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
defer srv.Shutdown()
|
||||
m.cfg.ACME.Resolvers = []string{pc.LocalAddr().String()}
|
||||
c := m.Preflight(context.Background())
|
||||
if !c.OK || !strings.Contains(steps(c), "Public resolvers=warn") {
|
||||
t.Fatalf("got %s %+v", steps(c), c.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightFollowsCNAME(t *testing.T) {
|
||||
f := newFake()
|
||||
m, _ := setup(t, f, func(a *config.ACME) { a.Domain = "dhcp.other.test" })
|
||||
c := m.Preflight(context.Background())
|
||||
if !c.OK || !strings.Contains(c.Steps[2].Detail, "dhcp.acme.adbraun.test") {
|
||||
t.Fatalf("got %s %+v", steps(c), c.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZonesFor(t *testing.T) {
|
||||
zs := []jarl.Zone{{Name: "lol.", View: "a"}, {Name: "adbraun.lol.", View: "internal", LinkGroup: 3}, {Name: "adbraun.lol.", View: "external", LinkGroup: 3},
|
||||
{Name: "adbraun.lol.", View: "guest"}, {Name: "notadbraun.lol.", View: "internal"}}
|
||||
got := jarl.ZonesFor("_acme-challenge.dhcp.adbraun.lol.", zs, "")
|
||||
if len(got) != 2 || got[0].View != "internal" || got[1].View != "guest" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
if n := jarl.ChallengeName("*.Dhcp.adbraun.lol."); n != "_acme-challenge.dhcp.adbraun.lol." {
|
||||
t.Errorf("challenge name %q", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// Package certs obtains and renews the web UI's certificate from an ACME CA
|
||||
// (Let's Encrypt), with Jarl publishing the DNS-01 challenge
|
||||
// (docs/certificates.md). Before every order a preflight check proves the
|
||||
// challenge can be published and seen, so a broken setup never spends the
|
||||
// CA's rate limits.
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
legolog "github.com/go-acme/lego/v4/log"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/config"
|
||||
"steward/internal/jarl"
|
||||
)
|
||||
|
||||
// Store keeps the account and certificate (the database's meta table).
|
||||
type Store interface {
|
||||
GetMeta(ctx context.Context, key string) (string, error)
|
||||
SetMeta(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
const (
|
||||
metaAccount = "acme.account"
|
||||
metaCert = "acme.certificate"
|
||||
)
|
||||
|
||||
// Config configures the manager.
|
||||
type Config struct {
|
||||
ACME config.ACME
|
||||
Version string
|
||||
}
|
||||
|
||||
// Status is api.CertStatus.
|
||||
type Status = api.CertStatus
|
||||
|
||||
// Manager keeps the web UI's certificate.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
jarl *jarl.Client
|
||||
store Store
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate
|
||||
leaf *x509.Certificate
|
||||
issuedBy string
|
||||
working bool
|
||||
lastErr string
|
||||
lastAttempt time.Time
|
||||
nextAttempt time.Time
|
||||
lastCheck *Check
|
||||
failures int
|
||||
force bool // renew now, even if not due (Kick)
|
||||
|
||||
lego sync.Mutex // one ACME order at a time
|
||||
kick chan struct{}
|
||||
}
|
||||
|
||||
// New makes a manager and loads the stored certificate.
|
||||
func New(ctx context.Context, cfg Config, j *jarl.Client, st Store, log *slog.Logger) *Manager {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
// lego logs each step of an order; send it to the journal with the rest.
|
||||
legolog.Logger = slog.NewLogLogger(log.With("component", "acme").Handler(), slog.LevelInfo)
|
||||
m := &Manager{cfg: cfg, jarl: j, store: st, log: log, now: time.Now, kick: make(chan struct{}, 1)}
|
||||
m.load(ctx)
|
||||
return m
|
||||
}
|
||||
|
||||
type stored struct {
|
||||
Domain string `json:"domain"`
|
||||
Directory string `json:"directory"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
Obtained time.Time `json:"obtained"`
|
||||
}
|
||||
|
||||
func (m *Manager) load(ctx context.Context) {
|
||||
raw, err := m.store.GetMeta(ctx, metaCert)
|
||||
if err != nil || raw == "" {
|
||||
return
|
||||
}
|
||||
var s stored
|
||||
if json.Unmarshal([]byte(raw), &s) != nil {
|
||||
return
|
||||
}
|
||||
if err := m.use(s); err != nil {
|
||||
m.log.Warn("the stored certificate can't be used; getting a new one", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// use makes a stored certificate the one served.
|
||||
func (m *Manager) use(s stored) error {
|
||||
c, err := tls.X509KeyPair([]byte(s.CertPEM), []byte(s.KeyPEM))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(c.Certificate[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Leaf = leaf
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// A certificate for another name isn't served.
|
||||
if !strings.EqualFold(s.Domain, m.cfg.ACME.Domain) {
|
||||
m.cert, m.leaf = nil, nil
|
||||
return fmt.Errorf("it is for %s, not %s", s.Domain, m.cfg.ACME.Domain)
|
||||
}
|
||||
m.cert, m.leaf, m.issuedBy = &c, leaf, s.Directory
|
||||
return nil
|
||||
}
|
||||
|
||||
// Certificate is the certificate to serve (nil when there is none yet).
|
||||
func (m *Manager) Certificate() *tls.Certificate {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cert == nil || m.now().After(m.leaf.NotAfter) {
|
||||
return nil
|
||||
}
|
||||
return m.cert
|
||||
}
|
||||
|
||||
// Status describes the certificate.
|
||||
func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st := Status{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), Staging: m.cfg.ACME.Staging(), State: "none",
|
||||
IssuedBy: m.issuedBy, LastError: m.lastErr, Check: m.lastCheck}
|
||||
if m.leaf != nil {
|
||||
nb, na := m.leaf.NotBefore, m.leaf.NotAfter
|
||||
ra := m.renewAt()
|
||||
st.NotBefore, st.NotAfter, st.RenewAt, st.Issuer, st.State = &nb, &na, &ra, m.leaf.Issuer.CommonName, "ok"
|
||||
}
|
||||
if m.lastErr != "" {
|
||||
st.State = "error"
|
||||
}
|
||||
if m.working {
|
||||
st.State = "working"
|
||||
}
|
||||
if !m.lastAttempt.IsZero() {
|
||||
t := m.lastAttempt
|
||||
st.LastAttempt = &t
|
||||
}
|
||||
if !m.nextAttempt.IsZero() {
|
||||
t := m.nextAttempt
|
||||
st.NextAttempt = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// renewAt is when the certificate is renewed: a third of its lifetime
|
||||
// before it ends (30 days for Let's Encrypt's 90), m.mu held.
|
||||
func (m *Manager) renewAt() time.Time {
|
||||
life := m.leaf.NotAfter.Sub(m.leaf.NotBefore)
|
||||
return m.leaf.NotAfter.Add(-life / 3).UTC().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// due reports whether a new certificate is needed: none yet, another
|
||||
// directory configured, or renewal time.
|
||||
func (m *Manager) due() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.force || m.leaf == nil || m.issuedBy != m.cfg.ACME.DirectoryURL() || !m.now().Before(m.renewAt())
|
||||
}
|
||||
|
||||
// Run keeps the certificate: it gets one when needed, checks twice a day
|
||||
// whether it is due, and retries failures with a growing wait.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.kick:
|
||||
case <-timer.C:
|
||||
}
|
||||
wait := 12 * time.Hour
|
||||
if m.due() {
|
||||
if err := m.Renew(ctx); err != nil {
|
||||
m.mu.Lock()
|
||||
m.failures++
|
||||
// 1h, 3h, 6h, then 12h.
|
||||
waits := []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour}
|
||||
wait = waits[min(m.failures, len(waits))-1]
|
||||
m.mu.Unlock()
|
||||
m.log.Warn("getting the web UI's certificate failed; trying again later", "domain", m.cfg.ACME.Domain, "err", err, "retry_in", wait)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.nextAttempt = m.now().Add(wait).UTC()
|
||||
m.mu.Unlock()
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// Kick asks Run to renew now, even if the certificate isn't due.
|
||||
func (m *Manager) Kick() {
|
||||
m.mu.Lock()
|
||||
m.force, m.failures = true, 0
|
||||
m.mu.Unlock()
|
||||
select {
|
||||
case m.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ErrBusy is returned while another order runs.
|
||||
var ErrBusy = errors.New("a certificate order is already running")
|
||||
|
||||
// Renew gets a new certificate now: the preflight check, then the order.
|
||||
func (m *Manager) Renew(ctx context.Context) error {
|
||||
if !m.lego.TryLock() {
|
||||
return ErrBusy
|
||||
}
|
||||
defer m.lego.Unlock()
|
||||
m.mu.Lock()
|
||||
m.working, m.lastAttempt = true, m.now().UTC()
|
||||
m.mu.Unlock()
|
||||
err := m.renew(ctx)
|
||||
m.mu.Lock()
|
||||
m.working, m.force = false, false
|
||||
if err != nil {
|
||||
m.lastErr = err.Error()
|
||||
} else {
|
||||
m.lastErr, m.failures = "", 0
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) renew(ctx context.Context) error {
|
||||
c := m.Preflight(ctx)
|
||||
if !c.OK {
|
||||
for _, s := range c.Steps {
|
||||
if s.Status == "fail" {
|
||||
return fmt.Errorf("the check before asking the CA failed at %s: %s", strings.ToLower(s.Name), s.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.log.Info("the check passed; asking the CA for a certificate", "domain", m.cfg.ACME.Domain, "directory", m.cfg.ACME.DirectoryURL())
|
||||
res, err := m.obtain(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := stored{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), CertPEM: string(res.Certificate), KeyPEM: string(res.PrivateKey), Obtained: m.now().UTC()}
|
||||
if err := m.use(s); err != nil {
|
||||
return fmt.Errorf("the CA's certificate can't be used: %w", err)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
if err := m.store.SetMeta(ctx, metaCert, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
na := m.leaf.NotAfter
|
||||
m.mu.Unlock()
|
||||
m.log.Info("got a new certificate for the web UI", "domain", s.Domain, "until", na.Format(time.RFC3339), "staging", m.cfg.ACME.Staging())
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- ACME
|
||||
|
||||
type account struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
}
|
||||
|
||||
type legoUser struct {
|
||||
email string
|
||||
reg *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *legoUser) GetEmail() string { return u.email }
|
||||
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
|
||||
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
func (m *Manager) httpClient() (*http.Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if f := m.cfg.ACME.CABundle; f != "" {
|
||||
pemData, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tls.acme.ca_bundle: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pemData) {
|
||||
return nil, fmt.Errorf("tls.acme.ca_bundle: no certificates in %s", f)
|
||||
}
|
||||
tr.TLSClientConfig.RootCAs = pool
|
||||
}
|
||||
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
||||
}
|
||||
|
||||
// client returns a lego client for the account, registering it if needed.
|
||||
func (m *Manager) client(ctx context.Context) (*lego.Client, error) {
|
||||
dir := m.cfg.ACME.DirectoryURL()
|
||||
hc, err := m.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &legoUser{email: m.cfg.ACME.Email}
|
||||
var a account
|
||||
if raw, _ := m.store.GetMeta(ctx, metaAccount); raw != "" && json.Unmarshal([]byte(raw), &a) == nil && a.Directory == dir && a.KeyPEM != "" {
|
||||
b, _ := pem.Decode([]byte(a.KeyPEM))
|
||||
if b == nil {
|
||||
return nil, errors.New("the stored ACME account key isn't PEM")
|
||||
}
|
||||
if user.key, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
||||
}
|
||||
if a.URI != "" {
|
||||
user.reg = ®istration.Resource{URI: a.URI}
|
||||
}
|
||||
} else {
|
||||
if user.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cfg := lego.NewConfig(user)
|
||||
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "steward/"+m.cfg.Version
|
||||
cfg.Certificate.Timeout = 90 * time.Second
|
||||
c, err := lego.NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the ACME server %s: %w", dir, err)
|
||||
}
|
||||
if user.reg != nil {
|
||||
return c, nil
|
||||
}
|
||||
if !m.cfg.ACME.AgreeTOS {
|
||||
tos := c.GetToSURL()
|
||||
if tos == "" {
|
||||
tos = "on the CA's website"
|
||||
}
|
||||
return nil, fmt.Errorf("Steward registers an account with %s only after you accept its terms of service (%s): set tls.acme.agree_tos: true in steward.yaml and restart stewardd", dir, tos)
|
||||
}
|
||||
reg, err := c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registering the ACME account: %w", err)
|
||||
}
|
||||
user.reg = reg
|
||||
der, err := x509.MarshalPKCS8PrivateKey(user.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, _ := json.Marshal(account{Directory: dir, Email: user.email, URI: reg.URI,
|
||||
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))})
|
||||
if err := m.store.SetMeta(ctx, metaAccount, string(b)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// provider publishes the challenge through Jarl.
|
||||
type provider struct {
|
||||
m *Manager
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (p *provider) set(domain, keyAuth string, set bool) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
ctx := p.ctx
|
||||
if !set {
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
}
|
||||
all, err := p.m.jarl.Zones(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zones := jarl.ZonesFor(info.EffectiveFQDN, all, "")
|
||||
if len(zones) == 0 {
|
||||
return fmt.Errorf("no primary zone in Jarl holds %s", strings.TrimSuffix(info.EffectiveFQDN, "."))
|
||||
}
|
||||
return p.m.jarl.SetTXT(ctx, info.EffectiveFQDN, info.Value, zones, set, false, "Steward's certificate")
|
||||
}
|
||||
|
||||
func (p *provider) Present(domain, token, keyAuth string) error { return p.set(domain, keyAuth, true) }
|
||||
func (p *provider) CleanUp(domain, token, keyAuth string) error { return p.set(domain, keyAuth, false) }
|
||||
|
||||
// Timeout is how long lego waits for the record to reach the name servers.
|
||||
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
||||
return 5 * time.Minute, 5 * time.Second
|
||||
}
|
||||
|
||||
func (m *Manager) obtain(ctx context.Context) (*certificate.Resource, error) {
|
||||
c, err := m.client(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []dns01.ChallengeOption{dns01.AddDNSTimeout(10 * time.Second)}
|
||||
if len(m.cfg.ACME.Resolvers) > 0 {
|
||||
opts = append(opts, dns01.AddRecursiveNameservers(m.cfg.ACME.Resolvers))
|
||||
}
|
||||
if !m.cfg.ACME.CheckPropagation {
|
||||
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
||||
}
|
||||
if err := c.Challenge.SetDNS01Provider(&provider{m: m, ctx: ctx}, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := c.Certificate.Obtain(certificate.ObtainRequest{Domains: []string{m.cfg.ACME.Domain}, Bundle: true})
|
||||
if err != nil {
|
||||
return nil, cleanLegoError(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// cleanLegoError shortens lego's errors (as Jarl does): "[name] acme:
|
||||
// error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG" becomes
|
||||
// "name: the CA refused (unauthorized): MSG".
|
||||
func cleanLegoError(err error) error {
|
||||
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
|
||||
var out []string
|
||||
for _, line := range strings.Split(msg, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
domain := ""
|
||||
for strings.HasPrefix(line, "[") {
|
||||
end := strings.Index(line, "] ")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
if domain == "" {
|
||||
domain = line[1:end]
|
||||
}
|
||||
line = line[end+2:]
|
||||
}
|
||||
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
|
||||
line = strings.TrimPrefix(line, p)
|
||||
}
|
||||
if mm := problemRe.FindStringSubmatch(line); mm != nil {
|
||||
line = "the CA refused (" + mm[2] + "): " + mm[3]
|
||||
}
|
||||
if domain != "" {
|
||||
line = domain + ": " + line
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.Join(out, "; "))
|
||||
}
|
||||
|
||||
var problemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|
||||
+171
-2
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package dhcp4
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Field is a message's header field or option, in words: for the activity
|
||||
// log's details.
|
||||
type Field struct {
|
||||
// Code is the option's number (0 for a header field).
|
||||
Code int `json:"code,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// protocol are the options the server and clients set themselves, which
|
||||
// the catalog leaves out because they can't be configured.
|
||||
var protocol = map[byte]Def{
|
||||
1: {Name: "subnet-mask", Type: TypeIP},
|
||||
12: {Name: "host-name", Type: TypeString},
|
||||
50: {Name: "requested-address", Type: TypeIP},
|
||||
51: {Name: "lease-time", Type: "seconds"},
|
||||
52: {Name: "option-overload", Type: TypeU8},
|
||||
53: {Name: "message-type", Type: "message-type"},
|
||||
54: {Name: "server-identifier", Type: TypeIP},
|
||||
55: {Name: "parameter-request-list", Type: "codes"},
|
||||
56: {Name: "message", Type: TypeString},
|
||||
57: {Name: "max-message-size", Type: TypeU16},
|
||||
58: {Name: "renewal-time", Type: "seconds"},
|
||||
59: {Name: "rebinding-time", Type: "seconds"},
|
||||
61: {Name: "client-identifier", Type: "client-id"},
|
||||
77: {Name: "user-class", Type: TypeHex},
|
||||
81: {Name: "client-fqdn", Type: "fqdn"},
|
||||
82: {Name: "relay-agent-information", Type: TypeHex},
|
||||
116: {Name: "auto-configure", Type: TypeU8},
|
||||
118: {Name: "subnet-selection", Type: TypeIP},
|
||||
}
|
||||
|
||||
// optionDef is an option's definition for display.
|
||||
func optionDef(code byte) Def {
|
||||
if d, ok := protocol[code]; ok {
|
||||
d.Code = code
|
||||
return d
|
||||
}
|
||||
return ByCode(code)
|
||||
}
|
||||
|
||||
// OptionName is an option's name: "routers", "parameter-request-list",
|
||||
// "option-224".
|
||||
func OptionName(code byte) string { return optionDef(code).Name }
|
||||
|
||||
// DescribeOption is an option's value in words.
|
||||
func DescribeOption(code byte, b []byte) string {
|
||||
d := optionDef(code)
|
||||
switch d.Type {
|
||||
case "seconds":
|
||||
if len(b) == 4 {
|
||||
s := binary.BigEndian.Uint32(b)
|
||||
return fmt.Sprintf("%d s (%s)", s, shortDuration(time.Duration(s)*time.Second))
|
||||
}
|
||||
case "message-type":
|
||||
if len(b) == 1 {
|
||||
return MessageType(b[0]).String()
|
||||
}
|
||||
case "codes":
|
||||
names := make([]string, len(b))
|
||||
for i, c := range b {
|
||||
names[i] = OptionName(c) + " (" + strconv.Itoa(int(c)) + ")"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
case "client-id":
|
||||
hexed := Decode(TypeHex, b)
|
||||
switch {
|
||||
case len(b) == 7 && b[0] == 1:
|
||||
return hexed + " (type 1: the MAC address " + Decode(TypeHex, b[1:]) + ")"
|
||||
case len(b) > 5 && b[0] == 255:
|
||||
return hexed + " (RFC 4361: IAID " + Decode(TypeHex, b[1:5]) + " and a DUID)"
|
||||
}
|
||||
return hexed
|
||||
case "fqdn":
|
||||
// Flags, two obsolete RCODEs, then the name: in DNS wire format
|
||||
// when the E flag is set, else as text.
|
||||
if len(b) >= 3 {
|
||||
name := string(b[3:])
|
||||
if b[0]&0x04 != 0 {
|
||||
if names, err := decodeNames(b[3:]); err == nil && len(names) > 0 {
|
||||
name = names[0]
|
||||
}
|
||||
}
|
||||
var flags []string
|
||||
if b[0]&0x01 != 0 {
|
||||
flags = append(flags, "server updates the A record")
|
||||
}
|
||||
if b[0]&0x08 != 0 {
|
||||
flags = append(flags, "no DNS updates")
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
flags = append(flags, "client updates the A record")
|
||||
}
|
||||
return name + " (" + strings.Join(flags, ", ") + ")"
|
||||
}
|
||||
default:
|
||||
return Decode(d.Type, b)
|
||||
}
|
||||
return Decode(TypeHex, b)
|
||||
}
|
||||
|
||||
// shortDuration writes 43200s as "12h", 90s as "1m30s", a week as "7d".
|
||||
func shortDuration(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return "0s"
|
||||
}
|
||||
var out string
|
||||
for _, u := range []struct {
|
||||
d time.Duration
|
||||
name string
|
||||
}{{24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
|
||||
if n := d / u.d; n > 0 {
|
||||
out += strconv.Itoa(int(n)) + u.name
|
||||
d -= n * u.d
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Describe lists a message's header fields that are set, and its options
|
||||
// in the order they are in the message.
|
||||
func (p *Packet) Describe() (fields, options []Field) {
|
||||
add := func(name, value string) { fields = append(fields, Field{Name: name, Value: value}) }
|
||||
add("transaction ID", fmt.Sprintf("0x%08x", p.XID))
|
||||
add("client hardware address", p.MAC().String())
|
||||
if !IsZero(p.CIAddr) {
|
||||
add("client address (ciaddr)", p.CIAddr.String())
|
||||
}
|
||||
if !IsZero(p.YIAddr) {
|
||||
add("your address (yiaddr)", p.YIAddr.String())
|
||||
}
|
||||
if !IsZero(p.SIAddr) {
|
||||
add("next server (siaddr)", p.SIAddr.String())
|
||||
}
|
||||
if !IsZero(p.GIAddr) {
|
||||
add("relay agent (giaddr)", p.GIAddr.String())
|
||||
}
|
||||
if p.Broadcast() {
|
||||
add("flags", "broadcast (the client wants replies broadcast)")
|
||||
}
|
||||
if p.Secs != 0 {
|
||||
add("seconds since the client began", strconv.Itoa(int(p.Secs)))
|
||||
}
|
||||
if p.Hops != 0 {
|
||||
add("hops", strconv.Itoa(int(p.Hops)))
|
||||
}
|
||||
if p.SName != "" {
|
||||
add("server name (sname)", p.SName)
|
||||
}
|
||||
if p.File != "" {
|
||||
add("boot file (file)", p.File)
|
||||
}
|
||||
for _, o := range p.Options {
|
||||
options = append(options, Field{Code: int(o.Code), Name: OptionName(o.Code), Value: DescribeOption(o.Code, o.Data)})
|
||||
}
|
||||
return fields, options
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dhcp4
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDescribe(t *testing.T) {
|
||||
p := NewRequest(Discover, net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}, 0x1234)
|
||||
p.Set(OptParamRequest, []byte{1, 3, 6, 224})
|
||||
p.Set(OptClientID, []byte{1, 0xaa, 0, 0, 0, 0, 1})
|
||||
p.Set(OptLeaseTime, U32(5400))
|
||||
p.Set(81, []byte{0x05, 0, 0, 6, 'l', 'a', 'p', 't', 'o', 'p', 0})
|
||||
fields, opts := p.Describe()
|
||||
got := map[string]string{}
|
||||
for _, f := range append(fields, opts...) {
|
||||
got[f.Name] = f.Value
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"transaction ID": "0x00001234",
|
||||
"message-type": "DHCPDISCOVER",
|
||||
"parameter-request-list": "subnet-mask (1), routers (3), domain-name-servers (6), option-224 (224)",
|
||||
"client-identifier": "01:aa:00:00:00:00:01 (type 1: the MAC address aa:00:00:00:00:01)",
|
||||
"lease-time": "5400 s (1h30m)",
|
||||
"client-fqdn": "laptop (server updates the A record)",
|
||||
"client hardware address": "aa:00:00:00:00:01",
|
||||
} {
|
||||
if !strings.EqualFold(got[name], want) {
|
||||
t.Errorf("%s: %q, want %q", name, got[name], want)
|
||||
}
|
||||
}
|
||||
if shortDuration(7*24*3600e9) != "7d" || shortDuration(90e9) != "1m30s" || shortDuration(43200e9) != "12h" {
|
||||
t.Error("shortDuration")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -106,6 +109,12 @@ func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
|
||||
return m["mac "+cl.mac]
|
||||
}
|
||||
|
||||
// admits says whether the subnet answers the client (its allow and deny
|
||||
// lists), and if not why.
|
||||
func (c *config) admits(sub *subnet, cl client) (bool, string) {
|
||||
return sub.Admits(cl.mac, cl.id, c.reservationFor(sub.ID, cl) != nil)
|
||||
}
|
||||
|
||||
// subnetFor finds the interface's subnet holding a.
|
||||
func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
|
||||
for _, s := range c.byIface[iface] {
|
||||
@@ -122,13 +131,11 @@ type client struct {
|
||||
id string // client identifier (option 61) in hex, or ""
|
||||
}
|
||||
|
||||
// matches reports whether a reservation is for the client: by client
|
||||
// identifier when the reservation has one, else by MAC address.
|
||||
// matches reports whether the reservation is the client's: by its client
|
||||
// identifier or its MAC address, either one when the reservation has both
|
||||
// (a dual-boot machine sends different client IDs from one MAC address).
|
||||
func (cl client) matches(r *model.Reservation) bool {
|
||||
if r.ClientID != "" {
|
||||
return r.ClientID == cl.id
|
||||
}
|
||||
return r.MAC == cl.mac
|
||||
return r.ClientID != "" && r.ClientID == cl.id || r.MAC != "" && r.MAC == cl.mac
|
||||
}
|
||||
|
||||
func (cl client) String() string {
|
||||
|
||||
+73
-27
@@ -41,7 +41,8 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
|
||||
}
|
||||
cl := client{mac: p.MAC().String(), id: p.ClientID()}
|
||||
t := p.Type()
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName()}
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "in", ClientID: cl.id}
|
||||
ev.Fields, ev.Options = p.Describe()
|
||||
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
|
||||
ev.IP = a.String()
|
||||
} else if !dhcp4.IsZero(p.CIAddr) {
|
||||
@@ -64,7 +65,13 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
|
||||
return nil
|
||||
}
|
||||
if out != nil {
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "out", ClientID: cl.id}
|
||||
ev.Fields, ev.Options = out.Packet.Describe()
|
||||
if out.Dst.IsValid() {
|
||||
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: out.Dst.String() + " (unicast)"})
|
||||
} else {
|
||||
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: "255.255.255.255 (broadcast on " + iface + ")"})
|
||||
}
|
||||
if !dhcp4.IsZero(out.Packet.YIAddr) {
|
||||
ev.IP = out.Packet.YIAddr.String()
|
||||
}
|
||||
@@ -79,6 +86,22 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
|
||||
// ---- DISCOVER
|
||||
|
||||
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
|
||||
// Only the subnets whose access lists let the client in.
|
||||
var allowed []*subnet
|
||||
var refusedBy *subnet
|
||||
why := ""
|
||||
for _, sub := range subs {
|
||||
if ok, reason := cfg.admits(sub, cl); ok {
|
||||
allowed = append(allowed, sub)
|
||||
} else {
|
||||
refusedBy, why = sub, reason
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
s.denied(iface, refusedBy, cl, why)
|
||||
return nil
|
||||
}
|
||||
subs = allowed
|
||||
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
|
||||
for try := 0; try < 8; try++ {
|
||||
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
|
||||
@@ -156,29 +179,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() {
|
||||
@@ -195,8 +213,9 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne
|
||||
if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] {
|
||||
return false
|
||||
}
|
||||
if r := cfg.resByIP[ip]; r != nil {
|
||||
if !cl.matches(r) {
|
||||
res := cfg.resByIP[ip]
|
||||
if res != nil {
|
||||
if !cl.matches(res) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
@@ -216,7 +235,10 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne
|
||||
return false
|
||||
}
|
||||
}
|
||||
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || !l.Owns(cl.mac, cl.id)) {
|
||||
// A lease of the reserved address by the reservation's client under
|
||||
// its other identity (the other OS of a dual-boot machine) is its own.
|
||||
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined ||
|
||||
!l.Owns(cl.mac, cl.id) && !(res != nil && (client{mac: l.MAC, id: l.ClientID}).matches(res))) {
|
||||
return false
|
||||
}
|
||||
if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl {
|
||||
@@ -239,6 +261,11 @@ func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *s
|
||||
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
|
||||
}
|
||||
|
||||
// denied records a client a subnet's access lists refused.
|
||||
func (s *Server) denied(iface string, sub *subnet, cl client, why string) {
|
||||
s.events.add(Event{Time: s.now(), Interface: iface, Type: "DENIED", MAC: cl.mac, ClientID: cl.id, SubnetID: sub.ID, Detail: "no answer: the client is " + why})
|
||||
}
|
||||
|
||||
// ---- REQUEST
|
||||
|
||||
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
|
||||
@@ -278,6 +305,11 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
|
||||
if sub == nil {
|
||||
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
|
||||
}
|
||||
// A client refused since it got its lease: a NAK makes it start over
|
||||
// at once (its DISCOVER then gets no answer).
|
||||
if ok, why := cfg.admits(sub, cl); !ok {
|
||||
return s.nak(cfg, iface, p, sub, "this client is "+why)
|
||||
}
|
||||
now := s.now()
|
||||
own := s.ownAddrs(iface)
|
||||
s.mu.Lock()
|
||||
@@ -290,7 +322,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
|
||||
}
|
||||
@@ -299,8 +333,12 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
|
||||
host = l.Hostname
|
||||
}
|
||||
}
|
||||
res := cfg.reservationFor(sub.ID, cl)
|
||||
if res != nil && res.IP != ip {
|
||||
res = nil
|
||||
}
|
||||
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(leaseTime(sub, res)), Interface: iface, LastSeen: seen}
|
||||
err := s.putLease(ctx, l)
|
||||
delete(s.offers, ip)
|
||||
s.mu.Unlock()
|
||||
@@ -315,10 +353,6 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
|
||||
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
|
||||
return nil
|
||||
}
|
||||
res := cfg.reservationFor(sub.ID, cl)
|
||||
if res != nil && res.IP != ip {
|
||||
res = nil
|
||||
}
|
||||
r := p.Reply(dhcp4.Ack)
|
||||
r.YIAddr = ip
|
||||
r.CIAddr = p.CIAddr
|
||||
@@ -402,6 +436,10 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
|
||||
if sub == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, why := cfg.admits(sub, cl); !ok {
|
||||
s.denied(iface, sub, cl, why)
|
||||
return nil
|
||||
}
|
||||
sid, err := s.serverAddr(iface, sub)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -416,6 +454,14 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
|
||||
return &Outbound{Packet: r, Dst: p.CIAddr}
|
||||
}
|
||||
|
||||
// leaseTime is a lease's length: the reservation's, else the subnet's.
|
||||
func leaseTime(sub *subnet, res *model.Reservation) time.Duration {
|
||||
if res != nil && res.LeaseTime > 0 {
|
||||
return time.Duration(res.LeaseTime) * time.Second
|
||||
}
|
||||
return sub.leaseTime
|
||||
}
|
||||
|
||||
// ---- options
|
||||
|
||||
// Options always sent when configured, even when the client doesn't ask.
|
||||
@@ -428,7 +474,7 @@ var essential = []byte{dhcp4.OptSubnetMask, dhcp4.OptRouters, dhcp4.OptDNSServer
|
||||
func (s *Server) addOptions(r, req *dhcp4.Packet, sub *subnet, res *model.Reservation, sid netip.Addr, lease bool) {
|
||||
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
|
||||
if lease {
|
||||
lt := uint32(sub.leaseTime / time.Second)
|
||||
lt := uint32(leaseTime(sub, res) / time.Second)
|
||||
r.Set(dhcp4.OptLeaseTime, dhcp4.U32(lt))
|
||||
r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2))
|
||||
r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8))
|
||||
|
||||
@@ -2,6 +2,7 @@ package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -115,9 +116,9 @@ func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Optio
|
||||
}
|
||||
|
||||
// lease runs DISCOVER and REQUEST and returns the ACK.
|
||||
func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
|
||||
func (h *harness) lease(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *dhcp4.Packet {
|
||||
h.t.Helper()
|
||||
off := h.discover(iface, m)
|
||||
off := h.discover(iface, m, opts...)
|
||||
if off == nil || off.Packet.Type() != dhcp4.Offer {
|
||||
h.t.Fatalf("no offer for %s on %s", m, iface)
|
||||
}
|
||||
@@ -126,6 +127,9 @@ func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
|
||||
req.Set(dhcp4.OptServerID, dhcp4.IP(sid))
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
|
||||
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
|
||||
for _, o := range opts {
|
||||
req.Set(o.Code, o.Data)
|
||||
}
|
||||
ack := h.send(iface, req)
|
||||
if ack == nil || ack.Packet.Type() != dhcp4.Ack {
|
||||
h.t.Fatalf("no ACK for %s: %+v", m, ack)
|
||||
@@ -444,3 +448,215 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservationLeaseTimeAndClientID(t *testing.T) {
|
||||
h := newHarness(t, func(st *model.State) {
|
||||
st.Reservations[0].LeaseTime = 600
|
||||
st.Reservations[0].ClientID = "ff0102"
|
||||
})
|
||||
u32 := func(p *dhcp4.Packet) uint32 { return binary.BigEndian.Uint32(p.Get(dhcp4.OptLeaseTime)) }
|
||||
id := func(hex ...byte) dhcp4.Option { return dhcp4.Option{Code: dhcp4.OptClientID, Data: hex} }
|
||||
// By its client ID, from any MAC address, with its own lease time.
|
||||
ack := h.lease("eth0", mac(0x50), id(0xff, 1, 2))
|
||||
if ack.YIAddr.String() != "10.0.10.20" || u32(ack) != 600 {
|
||||
t.Errorf("by client id: %s %d", ack.YIAddr, u32(ack))
|
||||
}
|
||||
if l := h.s.Lease(ack.YIAddr); !l.End.Equal(h.clock.Add(10 * time.Minute)) {
|
||||
t.Errorf("lease end %v", l.End)
|
||||
}
|
||||
// By its MAC address with another client ID (the machine's other OS):
|
||||
// the same address, though the first identity's lease is still on.
|
||||
ack = h.lease("eth0", mac(0x99), id(1, 0xaa, 0, 0, 0, 0, 0x99))
|
||||
if ack.YIAddr.String() != "10.0.10.20" {
|
||||
t.Errorf("by MAC: %s", ack.YIAddr)
|
||||
}
|
||||
// Anyone else gets a pool address, with the subnet's lease time.
|
||||
ack = h.lease("eth0", mac(0x51), id(0xff, 9, 9))
|
||||
if ack.YIAddr.String() == "10.0.10.20" || u32(ack) != 43200 {
|
||||
t.Errorf("stranger: %s %d", ack.YIAddr, u32(ack))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLists(t *testing.T) {
|
||||
// A deny-list subnet: everyone but the denied.
|
||||
h := newHarness(t, func(st *model.State) {
|
||||
st.Subnets[0].Deny = []model.ClientMatch{{Match: "aa:00:00:00:00:02"}, {Match: "aa:00:00:00:00:9*"}}
|
||||
})
|
||||
if h.discover("eth0", mac(1)) == nil {
|
||||
t.Error("an unlisted client was refused")
|
||||
}
|
||||
for _, m := range []byte{2, 0x95} {
|
||||
if out := h.discover("eth0", mac(m)); out != nil {
|
||||
t.Errorf("denied %s got %s", mac(m), out.Packet.YIAddr)
|
||||
}
|
||||
}
|
||||
if evs := h.s.Events(1, nil); evs[0].Type != "DENIED" || !strings.Contains(evs[0].Detail, "deny list of office") {
|
||||
t.Errorf("event %+v", evs[0])
|
||||
}
|
||||
// The deny list wins over a reservation (the printer, aa:...:98).
|
||||
if out := h.discover("eth0", mac(0x98)); out != nil {
|
||||
t.Errorf("a denied reserved client got %s", out.Packet.YIAddr)
|
||||
}
|
||||
|
||||
// An allow-list subnet: the allowed, by MAC, prefix or client ID, and
|
||||
// clients with a reservation.
|
||||
h = newHarness(t, func(st *model.State) {
|
||||
st.Subnets[0].Access = model.AccessAllowList
|
||||
st.Subnets[0].Allow = []model.ClientMatch{{Match: "aa:00:00:00:00:01"}, {Match: "id ff07"}}
|
||||
})
|
||||
ip := h.lease("eth0", mac(1)).YIAddr
|
||||
if h.discover("eth0", mac(3)) != nil {
|
||||
t.Error("an unlisted client got an address")
|
||||
}
|
||||
if h.discover("eth0", mac(7), dhcp4.Option{Code: dhcp4.OptClientID, Data: []byte{0xff, 7}}) == nil {
|
||||
t.Error("a client allowed by client ID was refused")
|
||||
}
|
||||
if h.discover("eth0", mac(0x99)) == nil {
|
||||
t.Error("a reserved client was refused")
|
||||
}
|
||||
// Taken off the list: its renewal gets a NAK.
|
||||
st := h.s.cfg.Load().state
|
||||
st.Subnets[0].Allow = st.Subnets[0].Allow[1:]
|
||||
h.s.SetConfig(st)
|
||||
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 9)
|
||||
ren.CIAddr = ip
|
||||
if out := h.send("eth0", ren); out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "allow list") {
|
||||
t.Errorf("renewal of a refused client: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,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 +34,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 +54,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 +86,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
|
||||
@@ -320,6 +325,18 @@ type Event struct {
|
||||
IP string `json:"ip,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// ID numbers the events since stewardd started.
|
||||
ID uint64 `json:"id"`
|
||||
// Direction is "in" for a client's message, "out" for Steward's
|
||||
// answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS).
|
||||
Direction string `json:"direction,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
// SubnetID is the subnet that refused a client (DENIED).
|
||||
SubnetID int64 `json:"subnet_id,omitempty"`
|
||||
// Fields and Options are the message, in words (GET /events/{id}, or
|
||||
// ?details=1).
|
||||
Fields []dhcp4.Field `json:"fields,omitempty"`
|
||||
Options []dhcp4.Field `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type events struct {
|
||||
@@ -327,11 +344,14 @@ type events struct {
|
||||
max int
|
||||
list []Event
|
||||
counts map[string]uint64
|
||||
lastID uint64
|
||||
}
|
||||
|
||||
func (e *events) add(ev Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.lastID++
|
||||
ev.ID = e.lastID
|
||||
e.list = append(e.list, ev)
|
||||
if len(e.list) > e.max {
|
||||
e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...)
|
||||
@@ -342,15 +362,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)
|
||||
@@ -358,6 +378,18 @@ func (s *Server) Events(limit int, mac, iface string) []Event {
|
||||
return out
|
||||
}
|
||||
|
||||
// Event returns one event, with its details.
|
||||
func (s *Server) Event(id uint64) (Event, bool) {
|
||||
s.events.mu.Lock()
|
||||
defer s.events.mu.Unlock()
|
||||
for i := len(s.events.list) - 1; i >= 0; i-- {
|
||||
if s.events.list[i].ID == id {
|
||||
return s.events.list[i], true
|
||||
}
|
||||
}
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
// Counts returns how many messages of each type were seen and sent since
|
||||
// the start.
|
||||
func (s *Server) Counts() map[string]uint64 {
|
||||
|
||||
@@ -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,19 @@ 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 = ""
|
||||
}
|
||||
if s.Access == model.AccessDenyList {
|
||||
s.Access = ""
|
||||
}
|
||||
if len(s.Allow) == 0 {
|
||||
s.Allow = nil
|
||||
}
|
||||
if len(s.Deny) == 0 {
|
||||
s.Deny = nil
|
||||
}
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
r.Options = normOptions(r.Options)
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Subnet access modes: who a subnet answers.
|
||||
const (
|
||||
// AccessDenyList answers every client except those on the deny list
|
||||
// (the default, stored as "").
|
||||
AccessDenyList = "deny-list"
|
||||
// AccessAllowList answers only clients on the allow list or with a
|
||||
// reservation in the subnet, and not on the deny list.
|
||||
AccessAllowList = "allow-list"
|
||||
)
|
||||
|
||||
// ClientMatch is an entry of a subnet's allow or deny list: a MAC address
|
||||
// ("aa:bb:cc:dd:ee:ff"), a MAC address prefix for a maker's devices
|
||||
// ("aa:bb:cc:*"), or a client identifier ("id 01aabbccddeeff").
|
||||
type ClientMatch struct {
|
||||
Match string `json:"match"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ParseClientMatch reads and normalizes an allow or deny list entry.
|
||||
func ParseClientMatch(s string) (string, error) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if rest, ok := strings.CutPrefix(s, "id"); ok && (strings.HasPrefix(rest, " ") || strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, "=")) {
|
||||
id, err := NormalizeClientID(rest[1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "id " + id, nil
|
||||
}
|
||||
if prefix, ok := strings.CutSuffix(s, "*"); ok {
|
||||
prefix = strings.TrimRight(strings.ReplaceAll(prefix, "-", ":"), ":")
|
||||
parts := strings.Split(prefix, ":")
|
||||
if prefix == "" || len(parts) > 5 {
|
||||
return "", fmt.Errorf("%q: a MAC address prefix has 1 to 5 bytes, as aa:bb:cc:*", s)
|
||||
}
|
||||
for i, p := range parts {
|
||||
if len(p) == 1 {
|
||||
p = "0" + p
|
||||
}
|
||||
if len(p) != 2 || strings.Trim(p, "0123456789abcdef") != "" {
|
||||
return "", fmt.Errorf("%q: a MAC address prefix is written as aa:bb:cc:*", s)
|
||||
}
|
||||
parts[i] = p
|
||||
}
|
||||
return strings.Join(parts, ":") + ":*", nil
|
||||
}
|
||||
mac, err := NormalizeMAC(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", s)
|
||||
}
|
||||
return mac, nil
|
||||
}
|
||||
|
||||
// Matches reports whether the entry is the client's: mac as
|
||||
// "aa:bb:cc:dd:ee:ff", id the client identifier in hex ("" for none).
|
||||
func (m ClientMatch) Matches(mac, id string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(m.Match, "id "):
|
||||
return id != "" && m.Match[3:] == id
|
||||
case strings.HasSuffix(m.Match, "*"):
|
||||
return mac != "" && strings.HasPrefix(mac, m.Match[:len(m.Match)-1])
|
||||
}
|
||||
return mac != "" && m.Match == mac
|
||||
}
|
||||
|
||||
// Admits says whether the subnet answers a client, and if not why. resv
|
||||
// tells whether the client has an (enabled) reservation in the subnet.
|
||||
func (s *Subnet) Admits(mac, id string, resv bool) (bool, string) {
|
||||
for _, m := range s.Deny {
|
||||
if m.Matches(mac, id) {
|
||||
why := "on the deny list of " + s.Label()
|
||||
if m.Comment != "" {
|
||||
why += " (" + m.Comment + ")"
|
||||
}
|
||||
return false, why
|
||||
}
|
||||
}
|
||||
if s.Access != AccessAllowList || resv {
|
||||
return true, ""
|
||||
}
|
||||
for _, m := range s.Allow {
|
||||
if m.Matches(mac, id) {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "not on the allow list of " + s.Label()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,9 +140,21 @@ 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"`
|
||||
// Access is who the subnet answers: everyone not on Deny ("", a
|
||||
// deny-list subnet) or, as an allow-list subnet, only clients on
|
||||
// Allow or with a reservation, and not on Deny (access.go).
|
||||
Access string `json:"access,omitempty"`
|
||||
Allow []ClientMatch `json:"allow,omitempty"`
|
||||
Deny []ClientMatch `json:"deny,omitempty"`
|
||||
// Disabled subnets keep their configuration and leases but get no
|
||||
// answers.
|
||||
Disabled bool `json:"disabled"`
|
||||
@@ -122,9 +173,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 +207,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))
|
||||
@@ -188,14 +267,21 @@ func Mask(p netip.Prefix) netip.Addr {
|
||||
// MAC address, or by its client identifier (option 61) when ClientID is
|
||||
// set. The subnet is the one whose prefix holds the address.
|
||||
type Reservation struct {
|
||||
ID int64 `json:"id"`
|
||||
ID int64 `json:"id"`
|
||||
// The client is known by its MAC address, its client identifier
|
||||
// (option 61), or either when both are set.
|
||||
MAC string `json:"mac,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
IP netip.Addr `json:"ip"`
|
||||
// Hostname is sent to the client (option 12).
|
||||
Hostname string `json:"hostname"`
|
||||
Options []OptionValue `json:"options"`
|
||||
Comment string `json:"comment"`
|
||||
// LeaseTime overrides the subnet's lease time (0: inherit).
|
||||
LeaseTime int `json:"lease_time,omitempty"`
|
||||
// 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,32 @@ 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.Access != nil {
|
||||
sn.Access = strings.ToLower(strings.TrimSpace(*in.Access))
|
||||
}
|
||||
for _, l := range []struct {
|
||||
in *[]model.ClientMatch
|
||||
out *[]model.ClientMatch
|
||||
}{{in.Allow, &sn.Allow}, {in.Deny, &sn.Deny}} {
|
||||
if l.in == nil {
|
||||
continue
|
||||
}
|
||||
list := []model.ClientMatch{}
|
||||
for _, m := range *l.in {
|
||||
norm, err := model.ParseClientMatch(m.Match)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
list = append(list, model.ClientMatch{Match: norm, Comment: strings.TrimSpace(m.Comment)})
|
||||
}
|
||||
*l.out = list
|
||||
}
|
||||
if in.LeaseTime != nil {
|
||||
sn.LeaseTime = *in.LeaseTime
|
||||
}
|
||||
@@ -421,6 +460,12 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err
|
||||
}
|
||||
res.Options = opts
|
||||
}
|
||||
if in.LeaseTime != nil {
|
||||
res.LeaseTime = *in.LeaseTime
|
||||
}
|
||||
if in.Disabled != nil {
|
||||
res.Disabled = *in.Disabled
|
||||
}
|
||||
if in.Comment != nil {
|
||||
res.Comment = *in.Comment
|
||||
}
|
||||
@@ -569,6 +614,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 +630,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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,7 +30,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
|
||||
}
|
||||
}
|
||||
@@ -122,14 +123,38 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
var in struct {
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
// By is how the reservation knows the client: mac, client_id or
|
||||
// both (the default when the client sent a client ID).
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
if err := decodeOptional(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
l := s.dhcp.Lease(ip)
|
||||
if l == nil || l.MAC == "" {
|
||||
if l == nil || l.MAC == "" && l.ClientID == "" {
|
||||
return notFound("no lease for %s", ip)
|
||||
}
|
||||
mac, cid := l.MAC, l.ClientID
|
||||
switch strings.ReplaceAll(in.By, "-", "_") {
|
||||
case "", "both":
|
||||
case "mac":
|
||||
cid = ""
|
||||
case "client_id":
|
||||
mac = ""
|
||||
default:
|
||||
return badRequest("by %q: use mac, client_id or both", in.By)
|
||||
}
|
||||
if mac == "" && cid == "" {
|
||||
what := "client ID (option 61); reserve it by mac"
|
||||
if in.By == "mac" {
|
||||
what = "MAC address; reserve it by client_id"
|
||||
}
|
||||
return badRequest("the client of %s sent no %s", ip, what)
|
||||
}
|
||||
who := mac
|
||||
if cid != "" {
|
||||
who = strings.TrimSpace(mac + " client id " + cid)
|
||||
}
|
||||
host := l.Hostname
|
||||
if in.Hostname != nil {
|
||||
host = *in.Hostname
|
||||
@@ -137,9 +162,9 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
host = ""
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "reserve "+ip.String()+" for "+l.MAC, func(st *model.State, newID func() int64) error {
|
||||
return s.change(w, r, p, "reserve "+ip.String()+" for "+who, func(st *model.State, newID func() int64) error {
|
||||
id = newID()
|
||||
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: l.MAC, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
|
||||
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
|
||||
return nil
|
||||
}, func(st *model.State) any {
|
||||
if res := st.ReservationByID(id); res != nil {
|
||||
@@ -153,15 +178,59 @@ 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
|
||||
})
|
||||
details := q.Get("details") == "1"
|
||||
out := make([]api.Event, len(evs))
|
||||
for i, e := range evs {
|
||||
if !details {
|
||||
e.Fields, e.Options = nil, nil
|
||||
}
|
||||
out[i] = api.Event(e)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getEvent: GET /events/{id}, one message with its fields and options.
|
||||
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLeases, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
return badRequest("%q is not an event number", r.PathValue("id"))
|
||||
}
|
||||
e, ok := s.dhcp.Event(id)
|
||||
if !ok {
|
||||
return notFound("no event %d: only the last 1000 are kept, and none from before stewardd started", id)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.Event(e))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- status and audit
|
||||
|
||||
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
@@ -172,6 +241,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{}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
@@ -59,9 +60,13 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
|
||||
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease))
|
||||
m.HandleFunc("GET "+p+"/events", s.authed(s.listEvents))
|
||||
m.HandleFunc("GET "+p+"/events/{id}", s.authed(s.getEvent))
|
||||
// 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)
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -246,6 +248,38 @@ func TestLeasesThroughDHCP(t *testing.T) {
|
||||
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
|
||||
t.Errorf("events %+v", evs)
|
||||
}
|
||||
// Details: on their own, or with ?details=1.
|
||||
if len(evs[0].Options) != 0 {
|
||||
t.Errorf("list with options: %+v", evs[0])
|
||||
}
|
||||
var ev api.Event
|
||||
if code := e.call(e.token, "GET", "/events/"+fmt.Sprint(evs[0].ID), nil, &ev); code != 200 || ev.Direction != "out" || len(ev.Options) == 0 {
|
||||
t.Errorf("event: %d %+v", code, ev)
|
||||
}
|
||||
found := false
|
||||
for _, o := range ev.Options {
|
||||
found = found || o.Name == "lease-time" && o.Value == "43200 s (12h)"
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no lease time in %+v", ev.Options)
|
||||
}
|
||||
e.call(e.token, "GET", "/events?details=1&mac="+mac.String(), nil, &evs)
|
||||
if len(evs[3].Options) == 0 || evs[3].Direction != "in" {
|
||||
t.Errorf("details=1: %+v", evs[3])
|
||||
}
|
||||
if code := e.call(e.token, "GET", "/events/99999", nil, nil); code != 404 {
|
||||
t.Errorf("missing event: %d", code)
|
||||
}
|
||||
// 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 +369,161 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveByClientID(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, nil)
|
||||
get := func(id byte) netip.Addr {
|
||||
m := net.HardwareAddr{0xaa, 0, 0, 0, 0, id}
|
||||
disc := dhcp4.NewRequest(dhcp4.Discover, m, 1)
|
||||
disc.Set(dhcp4.OptClientID, []byte{0xff, id})
|
||||
off := e.dhcp.Handle(context.Background(), "eth0.20", disc)
|
||||
req := dhcp4.NewRequest(dhcp4.Request, m, 2)
|
||||
req.Set(dhcp4.OptClientID, []byte{0xff, id})
|
||||
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1")))
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
|
||||
e.dhcp.Handle(context.Background(), "eth0.20", req)
|
||||
return off.Packet.YIAddr
|
||||
}
|
||||
a, b := get(1), get(2)
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/leases/"+a.String()+"/reserve", map[string]any{}, &mr); code != 201 {
|
||||
t.Fatalf("reserve: %d", code)
|
||||
}
|
||||
var rv api.Reservation
|
||||
json.Unmarshal(mr.Object, &rv)
|
||||
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
|
||||
t.Errorf("both: %+v", rv)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"by": "client_id"}, &mr); code != 201 {
|
||||
t.Fatalf("reserve by id: %d", code)
|
||||
}
|
||||
rv = api.Reservation{}
|
||||
json.Unmarshal(mr.Object, &rv)
|
||||
if rv.MAC != "" || rv.ClientID != "ff02" {
|
||||
t.Errorf("by client id: %+v", rv)
|
||||
}
|
||||
var eb api.ErrorBody
|
||||
if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(10)}, &eb); code != 422 || eb.Error.Problems[0].Code != "lease_time_range" {
|
||||
t.Errorf("short lease time: %d %+v", code, eb)
|
||||
}
|
||||
if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(7 * 86400)}, nil); code != 200 || e.eng.State().ReservationByID(rv.ID).LeaseTime != 7*86400 {
|
||||
t.Errorf("lease time: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessAPI(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Access: ptr("allow-list"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr); code != 201 || len(mr.Warnings) == 0 || mr.Warnings[0].Code != "allow_list_empty" {
|
||||
t.Fatalf("create: %d %+v", code, mr.Warnings)
|
||||
}
|
||||
in := api.SubnetInput{Allow: &[]model.ClientMatch{{Match: "AA-BB-CC-*", Comment: " sonos "}, {Match: "id 01:AA:BB"}}, Deny: &[]model.ClientMatch{{Match: "aabbcc000001"}}}
|
||||
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", in, nil); code != 200 {
|
||||
t.Fatalf("patch: %d", code)
|
||||
}
|
||||
sn := e.eng.State().Subnets[0]
|
||||
if sn.Access != "allow-list" || sn.Allow[0] != (model.ClientMatch{Match: "aa:bb:cc:*", Comment: "sonos"}) || sn.Allow[1].Match != "id 01aabb" || sn.Deny[0].Match != "aa:bb:cc:00:00:01" {
|
||||
t.Errorf("subnet %+v %+v %+v", sn.Access, sn.Allow, sn.Deny)
|
||||
}
|
||||
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Deny: &[]model.ClientMatch{{Match: "nonsense"}}}, nil); code != 400 {
|
||||
t.Errorf("bad entry: %d", code)
|
||||
}
|
||||
var eb api.ErrorBody
|
||||
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("whitelist")}, &eb); code != 422 || eb.Error.Problems[0].Code != "access_mode" {
|
||||
t.Errorf("bad mode: %d %+v", code, eb)
|
||||
}
|
||||
// "deny-list" is the default, stored as "".
|
||||
e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("deny-list")}, nil)
|
||||
if sn = e.eng.State().Subnets[0]; sn.Access != "" || len(sn.Allow) != 2 {
|
||||
t.Errorf("deny-list keeps the allow list: %+v", sn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
|
||||
// connection's first byte and hands the server a *tls.Conn when it is a
|
||||
// TLS handshake (0x16), the plain connection otherwise. That way stewardd
|
||||
// can start serving HTTPS when a certificate turns up (one it obtains,
|
||||
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
|
||||
// a redirect instead of an error.
|
||||
type sniffListener struct {
|
||||
net.Listener
|
||||
cfg *tls.Config
|
||||
ch chan net.Conn
|
||||
errc chan error
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
|
||||
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
|
||||
go l.loop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *sniffListener) loop() {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case l.errc <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
go l.classify(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) classify(c net.Conn) {
|
||||
c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
br := bufio.NewReader(c)
|
||||
b, err := br.Peek(1)
|
||||
c.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
var out net.Conn = &peekedConn{Conn: c, r: br}
|
||||
if b[0] == 0x16 {
|
||||
out = tls.Server(out, l.cfg)
|
||||
}
|
||||
select {
|
||||
case l.ch <- out:
|
||||
case <-l.done:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.ch:
|
||||
return c, nil
|
||||
case err := <-l.errc:
|
||||
return nil, err
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Close() error {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
return l.Listener.Close()
|
||||
}
|
||||
|
||||
// peekedConn reads through the buffer that holds the peeked byte.
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
|
||||
// webCertificate is the certificate stewardd serves: tls.cert_file's, or
|
||||
// the one it obtains itself (tls.acme).
|
||||
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if s.certs != nil {
|
||||
return s.certs.get(hello)
|
||||
}
|
||||
if s.cfg.ACME != nil {
|
||||
if c := s.cfg.ACME.Certificate(); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, errNoCertificate
|
||||
}
|
||||
|
||||
var errNoCertificate = errors.New("no certificate for HTTPS yet")
|
||||
|
||||
// httpsAvailable reports whether stewardd has a certificate to serve.
|
||||
func (s *Server) httpsAvailable() bool {
|
||||
return s.certs != nil || s.cfg.ACME != nil && s.cfg.ACME.Certificate() != nil
|
||||
}
|
||||
|
||||
// requireHTTPS sends plain-HTTP requests to HTTPS once there is a
|
||||
// certificate: browsers (GET and HEAD) get a redirect, anything else an
|
||||
// error, so tokens and passwords aren't sent in the clear. Requests from
|
||||
// loopback (tools on this server) and from trusted proxies (which
|
||||
// terminate TLS themselves) are served as they are.
|
||||
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil || !s.httpsAvailable() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
target := "https://" + r.Host + r.URL.RequestURI()
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
msg := "stewardd serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "https_required", msg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHTTPSAndPlainOnOnePort serves a certificate file: the same port
|
||||
// answers HTTPS, and plain HTTP from loopback.
|
||||
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "dhcp.test"}, DNSNames: []string{"dhcp.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
|
||||
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
os.WriteFile(filepath.Join(dir, "c.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "k.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
||||
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
e := newEnv(t)
|
||||
e.srv.cfg.Listen, e.srv.cfg.TLSCertFile, e.srv.cfg.TLSKeyFile = []string{addr}, filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ready := make(chan struct{})
|
||||
go e.srv.Serve(ctx, func() { close(ready) })
|
||||
<-ready
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(func() *x509.Certificate { c, _ := x509.ParseCertificate(der); return c }())
|
||||
hc := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "dhcp.test"}}}
|
||||
for _, url := range []string{"https://" + addr + "/healthz", "http://" + addr + "/healthz"} {
|
||||
resp, err := hc.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", url, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(b), `"ok":true`) {
|
||||
t.Errorf("%s: %d %s", url, resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+34
-11
@@ -25,16 +25,16 @@ 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, access, allow_list, deny_list, disabled, comment FROM subnets ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
sn := &model.Subnet{}
|
||||
var prefix, pools, opts string
|
||||
var prefix, pools, opts, allow, deny 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, &sn.Access, &allow, &deny, &disabled, &sn.Comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sn.Prefix, _ = netip.ParsePrefix(prefix)
|
||||
@@ -45,12 +45,24 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
|
||||
if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
|
||||
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(allow), &sn.Allow); err != nil {
|
||||
return nil, fmt.Errorf("subnet %d allow list: %w", sn.ID, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(deny), &sn.Deny); err != nil {
|
||||
return nil, fmt.Errorf("subnet %d deny list: %w", sn.ID, err)
|
||||
}
|
||||
if len(sn.Allow) == 0 {
|
||||
sn.Allow = nil
|
||||
}
|
||||
if len(sn.Deny) == 0 {
|
||||
sn.Deny = nil
|
||||
}
|
||||
st.Subnets = append(st.Subnets, sn)
|
||||
}
|
||||
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, lease_time, disabled, comment FROM reservations ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -58,10 +70,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, &r.LeaseTime, &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 +158,12 @@ 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, access, allow_list, deny_list, 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,
|
||||
access = excluded.access, allow_list = excluded.allow_list, deny_list = excluded.deny_list, 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)),
|
||||
sn.Access, jsonText(nonNilMatches(sn.Allow)), jsonText(nonNilMatches(sn.Deny)), boolInt(sn.Disabled), sn.Comment)
|
||||
return err
|
||||
case model.KindReservation:
|
||||
if c.After == nil {
|
||||
@@ -158,10 +174,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, lease_time, 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, lease_time = excluded.lease_time, disabled = excluded.disabled, comment = excluded.comment`,
|
||||
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.LeaseTime, boolInt(r.Disabled), r.Comment)
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unknown kind %q", c.Kind)
|
||||
@@ -174,6 +190,13 @@ func nonNil(p []model.Pool) []model.Pool {
|
||||
return p
|
||||
}
|
||||
|
||||
func nonNilMatches(m []model.ClientMatch) []model.ClientMatch {
|
||||
if m == nil {
|
||||
return []model.ClientMatch{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func nonNilOpts(o []model.OptionValue) []model.OptionValue {
|
||||
if o == nil {
|
||||
return []model.OptionValue{}
|
||||
|
||||
@@ -187,6 +187,35 @@ 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 '';
|
||||
`,
|
||||
// 6: a reservation's own lease time.
|
||||
`
|
||||
ALTER TABLE reservations ADD COLUMN lease_time INTEGER NOT NULL DEFAULT 0;
|
||||
`,
|
||||
// 7: who a subnet answers: its access mode and allow and deny lists.
|
||||
`
|
||||
ALTER TABLE subnets ADD COLUMN access TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE subnets ADD COLUMN allow_list TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE subnets ADD COLUMN deny_list TEXT NOT NULL DEFAULT '[]';
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -161,6 +164,7 @@ func (c *checker) subnets(st *model.State, env *Env) {
|
||||
c.leaseTime(s.LeaseTime, obj)
|
||||
}
|
||||
c.pools(s, obj)
|
||||
c.access(st, s, obj)
|
||||
c.options(s.Options, obj)
|
||||
c.routers(s, st.Settings.Options, obj)
|
||||
if env != nil && !s.Disabled && s.Interface != "" {
|
||||
@@ -173,27 +177,70 @@ 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// access checks a subnet's access mode and lists.
|
||||
func (c *checker) access(st *model.State, s *model.Subnet, obj string) {
|
||||
if s.Access != "" && s.Access != model.AccessDenyList && s.Access != model.AccessAllowList {
|
||||
c.add(Error, "access_mode", obj, "the access %q is not deny-list or allow-list", s.Access)
|
||||
}
|
||||
for _, list := range []struct {
|
||||
name string
|
||||
entries []model.ClientMatch
|
||||
}{{"allow", s.Allow}, {"deny", s.Deny}} {
|
||||
seen := map[string]bool{}
|
||||
for _, m := range list.entries {
|
||||
if norm, err := model.ParseClientMatch(m.Match); err != nil || norm != m.Match {
|
||||
c.add(Error, "access_entry", obj, "the %s list entry %q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", list.name, m.Match)
|
||||
} else if seen[m.Match] {
|
||||
c.add(Error, "access_entry_duplicate", obj, "%s is on the %s list twice", m.Match, list.name)
|
||||
}
|
||||
seen[m.Match] = true
|
||||
}
|
||||
}
|
||||
reserved := 0
|
||||
for _, r := range st.Reservations {
|
||||
if r.Disabled || !s.Prefix.Contains(r.IP) {
|
||||
continue
|
||||
}
|
||||
reserved++
|
||||
if ok, _ := s.Admits(r.MAC, r.ClientID, true); !ok {
|
||||
c.add(Warning, "reservation_denied", obj, "the client of reservation %s is on the deny list: it gets no address", r.Label())
|
||||
}
|
||||
}
|
||||
if s.Access == model.AccessAllowList && len(s.Allow) == 0 && reserved == 0 && !s.Disabled {
|
||||
c.add(Warning, "allow_list_empty", obj, "the subnet answers only clients on its allow list or with a reservation, and it has neither: nobody gets an address")
|
||||
}
|
||||
}
|
||||
|
||||
// routers warns about a gateway outside the subnet: clients can't reach it.
|
||||
func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
|
||||
val := ""
|
||||
@@ -221,7 +268,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 +289,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 +322,29 @@ 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
|
||||
// Either of a client's MAC address and client ID finds its
|
||||
// reservation, so neither may be another's.
|
||||
for _, k := range []key{{s.ID, "mac " + r.MAC}, {s.ID, "id " + r.ClientID}} {
|
||||
if k.id == "mac " || k.id == "id " {
|
||||
continue
|
||||
}
|
||||
if o := byClient[k]; o != nil && o != r {
|
||||
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
|
||||
break
|
||||
}
|
||||
byClient[k] = r
|
||||
}
|
||||
}
|
||||
byIP[r.IP] = r
|
||||
k := key{s.ID, "mac " + r.MAC}
|
||||
if r.ClientID != "" {
|
||||
k.id = "id " + r.ClientID
|
||||
if r.LeaseTime != 0 {
|
||||
c.leaseTime(r.LeaseTime, obj)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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), [
|
||||
|
||||
@@ -1,37 +1,80 @@
|
||||
// Activity: the latest DHCP messages, refreshed every few seconds.
|
||||
import { h, mount, input, select, debounce } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { subnetLabel } from "../widgets.js";
|
||||
|
||||
// allowClient puts a refused client on its subnet's allow list.
|
||||
async function allowClient(e) {
|
||||
try {
|
||||
const s = await get("/subnets/" + e.subnet_id);
|
||||
if ((s.allow || []).some((m) => m.match === e.mac)) { toast(e.mac + " is already allowed on " + subnetLabel(s)); return; }
|
||||
const allow = [...(s.allow || []), { match: e.mac, comment: "" }];
|
||||
await change("PATCH", "/subnets/" + e.subnet_id, { allow });
|
||||
toast(e.mac + " is allowed on " + subnetLabel(s) + "; it gets an address when it next asks");
|
||||
} catch (err) { modal("Not allowed", errorBox(err)); }
|
||||
}
|
||||
|
||||
// details shows a message's header fields and options in words.
|
||||
function details(e) {
|
||||
if (!e) return h("div", { class: "muted small" }, "Loading…");
|
||||
if (e.error) return h("div", { class: "msg err" }, e.error);
|
||||
const who = e.direction === "in" ? "Received from the client" : "Sent by Steward";
|
||||
return h("div", { class: "event-detail" },
|
||||
h("div", { class: "muted small" }, who),
|
||||
h("table", { class: "kv" }, h("tbody", null,
|
||||
e.fields.map((f) => h("tr", null, h("th", null, f.name), h("td", { class: "mono" }, f.value))),
|
||||
(e.options || []).map((o) => h("tr", null, h("th", null, o.name, h("span", { class: "muted small" }, " (" + o.code + ")")),
|
||||
h("td", { class: "mono" }, o.value))))));
|
||||
}
|
||||
|
||||
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");
|
||||
// Messages opened to show their options, kept across refreshes.
|
||||
const open = new Map(); // id -> the event with its details, or null while loading
|
||||
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("thead", null, h("tr", null, ["", "Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, evs.map((e) => {
|
||||
const t = e.type.replace(/^DHCP/, "");
|
||||
return h("tr", null,
|
||||
const row = h("tr", null,
|
||||
h("td", null, e.direction ? h("button", { class: "small", title: open.has(e.id) ? "Hide the options" : "Show every option of this message",
|
||||
onclick: () => toggle(e.id) }, open.has(e.id) ? "▾" : "▸") : ""),
|
||||
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("td", { class: "detail" }, e.detail || "",
|
||||
e.type === "DENIED" && e.subnet_id && e.mac && (e.detail || "").includes("allow list") && can("config", true)
|
||||
? [" ", h("button", { class: "small", title: "Put this client on the subnet's allow list", onclick: () => allowClient(e) }, "Allow")] : null));
|
||||
return open.has(e.id) ? [row, h("tr", { class: "event-details" }, h("td"), h("td", { colspan: 7 }, details(open.get(e.id))))] : row;
|
||||
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : ".")));
|
||||
};
|
||||
const toggle = async (id) => {
|
||||
if (open.has(id)) { open.delete(id); draw(); return; }
|
||||
open.set(id, null);
|
||||
draw();
|
||||
try { open.set(id, await get("/events/" + id)); } catch (e) { open.set(id, { error: e.message }); }
|
||||
draw();
|
||||
};
|
||||
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 },
|
||||
|
||||
@@ -16,14 +16,16 @@ export function leasesTable(leases, done, opts = {}) {
|
||||
const write = can("leases", true), config = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
|
||||
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
|
||||
{ label: "Client", render: (l) => h("span", { class: "mono" }, l.mac || "–",
|
||||
l.client_id ? h("div", { class: "muted small", title: "Client identifier (option 61)" }, "id " + (l.client_id.length > 20 ? l.client_id.slice(0, 18) + "…" : l.client_id)) : null) },
|
||||
{ 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) => [
|
||||
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
|
||||
config && !l.reservation && (l.mac || l.client_id) && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
|
||||
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
|
||||
] },
|
||||
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
|
||||
@@ -35,14 +37,17 @@ function stateBadge(st) {
|
||||
|
||||
async function reserve(l, done) {
|
||||
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
|
||||
const by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both");
|
||||
const err = h("div");
|
||||
await modal("Reserve " + l.ip, h("div", null,
|
||||
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
|
||||
h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host,
|
||||
l.client_id && l.mac ? [h("label", null, "Know it by"), by, h("label"),
|
||||
h("div", { class: "help" }, "It sent the client ID ", h("span", { class: "mono" }, l.client_id), ". With either one, it gets the address whichever system it boots.")] : null), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Reserve", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
|
||||
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -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 { optionsEditor, subnetLabel, fmtSeconds, parseSeconds } 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: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
|
||||
{ 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.mac || "", r.mac && r.client_id ? h("span", { class: "muted small" }, " or ") : "", r.client_id ? "id " + r.client_id : "") },
|
||||
{ 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) {
|
||||
@@ -38,23 +51,30 @@ export async function reservationDialog(r, done, preset = {}) {
|
||||
const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])];
|
||||
const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" });
|
||||
const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" });
|
||||
const cid = input({ value: r ? r.client_id || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" });
|
||||
const cid = input({ value: r ? r.client_id || "" : preset.client_id || "", placeholder: "optional: option 61, in hex", class: "mono" });
|
||||
const lease = input({ value: r && r.lease_time ? fmtSeconds(r.lease_time) : "", placeholder: "the subnet's" });
|
||||
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."),
|
||||
field("MAC address", mac),
|
||||
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
|
||||
field("Client identifier", cid, "Optional: the client's identifier (option 61, hex). With a MAC address too, either one gets the client this address (a machine that boots two systems sends different identifiers)."),
|
||||
field("Lease time", lease, "This client's lease time (12h, 7d); empty for the subnet's."),
|
||||
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 };
|
||||
const lt = parseSeconds(lease.value);
|
||||
if (lt === null) { mount(err, h("div", { class: "msg err" }, "Lease time: write it as 12h, 7d or 30m")); return false; }
|
||||
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), lease_time: lt, 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 +92,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));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// 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, select, field } 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";
|
||||
|
||||
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["settings", "Settings"]];
|
||||
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["access", "Access"], ["settings", "Settings"]];
|
||||
|
||||
export async function render(el, params) {
|
||||
const id = params.id;
|
||||
@@ -19,25 +19,89 @@ 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",
|
||||
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
|
||||
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
|
||||
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",
|
||||
s.access === "allow-list" ? [" · ", badge("allow-list", "accent")] : "")),
|
||||
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);
|
||||
else if (tab === "reservations") await reservationsTab(body, s);
|
||||
else if (tab === "access") accessTab(body, s);
|
||||
else await settingsTab(body, s);
|
||||
}
|
||||
|
||||
// matchList edits an allow or deny list: entries with a comment each.
|
||||
function matchList(entries, placeholder) {
|
||||
const rows = h("div", { class: "pool-rows" });
|
||||
let list = [];
|
||||
const addRow = (m) => {
|
||||
const it = { match: input({ value: m ? m.match : "", placeholder, class: "mono" }), comment: input({ value: m ? m.comment || "" : "", placeholder: "what it is (optional)" }) };
|
||||
it.row = h("div", { class: "pool-row" }, it.match, it.comment,
|
||||
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
|
||||
list.push(it);
|
||||
rows.append(it.row);
|
||||
return it.match;
|
||||
};
|
||||
for (const m of entries || []) addRow(m);
|
||||
const box = h("div", null, rows, h("button", { type: "button", class: "small", onclick: () => addRow(null).focus() }, "+ Add"));
|
||||
box.value = () => list.map((it) => ({ match: it.match.value.trim(), comment: it.comment.value.trim() })).filter((m) => m.match);
|
||||
return box;
|
||||
}
|
||||
|
||||
// accessTab: who the subnet answers.
|
||||
function accessTab(el, s) {
|
||||
const mode = select([["deny-list", "Deny-list: every client except those denied"], ["allow-list", "Allow-list: only clients allowed or with a reservation"]], s.access || "deny-list");
|
||||
const ph = "aa:bb:cc:dd:ee:ff, aa:bb:cc:* or id 01aabb…";
|
||||
const allow = matchList(s.allow, ph), deny = matchList(s.deny, ph);
|
||||
// The allow list only counts in an allow-list subnet.
|
||||
const sync = () => allow.classList.toggle("dimmed", mode.value !== "allow-list");
|
||||
mode.addEventListener("change", sync);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("PATCH", "/subnets/" + s.id, { access: mode.value, allow: allow.value(), deny: deny.value() }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
const form = h("div", { class: "form-grid" },
|
||||
field("Who gets an address", mode),
|
||||
field("Allow list", allow, "Used by an allow-list subnet. Clients with a reservation in the subnet are allowed too."),
|
||||
field("Deny list", deny, "Never answered, in either mode, even with a reservation."));
|
||||
const write = can("config", true);
|
||||
mount(el, h("div", { class: "panel" },
|
||||
h("p", { class: "muted" }, "Entries are a MAC address, a prefix for one maker's devices (aa:bb:cc:*) or a client identifier (id 01aabb…). A refused client gets no answer, and shows as DENIED in Activity; one with a lease gets a NAK when it next renews. Phones use a private MAC address per network unless told not to."),
|
||||
form, err,
|
||||
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check")) : null));
|
||||
if (!write) readOnly(el.querySelector(".panel"));
|
||||
sync();
|
||||
}
|
||||
|
||||
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 +117,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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,10 +342,23 @@ 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); }
|
||||
table.activity tr.event-details > td { white-space: normal; background: var(--bg); }
|
||||
.event-detail table.kv { border-collapse: collapse; margin: 4px 0; }
|
||||
.event-detail table.kv th { text-align: left; font-weight: normal; color: var(--muted); padding: 2px 16px 2px 0; vertical-align: top; white-space: nowrap;
|
||||
text-transform: none; letter-spacing: normal; font-size: inherit; background: none; border: 0; }
|
||||
.event-detail table.kv td { padding: 2px 0; white-space: normal; word-break: break-word; background: none; border: 0; }
|
||||
.type-OFFER, .type-ACK { color: var(--ok); }
|
||||
.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; }
|
||||
|
||||
.dimmed { opacity: 0.55; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,67 @@ 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
|
||||
|
||||
# ---- a reservation's own lease time
|
||||
|
||||
run "reservation lease time" 0 S reservation update $NA.20 --lease-time 2h
|
||||
run "the reserved client gets it" 0 udhcpc a 02:00:00:00:00:20
|
||||
contains "BOUND ip=$NA.20 .*lease=7200"
|
||||
|
||||
# ---- every option of a message
|
||||
|
||||
run "activity with details" 0 S activity --mac 02:00:00:00:00:20 --limit 4 -d
|
||||
contains "parameter-request-list: "
|
||||
contains "option 51 lease-time: 7200 s (2h)"
|
||||
ID=$(S activity --mac 02:00:00:00:00:20 --limit 1 | sed -n 's/^#\([0-9]*\) .*/\1/p' | tail -1)
|
||||
run "one message" 0 S activity show "$ID"
|
||||
contains "DHCPACK on eth0"
|
||||
contains "server-identifier: $NA.2"
|
||||
|
||||
# ---- allow and deny lists
|
||||
|
||||
run "deny a client" 0 S access deny iot 02:00:00:00:00:50 --comment "test"
|
||||
run "a denied client gets nothing" 1 udhcpc b 02:00:00:00:00:50
|
||||
run "activity says it was denied" 0 S activity --mac 02:00:00:00:00:50
|
||||
contains "DENIED.*deny list of iot .*(test)"
|
||||
run "allow-list mode" 0 S access mode iot allow-list
|
||||
run "an unlisted client gets nothing" 1 udhcpc b 02:00:00:00:00:51
|
||||
run "allow by prefix" 0 S access allow iot "02:00:00:00:00:*"
|
||||
run "now it gets an address" 0 udhcpc b 02:00:00:00:00:51
|
||||
contains "BOUND ip=$NB."
|
||||
run "the deny list still wins" 1 udhcpc b 02:00:00:00:00:50
|
||||
run "access shows it all" 0 S access show iot
|
||||
contains "allow-list: 1 allowed"
|
||||
run "back to a deny-list subnet" 0 S access mode iot deny-list
|
||||
run "remove the entries" 0 S access remove iot 02:00:00:00:00:50 "02:00:00:00:00:*"
|
||||
|
||||
# ---- backup
|
||||
|
||||
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup
|
||||
|
||||
Reference in new issue
Block a user