package hostapd import ( "log" "strings" "time" "github.com/nemunaire/repeater/internal/station/arp" "github.com/nemunaire/repeater/internal/station/dhcp" ) // DHCPCorrelator helps correlate hostapd stations with DHCP leases (and the // ARP table as a fallback) so the UI gets an IP and hostname for every // station, not just a MAC. Hostapd itself only knows MACs. type DHCPCorrelator struct { backend *Backend dhcpLeasesPath string arpTablePath string stopChan chan struct{} running bool } // NewDHCPCorrelator creates a new DHCP correlator func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath, arpTablePath string) *DHCPCorrelator { if dhcpLeasesPath == "" { dhcpLeasesPath = "/var/lib/misc/udhcpd.leases" } if arpTablePath == "" { arpTablePath = "/proc/net/arp" } return &DHCPCorrelator{ backend: backend, dhcpLeasesPath: dhcpLeasesPath, arpTablePath: arpTablePath, stopChan: make(chan struct{}), } } // Start begins periodic correlation of DHCP leases with hostapd stations func (dc *DHCPCorrelator) Start() { if dc.running { return } dc.running = true go dc.correlationLoop() log.Printf("DHCP/ARP correlation started (leases=%s, arp=%s)", dc.dhcpLeasesPath, dc.arpTablePath) } // Stop stops the correlation loop func (dc *DHCPCorrelator) Stop() { if !dc.running { return } dc.running = false close(dc.stopChan) log.Printf("DHCP correlation stopped") } // correlationLoop periodically correlates DHCP leases with stations func (dc *DHCPCorrelator) correlationLoop() { // Do an initial correlation immediately dc.correlate() // Then correlate every 10 seconds ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: dc.correlate() case <-dc.stopChan: return } } } // correlate performs one correlation cycle. We pull from two sources: // // - The DHCP lease file (udhcpd binary or ISC text — auto-detected) is the // authoritative source for hostnames and the IP that was actually // assigned. // - The ARP table is a universal IP fallback. If the lease file is missing // or the station uses a static IP, ARP still gives us its current IPv4. // // The two are merged: ARP fills gaps, DHCP wins on conflict because it // carries the hostname and is less prone to stale entries. func (dc *DHCPCorrelator) correlate() { macToIP := make(map[string]string) macToHostname := make(map[string]string) // ARP first, so DHCP overrides on conflict. if entries, err := arp.ParseTable(dc.arpTablePath); err == nil { for _, entry := range entries { // Flags 2 (COMPLETE) and 6 (COMPLETE|PERM) — incomplete // entries have a zero MAC and would pollute the mapping. if entry.Flags != 2 && entry.Flags != 6 { continue } mac := strings.ToLower(entry.HWAddress.String()) if mac == "" || mac == "00:00:00:00:00:00" { continue } macToIP[mac] = entry.IP.String() } } else { log.Printf("ARP table read failed (%s): %v", dc.arpTablePath, err) } if leases, err := dhcp.ParseLeases(dc.dhcpLeasesPath); err == nil { for _, lease := range leases { mac := strings.ToLower(lease.MAC) if mac == "" { continue } if lease.IP != "" { macToIP[mac] = lease.IP } if lease.Hostname != "" { macToHostname[mac] = lease.Hostname } } } else { log.Printf("DHCP lease parse failed (%s): %v", dc.dhcpLeasesPath, err) } dc.backend.UpdateLeaseInfo(macToIP, macToHostname) }