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" }