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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user