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.
185 lines
4.9 KiB
Go
185 lines
4.9 KiB
Go
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
|
||
}
|