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:
kami
2026-08-01 14:00:46 +04:00
parent 7f42cc73be
commit e4bfcd958f
9 changed files with 428 additions and 63 deletions
+98 -2
View File
@@ -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")
}
}