repeater/internal/device/device.go
Pierre-Olivier Mercier 2b3a5b89f8 Add configuration system and ARP-based device discovery
Implement comprehensive configuration management with CLI flags for WiFi interface, device discovery method, and file paths. Add ARP table parsing as an alternative to DHCP leases for more reliable device detection. Improve WiFi scanning to handle concurrent scan requests gracefully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-01 23:31:01 +07:00

224 lines
5.3 KiB
Go

package device
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"regexp"
"strings"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/models"
)
// ARPEntry represents an entry in the ARP table
type ARPEntry struct {
IP net.IP
HWType int
Flags int
HWAddress net.HardwareAddr
Mask string
Device string
}
// GetConnectedDevices returns a list of connected devices
func GetConnectedDevices(cfg *config.Config) ([]models.ConnectedDevice, error) {
if cfg.UseARPDiscovery {
return getDevicesFromARP(cfg)
}
return getDevicesFromDHCP(cfg)
}
// getDevicesFromARP discovers devices using ARP table
func getDevicesFromARP(cfg *config.Config) ([]models.ConnectedDevice, error) {
var devices []models.ConnectedDevice
arpEntries, err := parseARPTable(cfg.ARPTablePath)
if err != nil {
return devices, err
}
for _, entry := range arpEntries {
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
if entry.Flags == 2 || entry.Flags == 6 {
device := models.ConnectedDevice{
Name: "", // No hostname available from ARP
MAC: entry.HWAddress.String(),
IP: entry.IP.String(),
Type: guessDeviceType("", entry.HWAddress.String()),
}
devices = append(devices, device)
}
}
return devices, nil
}
// getDevicesFromDHCP discovers devices using DHCP leases and ARP validation
func getDevicesFromDHCP(cfg *config.Config) ([]models.ConnectedDevice, error) {
var devices []models.ConnectedDevice
// Read DHCP leases
leases, err := parseDHCPLeases(cfg.DHCPLeasesPath)
if err != nil {
return devices, err
}
// Get ARP information for validation
arpInfo, err := getARPInfo()
if err != nil {
return devices, err
}
for _, lease := range leases {
device := models.ConnectedDevice{
Name: lease.Hostname,
MAC: lease.MAC,
IP: lease.IP,
Type: guessDeviceType(lease.Hostname, lease.MAC),
}
// Check if the device is still connected via ARP
if _, exists := arpInfo[lease.IP]; exists {
devices = append(devices, device)
}
}
return devices, nil
}
// parseARPTable reads and parses ARP table from /proc/net/arp format
func parseARPTable(path string) ([]ARPEntry, error) {
var entries []ARPEntry
content, err := os.ReadFile(path)
if err != nil {
return entries, err
}
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(line)
if len(fields) > 5 {
var entry ARPEntry
// Parse HWType (hex format)
if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil {
continue
}
// Parse Flags (hex format)
if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil {
continue
}
// Parse IP address
entry.IP = net.ParseIP(fields[0])
if entry.IP == nil {
continue
}
// Parse MAC address
entry.HWAddress, err = net.ParseMAC(fields[3])
if err != nil {
continue
}
entry.Mask = fields[4]
entry.Device = fields[5]
entries = append(entries, entry)
}
}
return entries, nil
}
// parseDHCPLeases reads and parses DHCP lease file
func parseDHCPLeases(path string) ([]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)
var currentLease models.DHCPLease
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "lease ") {
ip := strings.Fields(line)[1]
currentLease = models.DHCPLease{IP: ip}
} else if strings.Contains(line, "hardware ethernet") {
mac := strings.Fields(line)[2]
mac = strings.TrimSuffix(mac, ";")
currentLease.MAC = mac
} else if strings.Contains(line, "client-hostname") {
hostname := strings.Fields(line)[1]
hostname = strings.Trim(hostname, `";`)
currentLease.Hostname = hostname
} else if line == "}" {
if currentLease.IP != "" && currentLease.MAC != "" {
leases = append(leases, currentLease)
}
currentLease = models.DHCPLease{}
}
}
return leases, nil
}
// getARPInfo retrieves ARP table information
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
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
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
}
// guessDeviceType attempts to guess device type from hostname and MAC address
func guessDeviceType(hostname, mac string) string {
hostname = strings.ToLower(hostname)
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
return "mobile"
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
return "tablet"
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
return "laptop"
}
// Guess by MAC prefix (OUI)
if len(mac) >= 8 {
macPrefix := strings.ToUpper(mac[:8])
switch macPrefix {
case "00:50:56", "00:0C:29", "00:05:69": // VMware
return "laptop"
case "08:00:27": // VirtualBox
return "laptop"
default:
return "mobile"
}
}
return "unknown"
}