Files
abrown38andClaude Opus 5.5 5e6c61cc8e 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>
2026-10-18 17:50:21 +01:00

99 lines
3.2 KiB
Go

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, last_seen`
// LoadLeases reads every lease.
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+leaseCols+` FROM leases`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*model.Lease
for rows.Next() {
l := &model.Lease{}
var ip, start, end, updated string
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()
}
// PutLease writes a lease (replacing the address's previous one). It
// returns once the write is durable enough that a crash doesn't lose it:
// the DHCP server answers the client only afterwards.
func (s *Store) PutLease(ctx context.Context, l *model.Lease) error {
return s.PutLeases(ctx, []*model.Lease{l})
}
// PutLeases writes several leases in one transaction (imports).
func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, l := range ls {
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, 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
}
}
return tx.Commit()
}
// DeleteLease removes the lease of an address.
func (s *Store) DeleteLease(ctx context.Context, ip netip.Addr) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE ip = ?`, ip.String())
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PruneLeases deletes leases that ended before a time and aren't active
// (they are kept a while so returning clients get their old address).
func (s *Store) PruneLeases(ctx context.Context, before time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE end < ?`, ts(before))
if err != nil {
return 0, err
}
return res.RowsAffected()
}