From 5a3942f351845003de91890b2ba99d938cf2c949 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 2 May 2026 11:07:37 +0800 Subject: [PATCH] station/hostapd: Resolve station IPs via udhcpd leases and ARP fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/config/cli.go | 2 +- internal/config/config.go | 2 +- internal/station/arp/parser.go | 9 +- internal/station/dhcp/backend.go | 2 +- internal/station/dhcp/parser.go | 129 ++++++++++++++++++++++-- internal/station/dhcp/parser_test.go | 75 ++++++++++++++ internal/station/hostapd/backend.go | 11 ++ internal/station/hostapd/correlation.go | 117 ++++++++++----------- 8 files changed, 271 insertions(+), 76 deletions(-) create mode 100644 internal/station/dhcp/parser_test.go diff --git a/internal/config/cli.go b/internal/config/cli.go index 3e86245..cac9b92 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -12,7 +12,7 @@ func declareFlags(o *Config) { flag.StringVar(&o.EthernetInterface, "ethernet-interface", "eth0", "Ethernet interface to probe for a DHCP-assigned address at startup; if no DHCP address is present, wpa_supplicant is started") flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)") flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'") - flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") + flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/udhcpd/udhcpd.leases", "Path to DHCP leases file (udhcpd binary or ISC dhcpd text format — auto-detected)") flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file") diff --git a/internal/config/config.go b/internal/config/config.go index 1e89e82..1bc9af0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,7 +34,7 @@ func ConsolidateConfig() (opts *Config, err error) { WifiInterface: "wlan0", HotspotInterface: "wlan1", EthernetInterface: "eth0", - DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", + DHCPLeasesPath: "/var/lib/udhcpd/udhcpd.leases", ARPTablePath: "/proc/net/arp", SyslogEnabled: false, SyslogPath: "/var/log/messages", diff --git a/internal/station/arp/parser.go b/internal/station/arp/parser.go index ca40f01..881d73f 100644 --- a/internal/station/arp/parser.go +++ b/internal/station/arp/parser.go @@ -17,6 +17,13 @@ type ARPEntry struct { Device string } +// ParseTable is the exported entry point for the /proc/net/arp parser. +// Other packages (e.g. the hostapd correlator) use it as a universal IP +// source for stations whose DHCP lease isn't available. +func ParseTable(path string) ([]ARPEntry, error) { + return parseARPTable(path) +} + // parseARPTable reads and parses ARP table from /proc/net/arp format func parseARPTable(path string) ([]ARPEntry, error) { var entries []ARPEntry @@ -26,7 +33,7 @@ func parseARPTable(path string) ([]ARPEntry, error) { return entries, err } - for _, line := range strings.Split(string(content), "\n") { + for line := range strings.SplitSeq(string(content), "\n") { fields := strings.Fields(line) if len(fields) > 5 { var entry ARPEntry diff --git a/internal/station/dhcp/backend.go b/internal/station/dhcp/backend.go index 6d6a4c8..5a983f2 100644 --- a/internal/station/dhcp/backend.go +++ b/internal/station/dhcp/backend.go @@ -33,7 +33,7 @@ func (b *Backend) Initialize(config backend.BackendConfig) error { b.dhcpLeasesPath = config.DHCPLeasesPath if b.dhcpLeasesPath == "" { - b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases" + b.dhcpLeasesPath = "/var/lib/udhcpd/udhcpd.leases" } return nil diff --git a/internal/station/dhcp/parser.go b/internal/station/dhcp/parser.go index 831d09e..4571ff9 100644 --- a/internal/station/dhcp/parser.go +++ b/internal/station/dhcp/parser.go @@ -2,6 +2,11 @@ package dhcp import ( "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "net" "os" "os/exec" "regexp" @@ -10,17 +15,120 @@ import ( "github.com/nemunaire/repeater/internal/models" ) -// parseDHCPLeases reads and parses DHCP lease file +// ParseLeases reads a DHCP lease file and returns the active leases. The +// format is auto-detected: udhcpd (BusyBox) writes a small binary file with +// fixed-size records, while ISC dhcpd uses a text format. Most embedded / +// router systems use one or the other. +func ParseLeases(path string) ([]models.DHCPLease, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if looksLikeUdhcpd(data) { + return parseUdhcpdLeases(data) + } + return parseISCLeases(bytes.NewReader(data)) +} + +// parseDHCPLeases is kept for backwards compatibility with callers in this +// package. New code should call ParseLeases. func parseDHCPLeases(path string) ([]models.DHCPLease, error) { + return ParseLeases(path) +} + +// looksLikeUdhcpd returns true when the file looks like a BusyBox udhcpd +// lease file. The format is: 8-byte big-endian timestamp header followed by +// 36-byte records. Detection rule: total size minus 8 is a positive multiple +// of 36 and the header decodes to a plausible Unix time. +func looksLikeUdhcpd(data []byte) bool { + const headerSize = 8 + const recordSize = 36 + + if len(data) < headerSize+recordSize { + return false + } + body := len(data) - headerSize + if body <= 0 || body%recordSize != 0 { + return false + } + ts := int64(binary.BigEndian.Uint64(data[:headerSize])) + // 2001-01-01 to 2100-01-01: anything outside this range is almost + // certainly not a Unix timestamp and we're looking at text instead. + const minTs = 978307200 // 2001-01-01 + const maxTs = 4102444800 // 2100-01-01 + return ts >= minTs && ts <= maxTs +} + +// parseUdhcpdLeases parses BusyBox's udhcpd lease file format. +// +// File layout (see networking/udhcp/dhcpd.c in BusyBox): +// +// [ 8 bytes: written_at — int64 big-endian Unix timestamp ] +// [ struct dyn_lease × N, 36 bytes each, packed: +// uint32 expires (big-endian, seconds remaining at write time) +// uint32 lease_nip (network-order IPv4) +// uint8 lease_mac[6] +// char hostname[20] (NUL-terminated, padded) +// uint8 pad[2] +// ] +func parseUdhcpdLeases(data []byte) ([]models.DHCPLease, error) { + const headerSize = 8 + const recordSize = 36 + + if len(data) < headerSize { + return nil, fmt.Errorf("udhcpd lease file too short: %d bytes", len(data)) + } + + body := data[headerSize:] + leases := make([]models.DHCPLease, 0, len(body)/recordSize) + + for off := 0; off+recordSize <= len(body); off += recordSize { + rec := body[off : off+recordSize] + + // expires: rec[0:4] — we keep records even when expired so the UI + // keeps showing the IP for a station whose DHCP lease just lapsed + // (the station is still associated with hostapd). + ipBytes := rec[4:8] + mac := net.HardwareAddr(rec[8:14]) + hostname := nullTerminated(rec[14:34]) + + // Skip the all-zero "empty slot" entries udhcpd leaves in the file. + if isAllZero(rec[4:14]) { + continue + } + + ip := net.IPv4(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]).String() + leases = append(leases, models.DHCPLease{ + IP: ip, + MAC: mac.String(), + Hostname: hostname, + }) + } + + return leases, nil +} + +func nullTerminated(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + b = b[:i] + } + return strings.TrimSpace(string(b)) +} + +func isAllZero(b []byte) bool { + for _, c := range b { + if c != 0 { + return false + } + } + return true +} + +// parseISCLeases parses ISC dhcpd's text lease format. +func parseISCLeases(r io.Reader) ([]models.DHCPLease, error) { var leases []models.DHCPLease - file, err := os.Open(path) - if err != nil { - return leases, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) + scanner := bufio.NewScanner(r) var currentLease models.DHCPLease for scanner.Scan() { @@ -51,7 +159,7 @@ func parseDHCPLeases(path string) ([]models.DHCPLease, error) { } } - return leases, nil + return leases, scanner.Err() } // getARPInfo retrieves ARP table information using arp command @@ -65,8 +173,7 @@ func getARPInfo() (map[string]string, error) { return arpInfo, err } - lines := strings.Split(string(output), "\n") - for _, line := range lines { + for line := range strings.SplitSeq(string(output), "\n") { if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { ip := matches[1] mac := matches[2] diff --git a/internal/station/dhcp/parser_test.go b/internal/station/dhcp/parser_test.go new file mode 100644 index 0000000..8a57960 --- /dev/null +++ b/internal/station/dhcp/parser_test.go @@ -0,0 +1,75 @@ +package dhcp + +import ( + "encoding/binary" + "testing" +) + +func TestParseUdhcpdLeases(t *testing.T) { + // Build a synthetic udhcpd lease file: 8-byte big-endian timestamp + // header, then two 36-byte records. + buf := make([]byte, 0, 8+36*3) + header := make([]byte, 8) + binary.BigEndian.PutUint64(header, 1714560000) // 2024-05-01 + buf = append(buf, header...) + + // Record 1: 192.168.1.42, aa:bb:cc:dd:ee:ff, "iPhone" + r1 := make([]byte, 36) + binary.BigEndian.PutUint32(r1[0:4], 3600) // expires + r1[4], r1[5], r1[6], r1[7] = 192, 168, 1, 42 // IP + r1[8], r1[9], r1[10] = 0xaa, 0xbb, 0xcc // MAC + r1[11], r1[12], r1[13] = 0xdd, 0xee, 0xff // + copy(r1[14:34], "iPhone\x00") // hostname (NUL-padded) + buf = append(buf, r1...) + + // Record 2: empty slot (all zero MAC/IP) — should be skipped + r2 := make([]byte, 36) + buf = append(buf, r2...) + + // Record 3: 10.0.0.1, b8:27:eb:11:22:33, no hostname + r3 := make([]byte, 36) + binary.BigEndian.PutUint32(r3[0:4], 7200) + r3[4], r3[5], r3[6], r3[7] = 10, 0, 0, 1 + r3[8], r3[9], r3[10] = 0xb8, 0x27, 0xeb + r3[11], r3[12], r3[13] = 0x11, 0x22, 0x33 + buf = append(buf, r3...) + + if !looksLikeUdhcpd(buf) { + t.Fatalf("synthetic file not detected as udhcpd") + } + + leases, err := parseUdhcpdLeases(buf) + if err != nil { + t.Fatalf("parseUdhcpdLeases: %v", err) + } + if len(leases) != 2 { + t.Fatalf("expected 2 leases, got %d: %+v", len(leases), leases) + } + + if leases[0].IP != "192.168.1.42" { + t.Errorf("lease[0].IP = %q, want %q", leases[0].IP, "192.168.1.42") + } + if leases[0].MAC != "aa:bb:cc:dd:ee:ff" { + t.Errorf("lease[0].MAC = %q, want %q", leases[0].MAC, "aa:bb:cc:dd:ee:ff") + } + if leases[0].Hostname != "iPhone" { + t.Errorf("lease[0].Hostname = %q, want %q", leases[0].Hostname, "iPhone") + } + + if leases[1].IP != "10.0.0.1" { + t.Errorf("lease[1].IP = %q, want %q", leases[1].IP, "10.0.0.1") + } + if leases[1].MAC != "b8:27:eb:11:22:33" { + t.Errorf("lease[1].MAC = %q, want %q", leases[1].MAC, "b8:27:eb:11:22:33") + } + if leases[1].Hostname != "" { + t.Errorf("lease[1].Hostname = %q, want empty", leases[1].Hostname) + } +} + +func TestLooksLikeUdhcpdRejectsText(t *testing.T) { + text := []byte("# The format of this file is documented in dhcpd.leases(5).\nlease 192.168.1.42 {\n starts 0 2024/05/01 00:00:00;\n hardware ethernet aa:bb:cc:dd:ee:ff;\n client-hostname \"iPhone\";\n}\n") + if looksLikeUdhcpd(text) { + t.Errorf("ISC text file misdetected as udhcpd") + } +} diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go index 6ebbba8..00de686 100644 --- a/internal/station/hostapd/backend.go +++ b/internal/station/hostapd/backend.go @@ -30,6 +30,8 @@ type Backend struct { // IP / hostname correlation - populated by periodic DHCP lease correlation ipByMAC map[string]string // MAC -> IP mapping hostnameByMAC map[string]string // MAC -> hostname mapping + + correlator *DHCPCorrelator } // NewBackend creates a new hostapd backend @@ -70,11 +72,20 @@ func (b *Backend) Initialize(config backend.BackendConfig) error { log.Printf("Warning: Failed to load initial stations: %v", err) } + // Hostapd only knows MACs. Pair every station with its IP/hostname by + // running a background correlator that polls the DHCP lease file and + // the ARP table. Without this, the device list shows MACs only. + b.correlator = NewDHCPCorrelator(b, config.DHCPLeasesPath, config.ARPTablePath) + b.correlator.Start() + return nil } // Close cleans up backend resources func (b *Backend) Close() error { + if b.correlator != nil { + b.correlator.Stop() + } b.StopEventMonitoring() return nil } diff --git a/internal/station/hostapd/correlation.go b/internal/station/hostapd/correlation.go index c653dce..5536600 100644 --- a/internal/station/hostapd/correlation.go +++ b/internal/station/hostapd/correlation.go @@ -1,32 +1,38 @@ package hostapd import ( - "bufio" "log" - "os" "strings" "time" - "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station/arp" + "github.com/nemunaire/repeater/internal/station/dhcp" ) -// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses +// 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 string) *DHCPCorrelator { +func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath, arpTablePath string) *DHCPCorrelator { if dhcpLeasesPath == "" { - dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases" + dhcpLeasesPath = "/var/lib/misc/udhcpd.leases" + } + if arpTablePath == "" { + arpTablePath = "/proc/net/arp" } return &DHCPCorrelator{ backend: backend, dhcpLeasesPath: dhcpLeasesPath, + arpTablePath: arpTablePath, stopChan: make(chan struct{}), } } @@ -39,7 +45,7 @@ func (dc *DHCPCorrelator) Start() { dc.running = true go dc.correlationLoop() - log.Printf("DHCP correlation started for hostapd backend") + log.Printf("DHCP/ARP correlation started (leases=%s, arp=%s)", dc.dhcpLeasesPath, dc.arpTablePath) } // Stop stops the correlation loop @@ -72,65 +78,54 @@ func (dc *DHCPCorrelator) correlationLoop() { } } -// correlate performs one correlation cycle +// 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() { - // Parse DHCP leases - leases, err := parseDHCPLeases(dc.dhcpLeasesPath) - if err != nil { - log.Printf("Warning: Failed to parse DHCP leases: %v", err) - return - } - - // Build MAC -> IP and MAC -> hostname mappings. Hostnames live in the - // dhcpd "client-hostname" field and let us label devices instead of - // falling back to bare MAC addresses. macToIP := make(map[string]string) macToHostname := make(map[string]string) - for _, lease := range leases { - mac := strings.ToLower(lease.MAC) - macToIP[mac] = lease.IP - if lease.Hostname != "" { - macToHostname[mac] = lease.Hostname + + // 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) } - -// parseDHCPLeases reads and parses DHCP lease file -func parseDHCPLeases(path string) ([]models.DHCPLease, error) { - var leases []models.DHCPLease - - file, err := os.Open(path) - if err != nil { - return leases, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - var currentLease models.DHCPLease - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if strings.HasPrefix(line, "lease ") { - ip := strings.Fields(line)[1] - currentLease = models.DHCPLease{IP: ip} - } else if strings.Contains(line, "hardware ethernet") { - mac := strings.Fields(line)[2] - mac = strings.TrimSuffix(mac, ";") - currentLease.MAC = mac - } else if strings.Contains(line, "client-hostname") { - hostname := strings.Fields(line)[1] - hostname = strings.Trim(hostname, `";`) - currentLease.Hostname = hostname - } else if line == "}" { - if currentLease.IP != "" && currentLease.MAC != "" { - leases = append(leases, currentLease) - } - currentLease = models.DHCPLease{} - } - } - - return leases, scanner.Err() -}