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

185 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dhcp
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"os/exec"
"regexp"
"strings"
"github.com/nemunaire/repeater/internal/models"
)
// 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
scanner := bufio.NewScanner(r)
var currentLease models.DHCPLease
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
fields := strings.Fields(line)
switch {
case strings.HasPrefix(line, "lease "):
if len(fields) < 2 {
continue
}
currentLease = models.DHCPLease{IP: fields[1]}
case strings.Contains(line, "hardware ethernet"):
if len(fields) < 3 {
continue
}
currentLease.MAC = strings.TrimSuffix(fields[2], ";")
case strings.Contains(line, "client-hostname"):
if len(fields) < 2 {
continue
}
currentLease.Hostname = strings.Trim(fields[1], `";`)
case line == "}":
if currentLease.IP != "" && currentLease.MAC != "" {
leases = append(leases, currentLease)
}
currentLease = models.DHCPLease{}
}
}
return leases, scanner.Err()
}
// getARPInfo retrieves ARP table information using arp command
// Returns a map of IP -> MAC address
func getARPInfo() (map[string]string, error) {
arpInfo := make(map[string]string)
cmd := exec.Command("arp", "-a")
output, err := cmd.Output()
if err != nil {
return arpInfo, err
}
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]
arpInfo[ip] = mac
}
}
return arpInfo, nil
}