package netscan import ( "context" "errors" "net/netip" "strings" "sync" "testing" "time" ) func TestValidateBounds(t *testing.T) { ok := []Config{ {Subnets: []string{"192.168.1.0/24"}}, {Subnets: []string{"10.0.0.0/24", "172.16.5.0/28"}, Ports: []int{22, 80}}, {Subnets: []string{"127.0.0.1/32"}}, {Subnets: []string{"100.64.1.0/24"}}, // CGNAT / tailnet } for _, c := range ok { if err := Validate(c); err != nil { t.Errorf("Validate(%v) = %v, want nil", c.Subnets, err) } } bad := map[string]Config{ "nothing to scan": {}, "public range": {Subnets: []string{"8.8.8.0/24"}}, "whole internet": {Subnets: []string{"0.0.0.0/0"}}, "a slash-8 is not a flat": {Subnets: []string{"10.0.0.0/8"}}, "a /16 is too big": {Subnets: []string{"192.168.0.0/16"}}, "not a cidr": {Subnets: []string{"192.168.1.1"}}, "ipv6": {Subnets: []string{"fd00::/120"}}, "garbage": {Subnets: []string{"выключи свет"}}, "bad port": {Subnets: []string{"192.168.1.0/24"}, Ports: []int{0}}, "huge port": {Subnets: []string{"192.168.1.0/24"}, Ports: []int{70000}}, "negative rate": {Subnets: []string{"192.168.1.0/24"}, Rate: -1}, } for name, c := range bad { if err := Validate(c); err == nil { t.Errorf("Validate(%s) = nil, want an error", strings.ReplaceAll(name, "\n", " ")) } } if !errors.Is(Validate(Config{}), ErrNoSubnets) { t.Error("an empty block should report ErrNoSubnets") } } // The whole safety story: a scanner probes its configured range and nothing // else. There is no API that takes a target, so this test asserts the negative // by watching every address the dialer was handed. func TestScanOnlyTouchesConfiguredSubnet(t *testing.T) { s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 10000}) inside := netip.MustParsePrefix("192.168.9.0/29") var mu sync.Mutex var seen []string s.dial = func(_ context.Context, addr string, _ time.Duration) bool { mu.Lock() seen = append(seen, addr) mu.Unlock() return addr == "192.168.9.3:80" } s.arp = func() (map[string]string, error) { return map[string]string{}, nil } hosts, err := s.Scan(context.Background()) if err != nil { t.Fatalf("Scan: %v", err) } if len(hosts) != 1 || hosts[0].Addr != "192.168.9.3" || len(hosts[0].Ports) != 1 { t.Fatalf("hosts = %+v", hosts) } // A /29 is 8 addresses; network (.0) and broadcast (.7) are skipped. if len(seen) != 6 { t.Errorf("probed %d addresses, want 6 (a /29 minus network and broadcast): %v", len(seen), seen) } for _, a := range seen { host, _, _ := strings.Cut(a, ":") ip, err := netip.ParseAddr(host) if err != nil || !inside.Contains(ip) { t.Errorf("probed %q, which is outside the configured subnet", a) } } } func TestScanHonoursMaxHosts(t *testing.T) { s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 10000, MaxHosts: 3}) var mu sync.Mutex n := 0 s.dial = func(_ context.Context, _ string, _ time.Duration) bool { mu.Lock() n++ mu.Unlock() return false } s.arp = func() (map[string]string, error) { return nil, nil } if _, err := s.Scan(context.Background()); err != nil { t.Fatal(err) } if n != 3 { t.Errorf("dialed %d times, want 3 (MaxHosts)", n) } } // The rate limiter must actually gate: 6 probes at 200/s cannot finish in less // than ~25ms. Asserted loosely, since a CI box is not a stopwatch. func TestScanIsRateLimited(t *testing.T) { s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 200}) s.dial = func(context.Context, string, time.Duration) bool { return false } s.arp = func() (map[string]string, error) { return nil, nil } start := time.Now() if _, err := s.Scan(context.Background()); err != nil { t.Fatal(err) } if el := time.Since(start); el < 20*time.Millisecond { t.Errorf("6 probes at 200/s took %v: the rate limiter is not gating", el) } } func TestScanStopsOnCanceledContext(t *testing.T) { s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 10000}) ctx, cancel := context.WithCancel(context.Background()) cancel() s.dial = func(context.Context, string, time.Duration) bool { t.Error("a canceled scan still dialed") return false } s.arp = func() (map[string]string, error) { return nil, nil } if _, err := s.Scan(ctx); err != nil { t.Fatal(err) } } // A host with every port closed but an ARP entry is still up. A host outside // the configured range must not be reported even if the kernel knows it — // otherwise the ARP cache, which is populated by the network rather than by // Maven, would widen the answer past what he configured. func TestARPFillsMACWithinTheConfiguredRangeOnly(t *testing.T) { s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 10000}) s.dial = func(context.Context, string, time.Duration) bool { return false } s.arp = func() (map[string]string, error) { return map[string]string{ "192.168.9.2": "aa:bb:cc:dd:ee:ff", "10.9.9.9": "11:22:33:44:55:66", }, nil } hosts, err := s.Scan(context.Background()) if err != nil { t.Fatal(err) } if len(hosts) != 1 { t.Fatalf("hosts = %+v", hosts) } if hosts[0].Addr != "192.168.9.2" || hosts[0].MAC != "aa:bb:cc:dd:ee:ff" { t.Errorf("host = %+v", hosts[0]) } if !hosts[0].Up() { t.Error("an ARP entry with no open port is still a live host") } } const arpFixture = `IP address HW type Flags HW address Mask Device 192.168.1.1 0x1 0x2 3c:84:6a:11:22:33 * wlp1s0 192.168.1.50 0x1 0x2 b8:27:eb:44:55:66 * wlp1s0 192.168.1.77 0x1 0x0 00:00:00:00:00:00 * wlp1s0 not-an-ip 0x1 0x2 de:ad:be:ef:00:01 * wlp1s0 short line ` func TestParseARP(t *testing.T) { got, err := parseARP(strings.NewReader(arpFixture)) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("got %d entries, want 2: %v", len(got), got) } if got["192.168.1.1"] != "3c:84:6a:11:22:33" || got["192.168.1.50"] != "b8:27:eb:44:55:66" { t.Errorf("entries = %v", got) } if _, ok := got["192.168.1.77"]; ok { t.Error("an incomplete ARP entry (flags 0x0) is not a discovered host") } } func TestNewAppliesDefaults(t *testing.T) { s := New(Config{Subnets: []string{"192.168.1.0/24"}}) if len(s.cfg.Ports) != len(DefaultPorts) || s.cfg.Rate != DefaultRate || s.cfg.MaxHosts != DefaultMaxHosts || s.cfg.Timeout != DefaultTimeout { t.Errorf("defaults not applied: %+v", s.cfg) } // The defaults must not alias the package slice, or a second scanner could // rewrite DefaultPorts through it. s.cfg.Ports[0] = 9999 if DefaultPorts[0] == 9999 { t.Error("New aliased DefaultPorts") } }