package scanner import ( "context" "encoding/json" "fmt" "net" "os/exec" "runtime" "strings" "sync" "time" ) // Device represents a discovered network device. type Device struct { IP string `json:"ip"` MAC string `json:"mac"` Hostname string `json:"hostname"` Vendor string `json:"vendor"` } // Scan scans the given subnet (e.g. "192.168.1.0/24"). // If subnet is empty, the local network is auto-detected. func Scan(ctx context.Context, subnet string) ([]Device, error) { // Auto-detect subnet if not provided if subnet == "" { detected, err := detectLocalSubnet() if err != nil { return nil, fmt.Errorf("auto-detect subnet: %w", err) } subnet = detected } // Parse CIDR _, ipNet, err := net.ParseCIDR(subnet) if err != nil { return nil, fmt.Errorf("parse subnet %q: %w", subnet, err) } // Collect all host IPs in the subnet hosts := hostsInNet(ipNet) // Concurrent ping sweep using TCP fallback (no raw ICMP needed) const maxConcurrent = 50 sem := make(chan struct{}, maxConcurrent) var mu sync.Mutex alive := make(map[string]bool) var wg sync.WaitGroup for _, ip := range hosts { // Check context before spawning select { case <-ctx.Done(): break default: } wg.Add(1) sem <- struct{}{} go func(ipStr string) { defer wg.Done() defer func() { <-sem }() if isAlive(ipStr) { mu.Lock() alive[ipStr] = true mu.Unlock() } }(ip.String()) } wg.Wait() if len(alive) == 0 { return []Device{}, nil } // Read ARP table to get MAC addresses arpTable, err := readARPTable() if err != nil { // Non-fatal: continue without MAC info arpTable = map[string]string{} } // Build results with reverse DNS lookups var devices []Device for ip := range alive { d := Device{IP: ip} if mac, ok := arpTable[ip]; ok { d.MAC = mac } // Reverse DNS lookup with short timeout hostname := reverseLookup(ip) d.Hostname = hostname devices = append(devices, d) } return devices, nil } // ToJSON serialises a slice of Devices to a JSON string. func ToJSON(devices []Device) string { b, _ := json.Marshal(devices) return string(b) } // detectLocalSubnet returns the CIDR of the first non-loopback IPv4 interface. func detectLocalSubnet() (string, error) { ifaces, err := net.Interfaces() if err != nil { return "", err } for _, iface := range ifaces { if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { continue } addrs, err := iface.Addrs() if err != nil { continue } for _, addr := range addrs { var ip net.IP var mask net.IPMask switch v := addr.(type) { case *net.IPNet: ip = v.IP mask = v.Mask case *net.IPAddr: ip = v.IP } if ip == nil || ip.IsLoopback() { continue } ip = ip.To4() if ip == nil { continue } // Reconstruct CIDR from network address network := ip.Mask(mask) ones, _ := mask.Size() return fmt.Sprintf("%s/%d", network.String(), ones), nil } } return "", fmt.Errorf("no suitable network interface found") } // hostsInNet returns all usable host addresses within the given network. func hostsInNet(ipNet *net.IPNet) []net.IP { var ips []net.IP // Start from network address, increment ip := cloneIP(ipNet.IP) // Increment past network address inc(ip) for ipNet.Contains(ip) { // Skip broadcast (last address) next := cloneIP(ip) inc(next) if !ipNet.Contains(next) { break // current ip is broadcast, stop } ips = append(ips, cloneIP(ip)) inc(ip) } return ips } func cloneIP(ip net.IP) net.IP { clone := make(net.IP, len(ip)) copy(clone, ip) return clone } func inc(ip net.IP) { for j := len(ip) - 1; j >= 0; j-- { ip[j]++ if ip[j] > 0 { break } } } // isAlive attempts a TCP connection to common ports to determine if a host is up. func isAlive(ip string) bool { timeout := 300 * time.Millisecond ports := []string{"445", "80", "22", "443"} for _, port := range ports { conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, port), timeout) if err == nil { conn.Close() return true } } return false } // readARPTable reads the ARP cache and returns a map of IP -> MAC. func readARPTable() (map[string]string, error) { table := make(map[string]string) if runtime.GOOS == "windows" { return readARPWindows(table) } return readARPLinux(table) } // readARPWindows parses `arp -a` output on Windows. // Example line: " 192.168.1.1 aa-bb-cc-dd-ee-ff dynamic" func readARPWindows(table map[string]string) (map[string]string, error) { out, err := exec.Command("arp", "-a").Output() if err != nil { return table, err } for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) fields := strings.Fields(line) if len(fields) < 2 { continue } ip := fields[0] if net.ParseIP(ip) == nil { continue } mac := fields[1] // Normalise aa-bb-cc-dd-ee-ff → aa:bb:cc:dd:ee:ff mac = strings.ReplaceAll(mac, "-", ":") if len(mac) == 17 { table[ip] = mac } } return table, nil } // readARPLinux parses /proc/net/arp on Linux. // Format: IP address HW type Flags HW address Mask Device func readARPLinux(table map[string]string) (map[string]string, error) { data, err := readFile("/proc/net/arp") if err != nil { return table, err } lines := strings.Split(string(data), "\n") // Skip header line for _, line := range lines[1:] { fields := strings.Fields(line) if len(fields) < 4 { continue } ip := fields[0] mac := fields[3] if net.ParseIP(ip) != nil && len(mac) == 17 && mac != "00:00:00:00:00:00" { table[ip] = mac } } return table, nil } // readFile is a thin wrapper so we can test Linux ARP parsing on any OS. func readFile(path string) ([]byte, error) { return exec.Command("cat", path).Output() } // reverseLookup returns the hostname for an IP via reverse DNS, with a short timeout. func reverseLookup(ip string) string { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() names, err := net.DefaultResolver.LookupAddr(ctx, ip) if err != nil || len(names) == 0 { return "" } // Strip trailing dot from FQDN return strings.TrimSuffix(names[0], ".") }