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.
189 lines
4.3 KiB
Go
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
|
|
}
|