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 }