repeater/internal/station/dhcp/parser_test.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

75 lines
2.4 KiB
Go

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