Compare commits
8
Commits
v0.1.0-rc.1
...
dev-0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c31858a3 | ||
|
|
1bc9f9f63a | ||
|
|
c7a64773a7 | ||
|
|
1407f7ab62 | ||
|
|
c995ae819d | ||
|
|
3ad2f981d4 | ||
|
|
b3c8329ae8 | ||
|
|
4fd72998ec |
No files matched your search
@@ -6,6 +6,39 @@ All notable changes to Steward are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Reserve another address from a lease.** The Reserve dialog on the
|
||||
Leases page fills in the lease's address but lets you change it, to
|
||||
any address of the same subnet (`steward lease reserve IP --ip ADDR`,
|
||||
`"ip"` in the API). The client moves there when it next renews.
|
||||
- **Failed logins in the journal.** A failed login and a refused API
|
||||
token are logged as well as audited, with the address first
|
||||
(`msg="login failed" ip=… user=…`, `msg="token refused" ip=…`), for
|
||||
Guard's `steward` preset or a fail2ban filter to ban such addresses
|
||||
(docs/operations.md).
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -124,6 +157,12 @@ and a CLI, built the way Jarl is.
|
||||
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.
|
||||
|
||||
@@ -107,10 +107,14 @@ sudo systemctl disable --now kea-dhcp4-server
|
||||
|
||||
## Status
|
||||
|
||||
Steward 0.1.0 is in testing (0.1.0-rc.1): a DHCPv4 server with subnets per
|
||||
interface, reservations, a web UI, REST API and CLI, Kea import, the web
|
||||
UI's certificate through Jarl, clients' last-seen times and a choice of
|
||||
address order per subnet and pool.
|
||||
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.3):
|
||||
allow and deny lists per subnet, every option of a DHCP message in
|
||||
words in the activity log, failed logins logged for Guard and
|
||||
fail2ban, and reserving a lease at another address of its subnet.
|
||||
|
||||
## 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
|
||||
}
|
||||
+74
-10
@@ -126,13 +126,20 @@ 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, ip 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.
|
||||
|
||||
--ip reserves another address of the same subnet instead; the client
|
||||
moves there when it next renews.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -146,6 +153,12 @@ then on. The host name the client sent becomes the reservation's, unless
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
if by != "" {
|
||||
body["by"] = by
|
||||
}
|
||||
if ip != "" {
|
||||
body["ip"] = ip
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
|
||||
return err
|
||||
@@ -155,6 +168,8 @@ 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(&ip, "ip", "", "reserve this address of the lease's subnet instead")
|
||||
reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)")
|
||||
cmd.AddCommand(reserve)
|
||||
return cmd
|
||||
}
|
||||
@@ -162,28 +177,37 @@ then on. The host name the client sent becomes the reservation's, unless
|
||||
func activityCmd() *cobra.Command {
|
||||
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
|
||||
@@ -191,12 +215,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, "subnet", subnet, "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" {
|
||||
@@ -219,9 +247,45 @@ func activityCmd() *cobra.Command {
|
||||
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{
|
||||
|
||||
+1
-1
@@ -173,7 +173,7 @@ 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(), certCmd(), auditCmd(), configCmd())
|
||||
return root
|
||||
|
||||
+33
-8
@@ -93,6 +93,7 @@ func subnetCmd() *cobra.Command {
|
||||
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"
|
||||
@@ -385,7 +386,7 @@ func reservationCmd() *cobra.Command {
|
||||
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 {
|
||||
@@ -427,6 +428,11 @@ 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, on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
|
||||
if r.Lease.LastSeen != nil {
|
||||
@@ -467,17 +473,18 @@ func reservationCmd() *cobra.Command {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -497,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 {
|
||||
@@ -513,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)
|
||||
@@ -547,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 {
|
||||
@@ -570,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")
|
||||
|
||||
+8
-3
@@ -99,6 +99,10 @@ Creating answers 201, other changes 200.
|
||||
"options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""}
|
||||
```
|
||||
|
||||
`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
|
||||
@@ -111,7 +115,7 @@ out. `lease_time` 0 means the settings'.
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` |
|
||||
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "disabled", "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
|
||||
@@ -121,8 +125,9 @@ out. `lease_time` 0 means the settings'.
|
||||
| `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=&subnet=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory). `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface |
|
||||
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"ip", "hostname", "comment", "by"}`, `ip` another address of the lease's subnet to reserve instead, `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
|
||||
|
||||
|
||||
+40
-8
@@ -77,35 +77,67 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
|
||||
|---|---|
|
||||
| `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 [--ip ADDR] [--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; `--ip` reserves another address of the same subnet instead |
|
||||
| `lease delete IP [-y]` | free the address at once |
|
||||
|
||||
### activity
|
||||
|
||||
`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`:
|
||||
`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.
|
||||
|
||||
@@ -69,6 +69,22 @@ clients renewing them get a NAK and ask again.
|
||||
database when it starts, and an older one refuses a database a newer one
|
||||
has upgraded: restore a backup to go back.
|
||||
|
||||
## Failed logins in the journal
|
||||
|
||||
A failed login to the web UI or the API, and a refused API token, are
|
||||
written to the journal as well as the audit log, with the address first:
|
||||
|
||||
```
|
||||
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
|
||||
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
|
||||
```
|
||||
|
||||
Guard's `steward` preset, or a fail2ban filter, can ban such addresses at
|
||||
the firewall (with Housecarl's ban lists). The address comes before the
|
||||
user name, which a caller chooses, so a pattern anchored on
|
||||
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
|
||||
json` they are JSON objects with the same fields in the same order.
|
||||
|
||||
## Checks
|
||||
|
||||
`stewardd check` validates the configuration in the database against
|
||||
|
||||
+52
-5
@@ -56,17 +56,23 @@ A reservation fixes a client's address:
|
||||
```sh
|
||||
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
|
||||
steward lease reserve 10.0.10.57 --ip 10.0.10.21 # its client, another address
|
||||
```
|
||||
|
||||
* **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.
|
||||
|
||||
@@ -108,7 +114,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.
|
||||
|
||||
@@ -194,6 +202,45 @@ 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
|
||||
|
||||
@@ -63,6 +63,11 @@ 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 |
|
||||
|
||||
|
||||
+28
-8
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
"steward/internal/validate"
|
||||
"steward/internal/version"
|
||||
@@ -81,12 +82,17 @@ type SubnetInput struct {
|
||||
Pools *[]string `json:"pools,omitempty"`
|
||||
// PoolOrder is listed or random; AddressOrder sequential, reverse,
|
||||
// random or "" (the settings').
|
||||
PoolOrder *string `json:"pool_order,omitempty"`
|
||||
AddressOrder *string `json:"address_order,omitempty"`
|
||||
LeaseTime *int `json:"lease_time,omitempty"`
|
||||
Options *[]OptionInput `json:"options,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
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).
|
||||
@@ -96,8 +102,10 @@ type ReservationInput struct {
|
||||
IP *string `json:"ip,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Options *[]OptionInput `json:"options,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// 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.
|
||||
@@ -253,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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -109,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] {
|
||||
@@ -125,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 {
|
||||
|
||||
+60
-11
@@ -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)
|
||||
@@ -190,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 {
|
||||
@@ -211,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 {
|
||||
@@ -234,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 {
|
||||
@@ -273,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()
|
||||
@@ -296,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, LastSeen: seen}
|
||||
Start: start, End: now.Add(leaseTime(sub, res)), Interface: iface, LastSeen: seen}
|
||||
err := s.putLease(ctx, l)
|
||||
delete(s.offers, ip)
|
||||
s.mu.Unlock()
|
||||
@@ -312,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
|
||||
@@ -399,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
|
||||
@@ -413,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.
|
||||
@@ -425,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)
|
||||
@@ -580,3 +584,79 @@ func TestAddressOrder(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
@@ -324,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 {
|
||||
@@ -331,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:]...)
|
||||
@@ -362,6 +378,18 @@ func (s *Server) Events(limit int, keep func(Event) bool) []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 {
|
||||
|
||||
@@ -436,6 +436,15 @@ func Normalize(st *model.State) {
|
||||
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,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()
|
||||
}
|
||||
@@ -149,6 +149,12 @@ type Subnet struct {
|
||||
// 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"`
|
||||
@@ -261,13 +267,17 @@ 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"`
|
||||
// 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"`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"steward/internal/api"
|
||||
)
|
||||
|
||||
// TestFailedLoginLogged: a failed login and a refused token are logged
|
||||
// with the address first, for Guard's and fail2ban's patterns.
|
||||
func TestFailedLoginLogged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var buf bytes.Buffer
|
||||
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
|
||||
e.call("", "POST", "/auth/login", api.Login{Username: "x ip=8.8.8.8", Password: "wrong"}, nil)
|
||||
e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil)
|
||||
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("no %q in\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
@@ -246,6 +248,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
|
||||
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,26 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
|
||||
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
|
||||
}
|
||||
@@ -440,6 +460,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -113,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
return nil
|
||||
}
|
||||
|
||||
// reserveLease makes a lease's address the client's reservation.
|
||||
// reserveLease makes a lease's address (or another in its subnet) the
|
||||
// client's reservation.
|
||||
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
ip, err := leaseIP(r)
|
||||
if err != nil {
|
||||
@@ -122,24 +124,65 @@ 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"`
|
||||
// IP reserves another address of the lease's subnet instead.
|
||||
IP string `json:"ip,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
|
||||
} else if !validate.ValidHostname(host) {
|
||||
host = ""
|
||||
}
|
||||
want := ip
|
||||
if in.IP != "" {
|
||||
if want, err = netip.ParseAddr(strings.TrimSpace(in.IP)); err != nil || !want.Is4() {
|
||||
return badRequest("ip %q: not an IPv4 address", in.IP)
|
||||
}
|
||||
}
|
||||
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 "+want.String()+" for "+who, func(st *model.State, newID func() int64) error {
|
||||
if want != ip {
|
||||
if sub := st.SubnetFor(ip); sub == nil || !sub.Prefix.Contains(want) {
|
||||
where := "the lease's subnet"
|
||||
if sub != nil {
|
||||
where = sub.Prefix.String()
|
||||
}
|
||||
return badRequest("%s isn't in %s", want, where)
|
||||
}
|
||||
}
|
||||
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: want, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
|
||||
return nil
|
||||
}, func(st *model.State) any {
|
||||
if res := st.ReservationByID(id); res != nil {
|
||||
@@ -177,14 +220,35 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -60,6 +60,7 @@ 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))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -247,6 +248,28 @@ 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} {
|
||||
@@ -434,3 +457,77 @@ func TestAddressOrderAPI(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
var eb0 api.ErrorBody
|
||||
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.10.30"}, &eb0); code != 400 {
|
||||
t.Errorf("reserve outside the subnet: %d %+v", code, eb0)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.20.200", "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" || rv.IP.String() != "10.0.20.200" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+32
-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, pool_order, address_order, 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.PoolOrder, &sn.AddressOrder, &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, disabled, 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
|
||||
}
|
||||
@@ -59,7 +71,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
|
||||
r := &model.Reservation{}
|
||||
var ip, opts string
|
||||
var disabled int
|
||||
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &disabled, &r.Comment); err != nil {
|
||||
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)
|
||||
@@ -146,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, pool_order, address_order, 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,
|
||||
pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
|
||||
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
|
||||
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 {
|
||||
@@ -160,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, disabled, 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, disabled = excluded.disabled, comment = excluded.comment`,
|
||||
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), boolInt(r.Disabled), r.Comment)
|
||||
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)
|
||||
@@ -176,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{}
|
||||
|
||||
@@ -206,6 +206,16 @@ ALTER TABLE leases ADD COLUMN last_seen TEXT;
|
||||
`
|
||||
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 '[]';
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -164,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 != "" {
|
||||
@@ -206,6 +207,40 @@ func (c *checker) pools(s *model.Subnet, obj string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := ""
|
||||
@@ -294,14 +329,21 @@ func (c *checker) reservations(st *model.State, env *Env) {
|
||||
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
|
||||
}
|
||||
byIP[r.IP] = r
|
||||
k := key{s.ID, "mac " + r.MAC}
|
||||
if r.ClientID != "" {
|
||||
k.id = "id " + r.ClientID
|
||||
// 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
|
||||
}
|
||||
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.LeaseTime != 0 {
|
||||
c.leaseTime(r.LeaseTime, obj)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
// 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, 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(), 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)),
|
||||
@@ -27,9 +55,19 @@ export async function render(el, params, query) {
|
||||
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("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);
|
||||
|
||||
@@ -16,7 +16,8 @@ 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) },
|
||||
@@ -24,7 +25,7 @@ export function leasesTable(leases, done, opts = {}) {
|
||||
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,15 +36,21 @@ function stateBadge(st) {
|
||||
}
|
||||
|
||||
async function reserve(l, done) {
|
||||
const ip = input({ value: l.ip, class: "mono" });
|
||||
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 this address from now on."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Address"), ip, h("label"),
|
||||
h("div", { class: "help" }, "Another address of the same subnet moves the client there when it next renews."),
|
||||
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", { ip: ip.value.trim(), hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Reservations: fixed addresses for clients.
|
||||
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.
|
||||
@@ -9,7 +9,7 @@ export function reservationsTable(rs, done, opts = {}) {
|
||||
const write = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] },
|
||||
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
|
||||
{ label: "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
|
||||
@@ -51,7 +51,8 @@ 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 : "" });
|
||||
@@ -60,7 +61,8 @@ export async function reservationDialog(r, done, preset = {}) {
|
||||
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),
|
||||
@@ -69,7 +71,9 @@ export async function reservationDialog(r, done, preset = {}) {
|
||||
{ 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);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// A subnet's page: its leases, its reservations and its settings.
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } 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, 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;
|
||||
@@ -21,7 +21,8 @@ export async function render(el, params) {
|
||||
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
|
||||
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")),
|
||||
" · 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",
|
||||
@@ -31,9 +32,59 @@ export async function render(el, params) {
|
||||
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 {
|
||||
|
||||
@@ -346,6 +346,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
|
||||
/* 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); }
|
||||
@@ -355,3 +360,5 @@ input:disabled, select:disabled, textarea:disabled { background: var(--info-soft
|
||||
.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; }
|
||||
@@ -234,6 +234,39 @@ 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