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:
parent
249217d4ad
commit
5a3942f351
8 changed files with 267 additions and 72 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue