diff --git a/backend/internal/arp/arpscan.go b/backend/internal/arp/arpscan.go index 7a41c8e..4fa1b4f 100644 --- a/backend/internal/arp/arpscan.go +++ b/backend/internal/arp/arpscan.go @@ -2,7 +2,11 @@ package arp import ( "log/slog" + "net" + "os" "os/exec" + "sort" + "strconv" "strings" "time" @@ -72,17 +76,13 @@ func Scan(ifaces, args string, strs []string) []models.Host { var foundHosts = []models.Host{} arpArgs = args - if ifaces != "" { + p = resolveScanInterfaces(ifaces) + for _, iface := range p { + slog.Debug("Scanning interface " + iface) + text = scanIface(iface) + slog.Debug("Found IPs: \n" + text) - p = strings.Split(ifaces, " ") - - for _, iface := range p { - slog.Debug("Scanning interface " + iface) - text = scanIface(iface) - slog.Debug("Found IPs: \n" + text) - - foundHosts = append(foundHosts, parseOutput(text, iface)...) - } + foundHosts = append(foundHosts, parseOutput(text, iface)...) } for _, s := range strs { @@ -96,3 +96,192 @@ func Scan(ifaces, args string, strs []string) []models.Host { return foundHosts } + +func resolveScanInterfaces(ifaces string) []string { + configured := strings.Fields(ifaces) + available := usableInterfaceMap() + auto := autoScanInterfaces(available) + selected, invalid, usedAuto := selectScanInterfaces(configured, available, auto) + + if len(invalid) > 0 { + slog.Warn("Ignoring unavailable scan interfaces", "ifaces", strings.Join(invalid, " ")) + } + if usedAuto && len(selected) > 0 { + slog.Warn("Using auto-detected scan interfaces", "ifaces", strings.Join(selected, " ")) + } + + return selected +} + +func selectScanInterfaces(configured []string, available map[string]bool, auto []string) ([]string, []string, bool) { + if len(configured) == 0 { + return uniqueStrings(auto), nil, len(auto) > 0 + } + + var selected []string + var invalid []string + for _, iface := range configured { + if available[iface] { + selected = appendUniqueString(selected, iface) + continue + } + invalid = appendUniqueString(invalid, iface) + } + + if len(selected) > 0 { + return selected, invalid, false + } + + return uniqueStrings(auto), invalid, len(auto) > 0 +} + +func usableInterfaceMap() map[string]bool { + interfaces, err := net.Interfaces() + if err != nil { + slog.Warn("Cannot list network interfaces", "err", err) + return nil + } + + available := make(map[string]bool) + for _, iface := range interfaces { + if isUsableInterface(iface) { + available[iface.Name] = true + } + } + + return available +} + +func isUsableInterface(iface net.Interface) bool { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + return false + } + + addrs, err := iface.Addrs() + if err != nil { + slog.Debug("Cannot list interface addresses", "iface", iface.Name, "err", err) + return false + } + + for _, addr := range addrs { + if hasUsableIPv4(addr) { + return true + } + } + + return false +} + +func hasUsableIPv4(addr net.Addr) bool { + ipNet, ok := addr.(*net.IPNet) + if !ok { + return false + } + + ip := ipNet.IP.To4() + return ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() +} + +func autoScanInterfaces(available map[string]bool) []string { + if len(available) == 0 { + return nil + } + + ifaces := defaultRouteInterfaces(available) + if len(ifaces) > 0 { + return ifaces + } + + for iface := range available { + if isVirtualInterface(iface) { + continue + } + ifaces = append(ifaces, iface) + } + sort.Strings(ifaces) + return ifaces +} + +func defaultRouteInterfaces(available map[string]bool) []string { + data, err := os.ReadFile("/proc/net/route") + if err != nil { + slog.Debug("Cannot read default routes", "err", err) + return nil + } + + var ifaces []string + minMetric := -1 + for _, line := range strings.Split(string(data), "\n")[1:] { + fields := strings.Fields(line) + if len(fields) < 7 || fields[1] != "00000000" { + continue + } + + iface := fields[0] + if !available[iface] || isVirtualInterface(iface) { + continue + } + + metric, err := strconv.Atoi(fields[6]) + if err != nil { + metric = 0 + } + + switch { + case minMetric == -1 || metric < minMetric: + minMetric = metric + ifaces = []string{iface} + case metric == minMetric: + ifaces = appendUniqueString(ifaces, iface) + } + } + + sort.Strings(ifaces) + return ifaces +} + +func isVirtualInterface(iface string) bool { + prefixes := []string{ + "br-", + "docker", + "lxc", + "tailscale", + "tap", + "tun", + "vboxnet", + "veth", + "virbr", + "vmnet", + "wg", + } + + for _, prefix := range prefixes { + if strings.HasPrefix(iface, prefix) { + return true + } + } + + return false +} + +func uniqueStrings(values []string) []string { + var out []string + for _, value := range values { + out = appendUniqueString(out, value) + } + return out +} + +func appendUniqueString(values []string, value string) []string { + if value == "" { + return values + } + + for _, item := range values { + if item == value { + return values + } + } + + return append(values, value) +} diff --git a/backend/internal/arp/arpscan_test.go b/backend/internal/arp/arpscan_test.go new file mode 100644 index 0000000..f4df4ab --- /dev/null +++ b/backend/internal/arp/arpscan_test.go @@ -0,0 +1,69 @@ +package arp + +import ( + "reflect" + "testing" +) + +func TestSelectScanInterfacesKeepsValidConfiguredInterfaces(t *testing.T) { + available := map[string]bool{ + "enp6s0": true, + "wlo1": true, + } + + selected, invalid, usedAuto := selectScanInterfaces( + []string{"enp6s0", "missing0"}, + available, + []string{"wlo1"}, + ) + + if !reflect.DeepEqual(selected, []string{"enp6s0"}) { + t.Fatalf("selected = %#v, want enp6s0", selected) + } + if !reflect.DeepEqual(invalid, []string{"missing0"}) { + t.Fatalf("invalid = %#v, want missing0", invalid) + } + if usedAuto { + t.Fatal("usedAuto = true, want false") + } +} + +func TestSelectScanInterfacesFallsBackWhenAllConfiguredInterfacesAreInvalid(t *testing.T) { + available := map[string]bool{ + "enp6s0": true, + } + + selected, invalid, usedAuto := selectScanInterfaces( + []string{"enP8p1s0"}, + available, + []string{"enp6s0"}, + ) + + if !reflect.DeepEqual(selected, []string{"enp6s0"}) { + t.Fatalf("selected = %#v, want enp6s0", selected) + } + if !reflect.DeepEqual(invalid, []string{"enP8p1s0"}) { + t.Fatalf("invalid = %#v, want enP8p1s0", invalid) + } + if !usedAuto { + t.Fatal("usedAuto = false, want true") + } +} + +func TestSelectScanInterfacesUsesAutoWhenConfigIsBlank(t *testing.T) { + selected, invalid, usedAuto := selectScanInterfaces( + nil, + map[string]bool{"enp6s0": true}, + []string{"enp6s0"}, + ) + + if !reflect.DeepEqual(selected, []string{"enp6s0"}) { + t.Fatalf("selected = %#v, want enp6s0", selected) + } + if len(invalid) != 0 { + t.Fatalf("invalid = %#v, want empty", invalid) + } + if !usedAuto { + t.Fatal("usedAuto = false, want true") + } +}