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:
+96
-29
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user