netscan: stop the scan wedging, and stop it overstating the LAN
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
This commit is contained in:
@@ -35,6 +35,7 @@ func TestValidateBounds(t *testing.T) {
|
||||
"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 {
|
||||
@@ -63,10 +64,11 @@ func TestScanOnlyTouchesConfiguredSubnet(t *testing.T) {
|
||||
}
|
||||
s.arp = func() (map[string]string, error) { return map[string]string{}, nil }
|
||||
|
||||
hosts, err := s.Scan(context.Background())
|
||||
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)
|
||||
}
|
||||
@@ -144,10 +146,11 @@ func TestARPFillsMACWithinTheConfiguredRangeOnly(t *testing.T) {
|
||||
"10.9.9.9": "11:22:33:44:55:66",
|
||||
}, nil
|
||||
}
|
||||
hosts, err := s.Scan(context.Background())
|
||||
res, err := s.Scan(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hosts := res.Hosts
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("hosts = %+v", hosts)
|
||||
}
|
||||
@@ -196,3 +199,96 @@ func TestNewAppliesDefaults(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user