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>
224 lines
5.3 KiB
Go
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"
|
|
}
|