Hidden interfaces, last-seen pings, activity by subnet

- interfaces.exclude in steward.yaml (names or shell patterns) leaves
  interfaces out of GET /interfaces, the web UI's picker and
  'steward interface list'. Subnets on them are still served.
- With the ping check on, active leases are pinged every minute; a
  client that answers gets a last-seen time (leases.last_seen,
  migration 4). Not answering never takes a client offline.
- The activity log filters by subnet (--subnet, the Activity page,
  GET /events?subnet=).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-18 17:50:21 +01:00
1 parent 6ad1bb9a0d
commit 5e6c61cc8e
31 files changed
+358 -39

No files matched your search

+10
View File
@@ -105,6 +105,16 @@ and a CLI, built the way Jarl is.
- **Disabled reservations** (`steward reservation disable|enable`, Disable - **Disabled reservations** (`steward reservation disable|enable`, Disable
in the web UI): kept but ignored, so the client gets a pool address like 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. 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`, - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl. period for the old one, as in Jarl.
+9 -5
View File
@@ -51,13 +51,13 @@ func leaseCmd() *cobra.Command {
ls = keep ls = keep
} }
return output(ls, func(w io.Writer) { 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 { for _, l := range ls {
st := l.State st := l.State
if l.Reservation != 0 { if l.Reservation != 0 {
st += ", reserved" 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, "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, "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) 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 { if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation) 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 { func activityCmd() *cobra.Command {
var mac, iface string var mac, subnet, iface string
var limit int var limit int
var follow bool var follow bool
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -193,7 +196,7 @@ func activityCmd() *cobra.Command {
} }
for { for {
var evs []api.Event 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 return err
} }
if flagOutput == "json" || flagOutput == "yaml" { if flagOutput == "json" || flagOutput == "yaml" {
@@ -212,6 +215,7 @@ func activityCmd() *cobra.Command {
} }
f := cmd.Flags() f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's") 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.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many") f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching") 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 := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)") 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(&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(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off") f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
+8
View File
@@ -424,6 +424,14 @@ func fmtWhen(t time.Time) string {
return "in " + s 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 { func orDash(s string) string {
if s == "" { if s == "" {
return "-" return "-"
+7 -1
View File
@@ -339,6 +339,9 @@ func reservationCmd() *cobra.Command {
lease := "-" lease := "-"
if r.Lease != nil { if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04") lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
if r.Lease.LastSeen != nil {
lease += ", seen " + fmtWhen(*r.Lease.LastSeen)
}
} }
state := "on" state := "on"
if r.Disabled { if r.Disabled {
@@ -374,7 +377,10 @@ func reservationCmd() *cobra.Command {
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname)) fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options)) fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
if r.Lease != nil { 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 { } else {
fmt.Fprintln(w, "Lease:\tnone now") fmt.Fprintln(w, "Lease:\tnone now")
} }
+1
View File
@@ -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(), Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme, 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()) }, eng, d, host, a.log, web.FS())
go d.Run(ctx) go d.Run(ctx)
hup := make(chan os.Signal, 1) hup := make(chan os.Signal, 1)
+3 -3
View File
@@ -62,7 +62,7 @@ Creating answers 201, other changes 200.
|---|---| |---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication | | `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 /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 | | `GET /catalog` | the option catalog, value types, reserved codes, roles |
### Settings ### 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 | | `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) | | `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` | | `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 ### History and import
+7 -5
View File
@@ -75,7 +75,7 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
| Command | | | 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 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 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 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 ### activity
`steward activity [--mac MAC] [--interface I] [--limit N] [-f]`: the latest `steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`:
DHCP messages, received and sent, oldest first. `-f` keeps watching, which the latest DHCP messages, received and sent, oldest first. `--subnet`
is useful while a device tries to get an address: 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.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
@@ -115,7 +117,7 @@ sent.
| `settings show` | | | `settings show` | |
| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | | | `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | |
| `option list` | the options Steward knows ([options.md](options.md)) | | `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 ### history
+1
View File
@@ -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.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)) | | `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)) | | `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 | | `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off | | `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log | | `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log |
+3 -1
View File
@@ -25,7 +25,9 @@ check or reload.
* **Leases** in SQLite, written before the client is answered, so a crash * **Leases** in SQLite, written before the client is answered, so a crash
or restart never hands out an address twice. or restart never hands out an address twice.
* **Conflict checks**: an address is pinged before it is offered; one that * **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; * **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 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 whole before it is saved and used; a history of changes that can be
+1
View File
@@ -55,6 +55,7 @@ It checks:
* addresses and options per interface, and reservations; * addresses and options per interface, and reservations;
* the ping check, against a container that squats on a pool address; * 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; * init-reboot, renewal and release with dhcpcd;
* a client moved to another VLAN: NAK, then that VLAN's address; * a client moved to another VLAN: NAK, then that VLAN's address;
* a full pool and a disabled subnet; * a full pool and a disabled subnet;
+14 -1
View File
@@ -20,7 +20,9 @@ For VLANs, give the server an interface on each, such as `eth0.20`, or a
bridge such as `br-iot`, each with an address in its subnet. That address 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 is the server identifier clients see, and the one they renew their lease
with. `steward interface list` shows the server's interfaces with their 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 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 (`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 `steward lease list` shows active and held-back leases; `--all` shows
the others too. Leases that ended more than 30 days ago are deleted. 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 `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. it gets a NAK when it next renews, then asks for a new address.
+24
View File
@@ -10,6 +10,7 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -35,6 +36,17 @@ type Config struct {
Backup Backup `yaml:"backup"` Backup Backup `yaml:"backup"`
// Jarl is the Jarl server Steward uses for DNS (docs/jarl.md). // Jarl is the Jarl server Steward uses for DNS (docs/jarl.md).
Jarl Jarl `yaml:"jarl"` 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 // TLS is the web UI's certificate: from files, or obtained through ACME
@@ -173,6 +185,7 @@ func Default() *Config {
return &Config{ return &Config{
Listen: []string{"127.0.0.1:8067"}, Listen: []string{"127.0.0.1:8067"},
TrustedProxies: []string{}, TrustedProxies: []string{},
Interfaces: Interfaces{Exclude: []string{}},
Socket: "/run/steward/steward.sock", Socket: "/run/steward/steward.sock",
Database: "/var/lib/steward/steward.db", Database: "/var/lib/steward/steward.db",
Log: Log{Level: "info", Format: "text"}, Log: Log{Level: "info", Format: "text"},
@@ -225,6 +238,11 @@ func (c *Config) check() error {
return fmt.Errorf("trusted_proxies: %v", err) 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) { if c.Database == "" || !filepath.IsAbs(c.Database) {
return fmt.Errorf("database must be an absolute path") return fmt.Errorf("database must be an absolute path")
} }
@@ -354,6 +372,12 @@ tls:
# ca_cert: "" # a CA to trust for Jarl's certificate # ca_cert: "" # a CA to trust for Jarl's certificate
# insecure: false # 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 # Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
# believed. # believed.
trusted_proxies: [] trusted_proxies: []
+1
View File
@@ -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: {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: '*.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://", "{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 { for yml, want := range cases {
path := filepath.Join(t.TempDir(), "s.yaml") path := filepath.Join(t.TempDir(), "s.yaml")
+3 -1
View File
@@ -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) return s.nak(cfg, iface, p, sub, why)
} }
start, host := now, p.HostName() start, host := now, p.HostName()
var seen *time.Time
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) { if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
seen = l.LastSeen
if l.ActiveAt(now) { if l.ActiveAt(now) {
start = l.Start 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, 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) err := s.putLease(ctx, l)
delete(s.offers, ip) delete(s.offers, ip)
s.mu.Unlock() s.mu.Unlock()
+38
View File
@@ -461,3 +461,41 @@ func TestDisabledReservation(t *testing.T) {
t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr) 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)
}
}
+1
View File
@@ -46,6 +46,7 @@ func (s *Server) Run(ctx context.Context) {
defer t.Stop() defer t.Stop()
prune := time.NewTicker(time.Hour) prune := time.NewTicker(time.Hour)
defer prune.Stop() defer prune.Stop()
go s.watchSeen(ctx)
for { for {
s.reconcile(ctx) s.reconcile(ctx)
select { select {
+2 -2
View File
@@ -31,7 +31,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
conn, err := icmp.ListenPacket("udp4", "0.0.0.0") conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil { if err != nil {
p.once.Do(func() { 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) p.Unavailable.Store(msg)
if p.Log != nil { if p.Log != nil {
p.Log.Warn(msg) p.Log.Warn(msg)
@@ -41,7 +41,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
} }
defer conn.Close() defer conn.Close()
seq := int(p.seq.Add(1) & 0xffff) 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) b, err := msg.Marshal(nil)
if err != nil { if err != nil {
return false return false
+95
View File
@@ -0,0 +1,95 @@
package dhcpd
import (
"context"
"net/netip"
"sync"
"time"
"steward/internal/model"
)
// How often active leases' addresses are pinged for their last-seen time,
// and how many pings run at once.
const (
seenEvery = time.Minute
seenWorkers = 16
)
// watchSeen pings the active leases' addresses every seenEvery.
func (s *Server) watchSeen(ctx context.Context) {
t := time.NewTicker(seenEvery)
defer t.Stop()
for {
s.CheckSeen(ctx)
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// CheckSeen pings every active lease's address once (when the ping check
// is on), and records when each that answered was seen. A client that
// doesn't answer keeps its last time, or none: many block pings, and stay
// online while their lease lasts.
func (s *Server) CheckSeen(ctx context.Context) {
cfg := s.cfg.Load()
if s.pinger == nil || !cfg.settings.PingCheck {
return
}
now := s.now()
var active []model.Lease
s.mu.Lock()
for _, l := range s.leases {
if l.ActiveAt(now) {
active = append(active, *l)
}
}
s.mu.Unlock()
timeout := cfg.settings.EffectivePingTimeout()
var (
mu sync.Mutex
seen = map[netip.Addr]time.Time{}
wg sync.WaitGroup
sem = make(chan struct{}, seenWorkers)
)
for _, l := range active {
if ctx.Err() != nil {
break
}
sem <- struct{}{}
wg.Add(1)
go func() {
defer func() { <-sem; wg.Done() }()
if s.pinger.Ping(ctx, l.IP, timeout) {
mu.Lock()
seen[l.IP] = s.now()
mu.Unlock()
}
}()
}
wg.Wait()
if len(seen) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, was := range active {
at, ok := seen[was.IP]
if !ok {
continue
}
// The address may have gone to another client meanwhile.
l := s.leases[was.IP]
if l == nil || l.MAC != was.MAC || l.ClientID != was.ClientID || !l.ActiveAt(at) {
delete(seen, was.IP)
continue
}
l.LastSeen = &at
}
if err := s.store.SetLastSeen(ctx, seen); err != nil {
s.warnOnce("last seen", time.Hour, "saving clients' last-seen times failed", "err", err)
}
}
+7 -6
View File
@@ -10,7 +10,6 @@ import (
"net" "net"
"net/netip" "net/netip"
"sort" "sort"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -24,6 +23,7 @@ type LeaseStore interface {
PutLease(ctx context.Context, l *model.Lease) error PutLease(ctx context.Context, l *model.Lease) error
DeleteLease(ctx context.Context, ip netip.Addr) error DeleteLease(ctx context.Context, ip netip.Addr) error
PruneLeases(ctx context.Context, before time.Time) (int64, 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 // Interfaces reports a network interface's IPv4 addresses with their
@@ -32,7 +32,8 @@ type Interfaces interface {
Addrs(name string) ([]netip.Prefix, error) 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 { type Pinger interface {
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool 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]++ e.counts[ev.Type]++
} }
// Events returns the latest events, newest first, optionally only those of // Events returns the latest events, newest first, optionally only those
// one MAC address or interface. // keep accepts.
func (s *Server) Events(limit int, mac, iface string) []Event { func (s *Server) Events(limit int, keep func(Event) bool) []Event {
s.events.mu.Lock() s.events.mu.Lock()
defer s.events.mu.Unlock() defer s.events.mu.Unlock()
out := []Event{} out := []Event{}
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- { for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
ev := s.events.list[i] ev := s.events.list[i]
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface { if keep != nil && !keep(ev) {
continue continue
} }
out = append(out, ev) out = append(out, ev)
+3
View File
@@ -34,6 +34,9 @@ type Lease struct {
// Interface is where the client was last seen. // Interface is where the client was last seen.
Interface string `json:"interface"` Interface string `json:"interface"`
Updated time.Time `json:"updated"` 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 // StateAt is the lease's state at a time (an active lease past its end
+4 -1
View File
@@ -572,6 +572,9 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin
st := s.eng.State() st := s.eng.State()
out := []api.HostInterface{} out := []api.HostInterface{}
for name, ps := range all { for name, ps := range all {
if s.excludedInterface(name) {
continue
}
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}} hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
for _, a := range ps { for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String()) 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). // Subnets may name interfaces that don't exist (yet).
for _, sn := range st.Subnets { 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 found := false
for i := range out { for i := range out {
if out[i].Name == sn.Interface { if out[i].Name == sn.Interface {
+24 -1
View File
@@ -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 { if err := require(p, auth.ResLeases, false); err != nil {
return err 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)) out := make([]api.Event, len(evs))
for i, e := range evs { for i, e := range evs {
out[i] = api.Event(e) out[i] = api.Event(e)
+15
View File
@@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
"path"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -44,6 +45,20 @@ type Config struct {
// ACME keeps the certificate stewardd obtains itself (tls.acme; nil // ACME keeps the certificate stewardd obtains itself (tls.acme; nil
// when off). // when off).
ACME *certs.Manager 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. // Interfaces lists the host's interfaces.
+25
View File
@@ -11,6 +11,7 @@ import (
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -246,6 +247,16 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" { if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs) 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 { if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
t.Errorf("delete lease: %d", code) t.Errorf("delete lease: %d", code)
} }
@@ -380,3 +391,17 @@ func TestTokenRotation(t *testing.T) {
t.Errorf("a token rotating itself: %d", code) 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)
}
}
+24 -5
View File
@@ -2,13 +2,14 @@ package store
import ( import (
"context" "context"
"database/sql"
"net/netip" "net/netip"
"time" "time"
"steward/internal/model" "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. // LoadLeases reads every lease.
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) { 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() { for rows.Next() {
l := &model.Lease{} l := &model.Lease{}
var ip, start, end, updated string 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 return nil, err
} }
l.IP, _ = netip.ParseAddr(ip) l.IP, _ = netip.ParseAddr(ip)
l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated) l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated)
l.LastSeen = parseTSPtr(seen)
out = append(out, l) out = append(out, l)
} }
return out, rows.Err() return out, rows.Err()
@@ -46,10 +49,26 @@ func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
} }
defer tx.Rollback() defer tx.Rollback()
for _, l := range ls { 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, 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`, 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)); err != nil { 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 return err
} }
} }
+4
View File
@@ -197,6 +197,10 @@ ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
// 3: disabled reservations. // 3: disabled reservations.
` `
ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; 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;
`, `,
} }
+7 -4
View File
@@ -2,16 +2,18 @@
import { h, mount, input, select, debounce } from "../ui.js"; import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js"; import { get } from "../api.js";
import { onLeave } from "../app.js"; import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.js";
export async function render(el, params, query) { 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 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 iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div"); const out = h("div");
let live = true; let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause"); const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => { 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" }, 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) => { 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", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""), h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || "")); 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)); mac.addEventListener("input", debounce(draw, 300));
subnetSel.addEventListener("change", draw);
iface.addEventListener("change", draw); iface.addEventListener("change", draw);
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"), 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: "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)); h("div", { class: "panel" }, out));
await draw(); await draw();
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000); const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
@@ -20,6 +20,7 @@ export function leasesTable(leases, done, opts = {}) {
{ label: "Host name", render: leaseHost }, { label: "Host name", render: leaseHost },
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] }, { label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
{ label: "Ends", render: (l) => time(l.end) }, { 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 || "–" }, opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) }, { label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
{ label: "", tdClass: "actions-cell", render: (l) => [ { label: "", tdClass: "actions-cell", render: (l) => [
@@ -1,5 +1,5 @@
// Reservations: fixed addresses for clients. // 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 { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.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" }, "–") }, { 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) : "–" }, 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 { 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") }, : badge("offline") },
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" }, { 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 }, { label: "Comment", render: (r) => r.comment },
+1 -1
View File
@@ -9,7 +9,7 @@ export async function render(el) {
const s = await get("/settings"); const s = await get("/settings");
const write = can("config", true); const write = can("config", true);
const lease = input({ value: fmtSeconds(s.lease_time || 43200) }); 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 }); const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
timeout.style.width = "7em"; timeout.style.width = "7em";
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) }); const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
+12
View File
@@ -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 run "lease time back" 0 S subnet show iot
contains "Lease time: *1h" 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 # ---- restart: leases survive
docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1 docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1
run "restart stewardd" 0 start run "restart stewardd" 0 start
run "leases after the restart" 0 S lease list run "leases after the restart" 0 S lease list
contains "$NA.101 *02:00:00:00:00:01 *laptop *active" 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 run "the client still gets its address" 0 udhcpc a 02:00:00:00:00:01
contains "BOUND ip=$NA.101 " contains "BOUND ip=$NA.101 "