e4bfcd958f
The results channel was sized by the number of hosts while each worker sends once per open port, so a subnet with more open ports than addresses filled the buffer and blocked a worker forever. Nothing drains the channel until wg.Wait returns and the sends have no ctx.Done case, so the calling turn hung for the life of the process. Size it by probes. Three more claims the scanner could not back. MaxHosts was spent in order, so the second of two configured subnets got two addresses out of 254 with nothing logged. A run cut short by the cap or the deadline came back indistinguishable from a complete one, and the shipped defaults never fit the budget, so every scan was silently truncated at the top of the range. Scan now reports truncation, targets are taken round-robin, and the default rate and the budget are consistent with a /24. The spoken reply read dotted quads out loud on the voice path. It now says how many devices and what shape, and writes the address list as a note, which is also the only record that Maven put packets on the LAN. The network noun is matched whole so posetil is not a scan, the rate has a stated ceiling, and a repeat question inside two minutes reuses the answer. Both query sources claimed the turn when the capability was off, which let an unconfigured scanner and an unconfigured house swallow questions that used to reach recall. Both now fall through. Found in review of #81 and #80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
295 lines
10 KiB
Go
295 lines
10 KiB
Go
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")
|
|
}
|
|
}
|