diff --git a/CHANGELOG.md b/CHANGELOG.md index ca244be..f6132b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,16 @@ and a CLI, built the way Jarl is. - **Disabled reservations** (`steward reservation disable|enable`, Disable in the web UI): kept but ignored, so the client gets a pool address like any other. Subnets get Disable/Enable buttons in the web UI too. +- **Hidden interfaces:** `interfaces.exclude` in `steward.yaml` (names or + shell patterns such as `docker*`) leaves interfaces out of the web UI's + interface picker, `steward interface list` and `GET /interfaces`. +- **Last seen:** with the ping check on, active leases' addresses are + pinged every minute, and a client that answers gets a last-seen time + (Leases page, reservations' online badge, `steward lease list|show`, + `steward reservation list|show`, `last_seen` in the API). A client that + blocks pings stays online for as long as its lease lasts, without one. +- **Activity by subnet:** `steward activity --subnet`, a subnet filter on + the Activity page, `GET /events?subnet=` (ID, name or prefix). - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`, Rotate in the web UI): a new secret for a token, with an optional grace period for the old one, as in Jarl. diff --git a/cmd/steward/leases.go b/cmd/steward/leases.go index 3ed5331..e8c0fce 100644 --- a/cmd/steward/leases.go +++ b/cmd/steward/leases.go @@ -51,13 +51,13 @@ func leaseCmd() *cobra.Command { ls = keep } return output(ls, func(w io.Writer) { - fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tINTERFACE\tSUBNET") + fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tLAST SEEN\tINTERFACE\tSUBNET") for _, l := range ls { st := l.State if l.Reservation != 0 { st += ", reserved" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), l.Interface, orDash(l.SubnetName)) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), fmtSeen(l.LastSeen), l.Interface, orDash(l.SubnetName)) } }) }, @@ -91,6 +91,9 @@ func leaseCmd() *cobra.Command { fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05")) fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End)) fmt.Fprintf(w, "Interface:\t%s\n", l.Interface) + if l.LastSeen != nil { + fmt.Fprintf(w, "Last seen:\t%s (%s, answered a ping)\n", l.LastSeen.Local().Format("2006-01-02 15:04:05"), fmtWhen(*l.LastSeen)) + } if l.Reservation != 0 { fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation) } @@ -157,7 +160,7 @@ then on. The host name the client sent becomes the reservation's, unless } func activityCmd() *cobra.Command { - var mac, iface string + var mac, subnet, iface string var limit int var follow bool cmd := &cobra.Command{ @@ -193,7 +196,7 @@ func activityCmd() *cobra.Command { } for { var evs []api.Event - if err := c.Get(ctx(), "/events", q("mac", mac, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil { + if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil { return err } if flagOutput == "json" || flagOutput == "yaml" { @@ -212,6 +215,7 @@ func activityCmd() *cobra.Command { } f := cmd.Flags() f.StringVar(&mac, "mac", "", "only this client's") + f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix); messages without an address, such as DISCOVERs, show for every subnet on their interface") f.StringVar(&iface, "interface", "", "only this interface's") f.IntVar(&limit, "limit", 50, "at most this many") f.BoolVarP(&follow, "follow", "f", false, "keep watching") @@ -336,7 +340,7 @@ reservation's own option of the same name wins. } f := set.Flags() f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)") - f.StringVar(&ping, "ping-check", "", "ping addresses before offering them: on or off") + f.StringVar(&ping, "ping-check", "", "ping addresses before offering them, and clients every minute for their last-seen time: on or off") f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)") f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)") f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off") diff --git a/cmd/steward/main.go b/cmd/steward/main.go index 41e7e4b..018ac1c 100644 --- a/cmd/steward/main.go +++ b/cmd/steward/main.go @@ -424,6 +424,14 @@ func fmtWhen(t time.Time) string { return "in " + s } +// fmtSeen is a last-seen time, or "-" for a client never seen. +func fmtSeen(t *time.Time) string { + if t == nil { + return "-" + } + return fmtWhen(*t) +} + func orDash(s string) string { if s == "" { return "-" diff --git a/cmd/steward/subnets.go b/cmd/steward/subnets.go index 06d9cae..eecf1d1 100644 --- a/cmd/steward/subnets.go +++ b/cmd/steward/subnets.go @@ -339,6 +339,9 @@ func reservationCmd() *cobra.Command { lease := "-" if r.Lease != nil { lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04") + if r.Lease.LastSeen != nil { + lease += ", seen " + fmtWhen(*r.Lease.LastSeen) + } } state := "on" if r.Disabled { @@ -374,7 +377,10 @@ func reservationCmd() *cobra.Command { fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname)) fmt.Fprintln(w, "Options:\t"+optionsText(r.Options)) if r.Lease != nil { - fmt.Fprintf(w, "Lease:\tuntil %s, last seen on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface) + fmt.Fprintf(w, "Lease:\tuntil %s, on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface) + if r.Lease.LastSeen != nil { + fmt.Fprintf(w, "Last seen:\t%s (answered a ping)\n", fmtWhen(*r.Lease.LastSeen)) + } } else { fmt.Fprintln(w, "Lease:\tnone now") } diff --git a/cmd/stewardd/main.go b/cmd/stewardd/main.go index 5232418..f6cef88 100644 --- a/cmd/stewardd/main.go +++ b/cmd/stewardd/main.go @@ -172,6 +172,7 @@ func serveCmd() *cobra.Command { Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme, + ExcludeInterfaces: a.cfg.Interfaces.Exclude, }, eng, d, host, a.log, web.FS()) go d.Run(ctx) hup := make(chan os.Signal, 1) diff --git a/docs/api.md b/docs/api.md index 0a6c00f..833a2fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -62,7 +62,7 @@ Creating answers 201, other changes 200. |---|---| | `GET /healthz` | `{"ok": true, "version"}`, without authentication | | `GET /status` | build, start time, `interfaces` (`listening`, `error`, `addresses`, `subnets`), `subnets` with usage, `problems`, `leases` (counts by state), `messages` (counts by type), `ping_problem` | -| `GET /interfaces` | the host's interfaces, their addresses and subnets | +| `GET /interfaces` | the host's interfaces, their addresses and subnets, leaving out those matching `interfaces.exclude` | | `GET /catalog` | the option catalog, value types, reserved codes, roles | ### Settings @@ -109,11 +109,11 @@ settings'. | | | |---|---| -| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation; `q` searches address, MAC and host name | +| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name | | `GET /leases/{ip}` | one | | `DELETE /leases/{ip}` | forget it (audited) | | `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` | -| `GET /events[?mac=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory) | +| `GET /events[?mac=&subnet=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory). `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface | ### History and import diff --git a/docs/cli.md b/docs/cli.md index 74085e8..68a38bb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -75,7 +75,7 @@ one. An option Steward doesn't know: `--option 224:string=hello`. | Command | | |---|---| -| `reservation list [--subnet SUBNET]` | reservations, and whether each client is online | +| `reservation list [--subnet SUBNET]` | reservations, whether each client is online, and when it last answered a ping | | `reservation show RES` | RES is its ID, address or MAC address | | `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` | | `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | | @@ -93,9 +93,11 @@ one. An option Steward doesn't know: `--option 224:string=hello`. ### activity -`steward activity [--mac MAC] [--interface I] [--limit N] [-f]`: the latest -DHCP messages, received and sent, oldest first. `-f` keeps watching, which -is useful while a device tries to get an address: +`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`: +the latest DHCP messages, received and sent, oldest first. `--subnet` +takes an ID, name or prefix; a message without an address (a DISCOVER, +"no address") shows for every subnet on its interface. `-f` keeps +watching, which is useful while a device tries to get an address: ``` 11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook @@ -115,7 +117,7 @@ sent. | `settings show` | | | `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | | | `option list` | the options Steward knows ([options.md](options.md)) | -| `interface list` | the server's interfaces, their addresses and subnets | +| `interface list` | the server's interfaces, their addresses and subnets (not those in `interfaces.exclude`) | ### history diff --git a/docs/configuration.md b/docs/configuration.md index dd15421..a57db33 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,7 @@ the file when it starts. | `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; stewardd rereads it when the file changes, or on `systemctl reload steward` | | `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) | | `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server Steward uses for DNS, and its token ([jarl.md](jarl.md)) | +| `interfaces.exclude` | `[]` | interfaces left out of the web UI's interface picker, `steward interface list` and `GET /interfaces`: names or shell patterns, such as `["lo", "docker*", "veth*"]`. A subnet can still name one, and is still served | | `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed | | `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off | | `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log | diff --git a/docs/design.md b/docs/design.md index fd34cfd..89674d6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -25,7 +25,9 @@ check or reload. * **Leases** in SQLite, written before the client is answered, so a crash or restart never hands out an address twice. * **Conflict checks**: an address is pinged before it is offered; one that - answers, or that a client declines, is held back for a while. + answers, or that a client declines, is held back for a while. Active + leases are pinged every minute too, for a "last seen" time that never + takes a client offline ([subnets.md](subnets.md#online-and-last-seen)). * **The Jarl way of working**: an API used by both the CLI and the web UI; users, sessions and API tokens; every configuration change validated as a whole before it is saved and used; a history of changes that can be diff --git a/docs/development.md b/docs/development.md index d67a896..0be649d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -55,6 +55,7 @@ It checks: * addresses and options per interface, and reservations; * the ping check, against a container that squats on a pool address; +* last seen: a client that stays up answers pings, one that left has none; * init-reboot, renewal and release with dhcpcd; * a client moved to another VLAN: NAK, then that VLAN's address; * a full pool and a disabled subnet; diff --git a/docs/subnets.md b/docs/subnets.md index 9379ae3..2df6ba8 100644 --- a/docs/subnets.md +++ b/docs/subnets.md @@ -20,7 +20,9 @@ For VLANs, give the server an interface on each, such as `eth0.20`, or a bridge such as `br-iot`, each with an address in its subnet. That address is the server identifier clients see, and the one they renew their lease with. `steward interface list` shows the server's interfaces with their -addresses and subnets. +addresses and subnets. To keep interfaces such as `lo`, `docker0` or +`veth*` out of that list and the web UI's picker, name them in +`interfaces.exclude` in steward.yaml ([configuration.md](configuration.md)). An interface that doesn't exist yet is fine. The subnet gets a warning (`interface_unknown`), and stewardd starts serving it within 30 seconds of @@ -143,6 +145,17 @@ then is the ACK sent. `steward lease list` shows active and held-back leases; `--all` shows the others too. Leases that ended more than 30 days ago are deleted. +### Online and last seen + +A client is **online** while its lease is active. With the ping check on +(the default), Steward also pings every active lease's address once a +minute, and a client that answers gets a **last seen** time: on the +Leases page, next to a reservation's online badge, and in `steward lease +list`. Many devices block pings (Windows' firewall does by default), so a +client that doesn't answer stays online and simply has no last-seen time, +or keeps the one it had. The time stays with the lease when it is +renewed, and is kept across restarts. + `steward lease delete IP` frees an address at once. A client still using it gets a NAK when it next renews, then asks for a new address. diff --git a/internal/config/config.go b/internal/config/config.go index b1138d4..07b6ddc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "net/netip" "net/url" "os" + "path" "path/filepath" "strings" "time" @@ -35,6 +36,17 @@ type Config struct { Backup Backup `yaml:"backup"` // Jarl is the Jarl server Steward uses for DNS (docs/jarl.md). Jarl Jarl `yaml:"jarl"` + // Interfaces says which of the host's interfaces are offered when + // choosing a subnet's interface. + Interfaces Interfaces `yaml:"interfaces"` +} + +// Interfaces filters the host's interfaces listed by the API, the web UI's +// interface picker and 'steward interface list'. +type Interfaces struct { + // Exclude are interface names or shell patterns ("docker*", "veth*") + // left out of the list. It doesn't stop a subnet from naming one. + Exclude []string `yaml:"exclude"` } // TLS is the web UI's certificate: from files, or obtained through ACME @@ -173,6 +185,7 @@ func Default() *Config { return &Config{ Listen: []string{"127.0.0.1:8067"}, TrustedProxies: []string{}, + Interfaces: Interfaces{Exclude: []string{}}, Socket: "/run/steward/steward.sock", Database: "/var/lib/steward/steward.db", Log: Log{Level: "info", Format: "text"}, @@ -225,6 +238,11 @@ func (c *Config) check() error { return fmt.Errorf("trusted_proxies: %v", err) } } + for _, p := range c.Interfaces.Exclude { + if _, err := path.Match(p, ""); err != nil || p == "" { + return fmt.Errorf("interfaces.exclude: %q is not an interface name or pattern (e.g. lo, docker*, veth*)", p) + } + } if c.Database == "" || !filepath.IsAbs(c.Database) { return fmt.Errorf("database must be an absolute path") } @@ -354,6 +372,12 @@ tls: # ca_cert: "" # a CA to trust for Jarl's certificate # insecure: false +# Interfaces left out when choosing a subnet's interface (the web UI's +# picker, 'steward interface list'): names or shell patterns. Subnets +# already on one are still served. +interfaces: + exclude: [] # e.g. ["lo", "docker*", "veth*", "br-*"] + # Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are # believed. trusted_proxies: [] diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3716e21..7e23ae7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -34,6 +34,7 @@ func TestErrors(t *testing.T) { "{tls: {cert_file: /a, key_file: /b, acme: {domain: d.example.com}}, jarl: {server: https://j, token: x}}": "can't both", "{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name", "{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://", + "interfaces: {exclude: ['eth[']}": "interfaces.exclude", } for yml, want := range cases { path := filepath.Join(t.TempDir(), "s.yaml") diff --git a/internal/dhcpd/handle.go b/internal/dhcpd/handle.go index 51d1a1b..0fed854 100644 --- a/internal/dhcpd/handle.go +++ b/internal/dhcpd/handle.go @@ -290,7 +290,9 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa return s.nak(cfg, iface, p, sub, why) } start, host := now, p.HostName() + var seen *time.Time if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) { + seen = l.LastSeen if l.ActiveAt(now) { start = l.Start } @@ -300,7 +302,7 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa } } l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive, - Start: start, End: now.Add(sub.leaseTime), Interface: iface} + Start: start, End: now.Add(sub.leaseTime), Interface: iface, LastSeen: seen} err := s.putLease(ctx, l) delete(s.offers, ip) s.mu.Unlock() diff --git a/internal/dhcpd/handle_test.go b/internal/dhcpd/handle_test.go index 2bcaafd..30efa93 100644 --- a/internal/dhcpd/handle_test.go +++ b/internal/dhcpd/handle_test.go @@ -461,3 +461,41 @@ func TestDisabledReservation(t *testing.T) { t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr) } } + +func TestLastSeen(t *testing.T) { + h := newHarness(t, nil) + up := h.lease("eth0", mac(1)).YIAddr + quiet := h.lease("eth0", mac(2)).YIAddr + h.ping.answers[up] = true + h.s.CheckSeen(context.Background()) + if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(h.clock) { + t.Errorf("answering client: %+v", l) + } + // One that never answers has no last-seen time, and stays active. + if l := h.s.Lease(quiet); l.LastSeen != nil || !l.ActiveAt(h.clock) { + t.Errorf("quiet client: %+v", l) + } + // Later, the time stays when it stops answering, and survives a + // renewal and a restart. + seen := h.clock + h.clock = h.clock.Add(10 * time.Minute) + h.ping.answers[up] = false + h.s.CheckSeen(context.Background()) + h.lease("eth0", mac(1)) + if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(seen) { + t.Errorf("after a renewal: %+v", l) + } + ls, _ := h.st.LoadLeases(context.Background()) + for _, l := range ls { + if l.IP == up && (l.LastSeen == nil || !l.LastSeen.Equal(seen)) { + t.Errorf("stored: %+v", l) + } + } + // Expired leases aren't pinged. + h.clock = h.clock.Add(48 * time.Hour) + h.ping.pinged = nil + h.s.CheckSeen(context.Background()) + if len(h.ping.pinged) != 0 { + t.Errorf("pinged %v", h.ping.pinged) + } +} diff --git a/internal/dhcpd/listen.go b/internal/dhcpd/listen.go index 7e9c2ed..c4e44da 100644 --- a/internal/dhcpd/listen.go +++ b/internal/dhcpd/listen.go @@ -46,6 +46,7 @@ func (s *Server) Run(ctx context.Context) { defer t.Stop() prune := time.NewTicker(time.Hour) defer prune.Stop() + go s.watchSeen(ctx) for { s.reconcile(ctx) select { diff --git a/internal/dhcpd/ping.go b/internal/dhcpd/ping.go index 589f230..a182ace 100644 --- a/internal/dhcpd/ping.go +++ b/internal/dhcpd/ping.go @@ -31,7 +31,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati conn, err := icmp.ListenPacket("udp4", "0.0.0.0") if err != nil { p.once.Do(func() { - msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)" + msg := "can't send pings, so addresses aren't checked before they are offered and clients' last-seen times aren't known: " + err.Error() + " (check net.ipv4.ping_group_range)" p.Unavailable.Store(msg) if p.Log != nil { p.Log.Warn(msg) @@ -41,7 +41,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati } defer conn.Close() seq := int(p.seq.Add(1) & 0xffff) - msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}} + msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward")}} b, err := msg.Marshal(nil) if err != nil { return false diff --git a/internal/dhcpd/seen.go b/internal/dhcpd/seen.go new file mode 100644 index 0000000..0b4fca7 --- /dev/null +++ b/internal/dhcpd/seen.go @@ -0,0 +1,95 @@ +package dhcpd + +import ( + "context" + "net/netip" + "sync" + "time" + + "steward/internal/model" +) + +// How often active leases' addresses are pinged for their last-seen time, +// and how many pings run at once. +const ( + seenEvery = time.Minute + seenWorkers = 16 +) + +// watchSeen pings the active leases' addresses every seenEvery. +func (s *Server) watchSeen(ctx context.Context) { + t := time.NewTicker(seenEvery) + defer t.Stop() + for { + s.CheckSeen(ctx) + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// CheckSeen pings every active lease's address once (when the ping check +// is on), and records when each that answered was seen. A client that +// doesn't answer keeps its last time, or none: many block pings, and stay +// online while their lease lasts. +func (s *Server) CheckSeen(ctx context.Context) { + cfg := s.cfg.Load() + if s.pinger == nil || !cfg.settings.PingCheck { + return + } + now := s.now() + var active []model.Lease + s.mu.Lock() + for _, l := range s.leases { + if l.ActiveAt(now) { + active = append(active, *l) + } + } + s.mu.Unlock() + timeout := cfg.settings.EffectivePingTimeout() + var ( + mu sync.Mutex + seen = map[netip.Addr]time.Time{} + wg sync.WaitGroup + sem = make(chan struct{}, seenWorkers) + ) + for _, l := range active { + if ctx.Err() != nil { + break + } + sem <- struct{}{} + wg.Add(1) + go func() { + defer func() { <-sem; wg.Done() }() + if s.pinger.Ping(ctx, l.IP, timeout) { + mu.Lock() + seen[l.IP] = s.now() + mu.Unlock() + } + }() + } + wg.Wait() + if len(seen) == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for _, was := range active { + at, ok := seen[was.IP] + if !ok { + continue + } + // The address may have gone to another client meanwhile. + l := s.leases[was.IP] + if l == nil || l.MAC != was.MAC || l.ClientID != was.ClientID || !l.ActiveAt(at) { + delete(seen, was.IP) + continue + } + l.LastSeen = &at + } + if err := s.store.SetLastSeen(ctx, seen); err != nil { + s.warnOnce("last seen", time.Hour, "saving clients' last-seen times failed", "err", err) + } +} diff --git a/internal/dhcpd/server.go b/internal/dhcpd/server.go index 7015fcf..31347f7 100644 --- a/internal/dhcpd/server.go +++ b/internal/dhcpd/server.go @@ -10,7 +10,6 @@ import ( "net" "net/netip" "sort" - "strings" "sync" "sync/atomic" "time" @@ -24,6 +23,7 @@ type LeaseStore interface { PutLease(ctx context.Context, l *model.Lease) error DeleteLease(ctx context.Context, ip netip.Addr) error PruneLeases(ctx context.Context, before time.Time) (int64, error) + SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) error } // Interfaces reports a network interface's IPv4 addresses with their @@ -32,7 +32,8 @@ type Interfaces interface { Addrs(name string) ([]netip.Prefix, error) } -// Pinger checks whether an address answers (conflict detection). +// Pinger checks whether an address answers (conflict detection, and +// clients' last-seen times). type Pinger interface { Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool } @@ -342,15 +343,15 @@ func (e *events) add(ev Event) { e.counts[ev.Type]++ } -// Events returns the latest events, newest first, optionally only those of -// one MAC address or interface. -func (s *Server) Events(limit int, mac, iface string) []Event { +// Events returns the latest events, newest first, optionally only those +// keep accepts. +func (s *Server) Events(limit int, keep func(Event) bool) []Event { s.events.mu.Lock() defer s.events.mu.Unlock() out := []Event{} for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- { ev := s.events.list[i] - if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface { + if keep != nil && !keep(ev) { continue } out = append(out, ev) diff --git a/internal/model/lease.go b/internal/model/lease.go index 1a5072c..3dbe76c 100644 --- a/internal/model/lease.go +++ b/internal/model/lease.go @@ -34,6 +34,9 @@ type Lease struct { // Interface is where the client was last seen. Interface string `json:"interface"` Updated time.Time `json:"updated"` + // LastSeen is when the client last answered a ping (nil: never). + // Clients that block pings stay online while their lease lasts. + LastSeen *time.Time `json:"last_seen,omitempty"` } // StateAt is the lease's state at a time (an active lease past its end diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 87a11c7..213e134 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -572,6 +572,9 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin st := s.eng.State() out := []api.HostInterface{} for name, ps := range all { + if s.excludedInterface(name) { + continue + } hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}} for _, a := range ps { hi.Addresses = append(hi.Addresses, a.String()) @@ -585,7 +588,7 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin } // Subnets may name interfaces that don't exist (yet). for _, sn := range st.Subnets { - if _, ok := all[sn.Interface]; !ok && sn.Interface != "" { + if _, ok := all[sn.Interface]; !ok && sn.Interface != "" && !s.excludedInterface(sn.Interface) { found := false for i := range out { if out[i].Name == sn.Interface { diff --git a/internal/server/h_leases.go b/internal/server/h_leases.go index d747277..825af71 100644 --- a/internal/server/h_leases.go +++ b/internal/server/h_leases.go @@ -153,7 +153,30 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin if err := require(p, auth.ResLeases, false); err != nil { return err } - evs := s.dhcp.Events(queryInt(r, "limit", 200), r.URL.Query().Get("mac"), r.URL.Query().Get("interface")) + q := r.URL.Query() + var only *model.Subnet + if ref := q.Get("subnet"); ref != "" { + var err error + if only, err = subnetRef(s.eng.State(), ref); err != nil { + return err + } + } + mac, iface := q.Get("mac"), q.Get("interface") + evs := s.dhcp.Events(queryInt(r, "limit", 200), func(e dhcpd.Event) bool { + if mac != "" && !strings.EqualFold(e.MAC, mac) || iface != "" && e.Interface != iface { + return false + } + if only != nil { + // A message with an address belongs to the subnet that has + // it; one without (a DISCOVER) to every subnet on its + // interface. + if ip, err := netip.ParseAddr(e.IP); err == nil { + return only.Prefix.Contains(ip) + } + return e.Interface == only.Interface + } + return true + }) out := make([]api.Event, len(evs)) for i, e := range evs { out[i] = api.Event(e) diff --git a/internal/server/server.go b/internal/server/server.go index d35350a..63b2aa6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "net/http" "net/netip" "os" + "path" "strconv" "strings" "sync" @@ -44,6 +45,20 @@ type Config struct { // ACME keeps the certificate stewardd obtains itself (tls.acme; nil // when off). ACME *certs.Manager + // ExcludeInterfaces are interface names or path.Match patterns left + // out of GET /interfaces (interfaces.exclude). + ExcludeInterfaces []string +} + +// excludedInterface reports whether interfaces.exclude hides the +// interface name. +func (s *Server) excludedInterface(name string) bool { + for _, p := range s.cfg.ExcludeInterfaces { + if ok, _ := path.Match(p, name); ok { + return true + } + } + return false } // Interfaces lists the host's interfaces. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e5ee6fa..d6cef41 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -11,6 +11,7 @@ import ( "net/http/cookiejar" "net/http/httptest" "net/netip" + "net/url" "strings" "testing" @@ -246,6 +247,16 @@ func TestLeasesThroughDHCP(t *testing.T) { if len(evs) != 4 || evs[0].Type != "DHCPACK" { t.Errorf("events %+v", evs) } + // By subnet: its addresses, and DISCOVERs on its interface. + e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, nil) + for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} { + if code := e.call(e.token, "GET", "/events?subnet="+url.QueryEscape(ref), nil, &evs); code != 200 || len(evs) != want { + t.Errorf("events of subnet %s: %d %+v", ref, code, evs) + } + } + if code := e.call(e.token, "GET", "/events?subnet=nope", nil, nil); code != 404 && code != 400 { + t.Errorf("unknown subnet: %d", code) + } if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 { t.Errorf("delete lease: %d", code) } @@ -380,3 +391,17 @@ func TestTokenRotation(t *testing.T) { t.Errorf("a token rotating itself: %d", code) } } + +func TestInterfacesExclude(t *testing.T) { + e := newEnv(t) + e.srv.cfg.ExcludeInterfaces = []string{"eth0.*", "docker*"} + // A subnet on an excluded interface the host doesn't have. + if c := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("172.17.0.0/16"), Interface: ptr("docker0")}, nil); c != 201 { + t.Fatalf("create: %d", c) + } + var ifs []api.HostInterface + e.call(e.token, "GET", "/interfaces", nil, &ifs) + if len(ifs) != 1 || ifs[0].Name != "eth0" { + t.Errorf("interfaces: %+v", ifs) + } +} diff --git a/internal/store/leases.go b/internal/store/leases.go index 3e1cde3..adea708 100644 --- a/internal/store/leases.go +++ b/internal/store/leases.go @@ -2,13 +2,14 @@ package store import ( "context" + "database/sql" "net/netip" "time" "steward/internal/model" ) -const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated` +const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated, last_seen` // LoadLeases reads every lease. func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) { @@ -21,11 +22,13 @@ func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) { for rows.Next() { l := &model.Lease{} var ip, start, end, updated string - if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated); err != nil { + var seen sql.NullString + if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated, &seen); err != nil { return nil, err } l.IP, _ = netip.ParseAddr(ip) l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated) + l.LastSeen = parseTSPtr(seen) out = append(out, l) } return out, rows.Err() @@ -46,10 +49,26 @@ func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error { } defer tx.Rollback() for _, l := range ls { - if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?) + if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(ip) DO UPDATE SET subnet_id = excluded.subnet_id, mac = excluded.mac, client_id = excluded.client_id, hostname = excluded.hostname, - state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated`, - l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated)); err != nil { + state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated, last_seen = excluded.last_seen`, + l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated), tsPtr(l.LastSeen)); err != nil { + return err + } + } + return tx.Commit() +} + +// SetLastSeen records when leases' clients answered a ping, in one +// transaction. +func (s *Store) SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for ip, t := range seen { + if _, err := tx.ExecContext(ctx, `UPDATE leases SET last_seen = ? WHERE ip = ?`, ts(t), ip.String()); err != nil { return err } } diff --git a/internal/store/store.go b/internal/store/store.go index 433973b..f129332 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -197,6 +197,10 @@ ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT; // 3: disabled reservations. ` ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; +`, + // 4: when a lease's client last answered a ping. + ` +ALTER TABLE leases ADD COLUMN last_seen TEXT; `, } diff --git a/internal/web/static/assets/pages/activity.js b/internal/web/static/assets/pages/activity.js index d8d223e..f3a0aa5 100644 --- a/internal/web/static/assets/pages/activity.js +++ b/internal/web/static/assets/pages/activity.js @@ -2,16 +2,18 @@ import { h, mount, input, select, debounce } from "../ui.js"; import { get } from "../api.js"; import { onLeave } from "../app.js"; +import { subnetLabel } from "../widgets.js"; export async function render(el, params, query) { - const ifaces = await get("/interfaces"); + const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]); + const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || ""); const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" }); const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || ""); const out = h("div"); let live = true; const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause"); const draw = async () => { - const evs = await get("/events", { mac: mac.value.trim(), interface: iface.value, limit: 300 }); + const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 }); mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" }, h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))), h("tbody", null, evs.map((e) => { @@ -26,14 +28,15 @@ export async function render(el, params, query) { h("td", { class: "mono" }, e.ip || ""), h("td", null, e.hostname || ""), h("td", { class: "detail" }, e.detail || "")); - })))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : "."))); + })))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : "."))); }; mac.addEventListener("input", debounce(draw, 300)); + subnetSel.addEventListener("change", draw); iface.addEventListener("change", draw); mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"), h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. A MAC address leads to that client's leases; Only this follows just that client."))), - h("div", { class: "filter-row" }, mac, iface, pause), + h("div", { class: "filter-row" }, mac, subnetSel, iface, pause), h("div", { class: "panel" }, out)); await draw(); const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000); diff --git a/internal/web/static/assets/pages/leases.js b/internal/web/static/assets/pages/leases.js index 1d26748..d8936b5 100644 --- a/internal/web/static/assets/pages/leases.js +++ b/internal/web/static/assets/pages/leases.js @@ -20,6 +20,7 @@ export function leasesTable(leases, done, opts = {}) { { label: "Host name", render: leaseHost }, { label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] }, { label: "Ends", render: (l) => time(l.end) }, + { label: "Last seen", render: (l) => l.last_seen ? time(l.last_seen) : "" }, opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" }, { label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) }, { label: "", tdClass: "actions-cell", render: (l) => [ diff --git a/internal/web/static/assets/pages/reservations.js b/internal/web/static/assets/pages/reservations.js index c8c70b4..9106dd2 100644 --- a/internal/web/static/assets/pages/reservations.js +++ b/internal/web/static/assets/pages/reservations.js @@ -1,5 +1,5 @@ // Reservations: fixed addresses for clients. -import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js"; +import { h, mount, table, time, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js"; import { get, change, can } from "../api.js"; import { optionsEditor, subnetLabel } from "../widgets.js"; @@ -12,7 +12,8 @@ export function reservationsTable(rs, done, opts = {}) { { label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") }, opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" }, { label: "Online", render: (r) => r.lease - ? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface)) + ? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface), + r.lease.last_seen ? h("span", { class: "muted small", title: "last answered a ping" }, ", seen ", time(r.lease.last_seen)) : null) : badge("offline") }, { label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" }, { label: "Comment", render: (r) => r.comment }, diff --git a/internal/web/static/assets/pages/settings.js b/internal/web/static/assets/pages/settings.js index 969c18d..9c8eac3 100644 --- a/internal/web/static/assets/pages/settings.js +++ b/internal/web/static/assets/pages/settings.js @@ -9,7 +9,7 @@ export async function render(el) { const s = await get("/settings"); const write = can("config", true); const lease = input({ value: fmtSeconds(s.lease_time || 43200) }); - const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check); + const ping = checkbox("Ping an address before offering it, and skip it if something answers; also ping clients every minute for their last-seen time", s.ping_check); const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 }); timeout.style.width = "7em"; const hold = input({ value: fmtSeconds(s.decline_hold || 86400) }); diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 211c584..de741fc 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -178,12 +178,24 @@ run "undo the lease time change" 0 S history undo $(S history list -o json | gre run "lease time back" 0 S subnet show iot contains "Lease time: *1h" +# ---- last seen: a client that stays up answers pings + +# busybox's default script puts the leased address on the interface. +docker run -d --name ${P}-live --network ${P}-b --mac-address 02:00:00:00:00:30 --cap-add NET_ADMIN steward-e2e-client \ + udhcpc -i eth0 -f -x hostname:live >/dev/null +for i in $(seq 1 20); do S lease list | grep -q " live " && break; sleep 0.5; done + # ---- restart: leases survive docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1 run "restart stewardd" 0 start run "leases after the restart" 0 S lease list contains "$NA.101 *02:00:00:00:00:01 *laptop *active" +# A ping round runs at start (then every minute). +sleep 2 +run "last seen" 0 S lease list +contains "live *active *in [0-9hm]* *now *eth1" +contains "laptop *active *in [0-9hm]* *- *eth0" run "the client still gets its address" 0 udhcpc a 02:00:00:00:00:01 contains "BOUND ip=$NA.101 "