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.
This commit is contained in:
nemunaire 2026-05-02 11:07:37 +08:00
commit 5a3942f351
8 changed files with 267 additions and 72 deletions

View file

@ -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]