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:
1 parent
6ad1bb9a0d
commit
5e6c61cc8e
31 files changed
+358
-39
No files matched your search
@@ -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: []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,7 +31,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
|
||||
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
|
||||
if err != nil {
|
||||
p.once.Do(func() {
|
||||
msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)"
|
||||
msg := "can't send pings, so addresses aren't checked before they are offered and clients' last-seen times aren't known: " + err.Error() + " (check net.ipv4.ping_group_range)"
|
||||
p.Unavailable.Store(msg)
|
||||
if p.Log != nil {
|
||||
p.Log.Warn(msg)
|
||||
@@ -41,7 +41,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
|
||||
}
|
||||
defer conn.Close()
|
||||
seq := int(p.seq.Add(1) & 0xffff)
|
||||
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}}
|
||||
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward")}}
|
||||
b, err := msg.Marshal(nil)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// How often active leases' addresses are pinged for their last-seen time,
|
||||
// and how many pings run at once.
|
||||
const (
|
||||
seenEvery = time.Minute
|
||||
seenWorkers = 16
|
||||
)
|
||||
|
||||
// watchSeen pings the active leases' addresses every seenEvery.
|
||||
func (s *Server) watchSeen(ctx context.Context) {
|
||||
t := time.NewTicker(seenEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
s.CheckSeen(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSeen pings every active lease's address once (when the ping check
|
||||
// is on), and records when each that answered was seen. A client that
|
||||
// doesn't answer keeps its last time, or none: many block pings, and stay
|
||||
// online while their lease lasts.
|
||||
func (s *Server) CheckSeen(ctx context.Context) {
|
||||
cfg := s.cfg.Load()
|
||||
if s.pinger == nil || !cfg.settings.PingCheck {
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
var active []model.Lease
|
||||
s.mu.Lock()
|
||||
for _, l := range s.leases {
|
||||
if l.ActiveAt(now) {
|
||||
active = append(active, *l)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
timeout := cfg.settings.EffectivePingTimeout()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
seen = map[netip.Addr]time.Time{}
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, seenWorkers)
|
||||
)
|
||||
for _, l := range active {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer func() { <-sem; wg.Done() }()
|
||||
if s.pinger.Ping(ctx, l.IP, timeout) {
|
||||
mu.Lock()
|
||||
seen[l.IP] = s.now()
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if len(seen) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, was := range active {
|
||||
at, ok := seen[was.IP]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// The address may have gone to another client meanwhile.
|
||||
l := s.leases[was.IP]
|
||||
if l == nil || l.MAC != was.MAC || l.ClientID != was.ClientID || !l.ActiveAt(at) {
|
||||
delete(seen, was.IP)
|
||||
continue
|
||||
}
|
||||
l.LastSeen = &at
|
||||
}
|
||||
if err := s.store.SetLastSeen(ctx, seen); err != nil {
|
||||
s.warnOnce("last seen", time.Hour, "saving clients' last-seen times failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,6 +20,7 @@ export function leasesTable(leases, done, opts = {}) {
|
||||
{ label: "Host name", render: leaseHost },
|
||||
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
|
||||
{ label: "Ends", render: (l) => time(l.end) },
|
||||
{ label: "Last seen", render: (l) => l.last_seen ? time(l.last_seen) : "" },
|
||||
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
|
||||
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
|
||||
{ label: "", tdClass: "actions-cell", render: (l) => [
|
||||
|
||||
@@ -1,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 },
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in new issue
Block a user