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}, "rate past the ceiling": {Subnets: []string{"192.168.1.0/24"}, Rate: MaxRate + 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 } res, err := s.Scan(context.Background()) if err != nil { t.Fatalf("Scan: %v", err) } hosts := res.Hosts 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 } res, err := s.Scan(context.Background()) if err != nil { t.Fatal(err) } hosts := res.Hosts 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") } } // A dense subnet must not wedge the scan. The results channel used to be sized // by the number of HOSTS while a worker sends once per open PORT, so a range // where the open ports outnumber the addresses filled the buffer, blocked a // worker inside wg.Wait, and hung Scan forever. Nothing drains the channel // before wg.Wait returns and the sends carry no ctx.Done case, so the caller's // deadline did not rescue it either. // // Six addresses, eight ports, everything open: 48 sends against a buffer that // used to hold 6. Against the old code this test does not fail, it hangs, so // the scan runs on its own goroutine with a deadline around it. func TestScanDoesNotWedgeWhenPortsOutnumberHosts(t *testing.T) { ports := []int{22, 80, 443, 8080, 8443, 9000, 9100, 9200} s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: ports, Rate: MaxRate}) s.dial = func(context.Context, string, time.Duration) bool { return true } s.arp = func() (map[string]string, error) { return nil, nil } done := make(chan Result, 1) go func() { res, err := s.Scan(context.Background()) if err != nil { t.Error(err) } done <- res }() select { case res := <-done: if len(res.Hosts) != 6 { t.Fatalf("hosts = %d, want 6: %+v", len(res.Hosts), res.Hosts) } for _, h := range res.Hosts { if len(h.Ports) != len(ports) { t.Errorf("%s reported %d open ports, want %d", h.Addr, len(h.Ports), len(ports)) } } case <-time.After(10 * time.Second): t.Fatal("Scan did not return: the results channel is sized by hosts, not by probes") } } // MaxHosts is spent evenly across the configured subnets. Taking it in order // meant a second configured LAN got whatever the first left over, which for a // pair of /24s under the default cap was two addresses out of 254. func TestTargetsSpreadAcrossSubnets(t *testing.T) { s := New(Config{Subnets: []string{"192.168.1.0/24", "192.168.2.0/24"}, MaxHosts: 20}) targets, truncated := s.targets() if !truncated { t.Error("508 addresses under a cap of 20 is a truncated target list") } if len(targets) != 20 { t.Fatalf("targets = %d, want 20", len(targets)) } var first, second int for _, a := range targets { switch { case netip.MustParsePrefix("192.168.1.0/24").Contains(a): first++ case netip.MustParsePrefix("192.168.2.0/24").Contains(a): second++ } } if first != 10 || second != 10 { t.Errorf("split %d/%d across the two subnets, want 10/10", first, second) } } // A run cut short by the caller's deadline reports itself as truncated, so the // spoken answer can stop claiming to describe the whole network. func TestScanReportsTruncation(t *testing.T) { s := New(Config{Subnets: []string{"192.168.9.0/24"}, 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 } ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() res, err := s.Scan(ctx) if err != nil { t.Fatal(err) } if !res.Truncated { t.Error("a scan stopped by the deadline must report Truncated") } full := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: MaxRate}) full.dial = func(context.Context, string, time.Duration) bool { return false } full.arp = func() (map[string]string, error) { return nil, nil } res, err = full.Scan(context.Background()) if err != nil { t.Fatal(err) } if res.Truncated { t.Error("a scan that covered every configured address is not truncated") } }