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

+24 -5
View File
@@ -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
}
}