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() -}