repeater/internal/station/dhcp/backend.go
Pierre-Olivier Mercier 5a3942f351 station/hostapd: Resolve station IPs via udhcpd leases and ARP fallback
The hostapd backend never populated IPs: NewDHCPCorrelator was defined
but never instantiated, and even when it was, the parser only handled
ISC dhcpd's text format. On a BusyBox-based router using udhcpd, every
device showed up with an empty IP.

Two fixes:

- Add a udhcpd binary lease parser. The format is documented in
  busybox/networking/udhcp/dhcpd.{h,c}: an 8-byte big-endian unix-time
  header followed by 36-byte dyn_lease records (expires, IP, MAC,
  20-byte hostname, 2-byte pad). ParseLeases auto-detects the format
  by inspecting the header so the same code path handles both udhcpd
  and ISC text leases.

- Wire the DHCPCorrelator into Backend.Initialize and have it merge
  two sources: ARP first (universal IP fallback for any station that
  has been talked to) and DHCP leases on top (authoritative, carries
  the hostname). ARP fills the gap when leases are missing or the
  station uses a static IP; DHCP wins on conflict.

Default DHCPLeasesPath updated to /var/lib/udhcpd/udhcpd.leases — the
common BusyBox path. Configurable as before.
2026-05-02 11:07:37 +08:00

189 lines
4.3 KiB
Go

package dhcp
import (
"sync"
"time"
"github.com/nemunaire/repeater/internal/station/backend"
)
// Backend implements StationBackend using DHCP lease discovery
type Backend struct {
dhcpLeasesPath string
lastStations map[string]backend.Station // Key: MAC address
callbacks backend.EventCallbacks
stopChan chan struct{}
stopOnce sync.Once
mu sync.RWMutex
running bool
}
// NewBackend creates a new DHCP backend
func NewBackend() *Backend {
return &Backend{
lastStations: make(map[string]backend.Station),
stopChan: make(chan struct{}),
}
}
// Initialize initializes the DHCP backend
func (b *Backend) Initialize(config backend.BackendConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.dhcpLeasesPath = config.DHCPLeasesPath
if b.dhcpLeasesPath == "" {
b.dhcpLeasesPath = "/var/lib/udhcpd/udhcpd.leases"
}
return nil
}
// Close cleans up backend resources
func (b *Backend) Close() error {
b.StopEventMonitoring()
return nil
}
// GetStations returns all connected stations from DHCP leases validated by ARP
func (b *Backend) GetStations() ([]backend.Station, error) {
b.mu.RLock()
dhcpLeasesPath := b.dhcpLeasesPath
b.mu.RUnlock()
// Read DHCP leases
leases, err := parseDHCPLeases(dhcpLeasesPath)
if err != nil {
return nil, err
}
// Get ARP information for validation
arpInfo, err := getARPInfo()
if err != nil {
return nil, err
}
var stations []backend.Station
for _, lease := range leases {
// Check if the device is still connected via ARP
if _, exists := arpInfo[lease.IP]; exists {
st := backend.Station{
MAC: lease.MAC,
IP: lease.IP,
Hostname: lease.Hostname,
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
Vendor: backend.LookupVendor(lease.MAC),
Signal: 0, // Not available from DHCP
RxBytes: 0, // Not available from DHCP
TxBytes: 0, // Not available from DHCP
ConnectedAt: time.Now(),
}
stations = append(stations, st)
}
}
return stations, nil
}
// StartEventMonitoring starts monitoring for station events via polling
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.running {
return nil
}
b.callbacks = callbacks
b.running = true
// Start polling goroutine
go b.pollLoop()
return nil
}
// StopEventMonitoring stops event monitoring. Idempotent — see hostapd backend.
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
b.stopOnce.Do(func() { close(b.stopChan) })
}
// SupportsRealTimeEvents returns false (DHCP is polling-based)
func (b *Backend) SupportsRealTimeEvents() bool {
return false
}
// pollLoop polls DHCP leases and simulates events
func (b *Backend) pollLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.checkForChanges()
case <-b.stopChan:
return
}
}
}
// checkForChanges compares current state with last state and triggers callbacks
func (b *Backend) checkForChanges() {
// Get current stations
current, err := b.GetStations()
if err != nil {
return
}
// Build map of current stations
currentMap := make(map[string]backend.Station)
for _, st := range current {
currentMap[st.MAC] = st
}
b.mu.Lock()
defer b.mu.Unlock()
// Check for new stations (connected)
for mac, st := range currentMap {
if _, exists := b.lastStations[mac]; !exists {
// New station connected
if cb := b.callbacks.OnStationConnected; cb != nil {
stCopy := st
backend.SafeGo("OnStationConnected", func() { cb(stCopy) })
}
} else {
// Check for updates (IP change, hostname change, etc.)
oldStation := b.lastStations[mac]
if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname {
if cb := b.callbacks.OnStationUpdated; cb != nil {
stCopy := st
backend.SafeGo("OnStationUpdated", func() { cb(stCopy) })
}
}
}
}
// Check for disconnected stations
for mac := range b.lastStations {
if _, exists := currentMap[mac]; !exists {
// Station disconnected
if cb := b.callbacks.OnStationDisconnected; cb != nil {
macCopy := mac
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
}
}
}
// Update last state
b.lastStations = currentMap
}