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
+96 -29
View File
@@ -46,8 +46,16 @@ const (
// DefaultTimeout — per-connection budget. Short: on a LAN a live host
// answers in single-digit milliseconds, and a filtered port never answers.
DefaultTimeout = 400 * time.Millisecond
// DefaultRate — connections per second across the whole scan.
DefaultRate = 50
// DefaultRate — connections per second across the whole scan. A default
// /24 at four ports is 1016 probes, so this is also what decides whether
// the shipped configuration fits inside the caller's budget: at 100/s it
// takes about ten seconds. Lowering it means a truncated scan, which is
// reported rather than hidden, but it is still a worse answer.
DefaultRate = 100
// MaxRate — the highest configurable rate. The dial loop floors the ticker
// interval at a millisecond, so anything above this was already a lie; say
// so at config load instead of silently clamping.
MaxRate = 1000
// DefaultMaxHosts — cap on addresses probed in one scan.
DefaultMaxHosts = 256
// MaxPrefixHosts — the largest CIDR that may be configured, in addresses.
@@ -127,6 +135,9 @@ func Validate(c Config) error {
if c.Rate < 0 || c.MaxHosts < 0 || c.Timeout < 0 {
return errors.New("netscan: rate, max_hosts and timeout must not be negative")
}
if c.Rate > MaxRate {
return fmt.Errorf("netscan: rate %d is above the ceiling of %d connections per second", c.Rate, MaxRate)
}
return nil
}
@@ -177,43 +188,89 @@ func New(cfg Config) *Scanner {
return &Scanner{cfg: cfg, dial: dialTCP, arp: readARP}
}
// targets expands the configured subnets into addresses, skipping the network
// and broadcast address of each, capped at MaxHosts. Deterministic order, so
// two scans of an unchanged network read the same.
func (s *Scanner) targets() []netip.Addr {
// expand lists the scannable addresses of one CIDR, skipping the network and
// broadcast address.
func expand(cidr string) []netip.Addr {
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
if err != nil {
return nil
}
p = p.Masked()
first := p.Addr()
var out []netip.Addr
for _, cidr := range s.cfg.Subnets {
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
if err != nil {
for a := first; p.Contains(a); a = a.Next() {
// Skip the network address; the broadcast address is skipped by
// looking one ahead.
if a == first && p.Bits() < 31 {
continue
}
p = p.Masked()
first := p.Addr()
for a := first; p.Contains(a); a = a.Next() {
if len(out) >= s.cfg.MaxHosts {
return out
}
// Skip the network address; the broadcast address is skipped by
// looking one ahead.
if a == first && p.Bits() < 31 {
continue
}
if p.Bits() < 31 && !p.Contains(a.Next()) {
continue
}
out = append(out, a)
if p.Bits() < 31 && !p.Contains(a.Next()) {
continue
}
out = append(out, a)
}
return out
}
// targets expands the configured subnets into addresses, capped at MaxHosts,
// and reports whether the cap cut anything off. Deterministic order, so two
// scans of an unchanged network read the same.
//
// The subnets are taken round-robin rather than in order. Consuming MaxHosts
// from the first subnet used to leave a second configured LAN 99% unprobed,
// with nothing logged: he named two ranges and got an answer about one.
// Round-robin spends the budget evenly, so every named range is represented
// and the shortfall is reported instead.
func (s *Scanner) targets() ([]netip.Addr, bool) {
lists := make([][]netip.Addr, 0, len(s.cfg.Subnets))
total := 0
for _, cidr := range s.cfg.Subnets {
l := expand(cidr)
if len(l) == 0 {
continue
}
lists = append(lists, l)
total += len(l)
}
out := make([]netip.Addr, 0, min(total, s.cfg.MaxHosts))
for i := 0; len(out) < s.cfg.MaxHosts; i++ {
took := false
for _, l := range lists {
if i >= len(l) {
continue
}
if len(out) >= s.cfg.MaxHosts {
break
}
out = append(out, l[i])
took = true
}
if !took {
break
}
}
return out, len(out) < total
}
// Result is one scan's outcome.
type Result struct {
// Hosts — what answered, ascending by address.
Hosts []Host
// Truncated — the scan did not cover every configured address, because
// MaxHosts cut the target list or the caller's context expired mid-run.
// Callers MUST NOT present a truncated result as the state of the network:
// "нашла 6 устройств" is a claim about the LAN, and a scan that stopped at
// .238 has not earned it.
Truncated bool
}
// Scan probes every configured address and returns the hosts that answered.
//
// It takes no target: the range is the configured one, always. Callers pass a
// context and nothing else, which is the point — see the package comment.
func (s *Scanner) Scan(ctx context.Context) ([]Host, error) {
func (s *Scanner) Scan(ctx context.Context) (Result, error) {
if len(s.cfg.Subnets) == 0 {
return nil, ErrNoSubnets
return Result{}, ErrNoSubnets
}
arp, err := s.arp()
if err != nil {
@@ -233,8 +290,13 @@ func (s *Scanner) Scan(ctx context.Context) ([]Host, error) {
addr string
ports []int
}
targets := s.targets()
results := make(chan result, len(targets))
targets, truncated := s.targets()
// One slot per PROBE, not per host: a worker sends once per open port, so
// a subnet with more open ports than addresses used to fill a host-sized
// buffer and wedge. Nothing drains this channel until wg.Wait returns, and
// the sends carry no select on ctx.Done, so that was a permanent hang of
// the calling turn plus a leak of every worker.
results := make(chan result, len(targets)*len(s.cfg.Ports))
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
@@ -247,10 +309,12 @@ scan:
// sometimes win over an already-canceled context and let one more
// probe out.
if ctx.Err() != nil {
truncated = true
break scan
}
select {
case <-ctx.Done():
truncated = true
break scan
case <-tick.C:
}
@@ -294,6 +358,9 @@ scan:
out := make([]Host, 0, len(byAddr))
for _, h := range byAddr {
sort.Ints(h.Ports)
if !h.Up() {
continue
}
out = append(out, *h)
}
sort.Slice(out, func(i, j int) bool {
@@ -301,7 +368,7 @@ scan:
aj, _ := netip.ParseAddr(out[j].Addr)
return ai.Less(aj)
})
return out, nil
return Result{Hosts: out, Truncated: truncated}, nil
}
func itoa(n int) string { return fmt.Sprintf("%d", n) }
+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")
}
}