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:
@@ -295,9 +295,13 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
// Claim the turn rather than fall through: "дом не подключён" is true,
|
||||
// and letting general knowledge answer would be an invented house.
|
||||
return "дом не подключён — я его не вижу.", true
|
||||
// Fall through rather than claim the turn. A capability that is off
|
||||
// must not change what an unconfigured box answers: "какая температура
|
||||
// в доме?" on a Maven with no smarthome block reached recall before
|
||||
// this source existed, and a stored fact is a better answer than
|
||||
// "дом не подключён" from a house that was never configured. The
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -313,9 +317,9 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Claim the turn: "сканирование не настроено" is true, and general
|
||||
// knowledge would answer with an invented list of devices.
|
||||
return "сканирование сети не настроено.", true
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
+157
-21
@@ -5,20 +5,33 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
// longer than this has already failed as a turn, so the scan returns whatever
|
||||
// it found rather than keeping him waiting.
|
||||
const scanBudget = 20 * time.Second
|
||||
//
|
||||
// It has to be consistent with the shipped defaults or every scan is truncated:
|
||||
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
|
||||
// second is a little over ten seconds plus the tail dials. 30s leaves room for
|
||||
// that without pretending a slower rate would fit.
|
||||
const scanBudget = 30 * time.Second
|
||||
|
||||
// scanReadOut — how many hosts she names out loud. The rest are a count: a
|
||||
// spoken list of twenty IP addresses is not an answer.
|
||||
const scanReadOut = 6
|
||||
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
|
||||
// to be two full sweeps of the LAN, up to a thousand connections each. The
|
||||
// network does not change on the scale of a follow-up question, and the cheapest
|
||||
// packet is the one not sent.
|
||||
const scanCacheTTL = 2 * time.Minute
|
||||
|
||||
// scanReadOut — how many hosts go into the written record's first lines before
|
||||
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
|
||||
const scanReadOut = 20
|
||||
|
||||
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||
// never puts a discovery packet on the network.
|
||||
@@ -30,10 +43,21 @@ const scanReadOut = 6
|
||||
type netWiring struct {
|
||||
scanner *netscan.Scanner
|
||||
subnets []string
|
||||
// api — where the address list is WRITTEN. The spoken answer is a count
|
||||
// and a shape, so the detail has to land somewhere readable; a note under
|
||||
// source "scan:lan" puts it on /history and, through the intake decorator,
|
||||
// on /events. It is also the only record that Maven put packets on the LAN
|
||||
// at all. nil ⇒ nothing is written, which is what the tests use.
|
||||
api ipc.CoreAPI
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached netscan.Result
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||
func wireNetScan(cfg *config.Config) *netWiring {
|
||||
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
|
||||
nc, ok := cfg.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -45,29 +69,125 @@ func wireNetScan(cfg *config.Config) *netWiring {
|
||||
log.Printf("netscan: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one line.
|
||||
// scan runs a scan, or reuses one younger than scanCacheTTL.
|
||||
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
|
||||
return w.cached, nil
|
||||
}
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
res, err := w.scanner.Scan(scanCtx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
w.cached, w.cachedAt = res, now
|
||||
// Written on a fresh scan only: the record is a trace of packets going out,
|
||||
// so a cached answer must not forge a second one.
|
||||
w.writeScanRecord(ctx, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one spoken line.
|
||||
//
|
||||
// It does NOT read addresses out. This is the query path, so the reply goes to
|
||||
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
|
||||
// a digit stream nobody can follow through a speaker. She says how many and
|
||||
// what shape they are; the addresses go into a note (see writeScanRecord).
|
||||
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
hosts, err := w.scanner.Scan(ctx)
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return "в сети никого не нашла.", true
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
// false claim, and the addresses at the end are the ones that go missing.
|
||||
tail := ""
|
||||
if res.Truncated {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
shown := hosts
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
out += tail
|
||||
if w.api != nil {
|
||||
out += ". список записала"
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// scanShape describes the hosts by what they answer on, which is the part of
|
||||
// the answer that carries meaning out loud: "два с вебом" says more about the
|
||||
// flat than four octets do.
|
||||
func scanShape(hosts []netscan.Host) string {
|
||||
var web, ssh, quiet int
|
||||
for _, h := range hosts {
|
||||
hasWeb, hasSSH := false, false
|
||||
for _, p := range h.Ports {
|
||||
switch p {
|
||||
case 80, 443, 8080:
|
||||
hasWeb = true
|
||||
case 22:
|
||||
hasSSH = true
|
||||
}
|
||||
}
|
||||
if hasWeb {
|
||||
web++
|
||||
}
|
||||
if hasSSH {
|
||||
ssh++
|
||||
}
|
||||
// No open port at all: seen only through the ARP cache.
|
||||
if len(h.Ports) == 0 {
|
||||
quiet++
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if web > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с вебом", web))
|
||||
}
|
||||
if ssh > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
|
||||
}
|
||||
if quiet > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d молча", quiet))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "из них " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writeScanRecord stores the address list as a note. This is both where the
|
||||
// detail becomes readable and the only trace that a scan happened at all: a
|
||||
// scan is a read, but "when did she last put packets on the LAN" deserves an
|
||||
// answer.
|
||||
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
|
||||
shown := res.Hosts
|
||||
if len(shown) > scanReadOut {
|
||||
shown = shown[:scanReadOut]
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, h := range shown {
|
||||
s := h.Addr
|
||||
if len(h.Ports) > 0 {
|
||||
@@ -77,13 +197,17 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
}
|
||||
s += " (" + strings.Join(ps, ", ") + ")"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
if h.MAC != "" {
|
||||
s += " " + h.MAC
|
||||
}
|
||||
lines = append(lines, s)
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; "))
|
||||
if len(hosts) > len(shown) {
|
||||
out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown))
|
||||
if len(res.Hosts) > len(shown) {
|
||||
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
|
||||
}
|
||||
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
|
||||
log.Printf("netscan: write scan note: %v", err)
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
@@ -111,13 +235,25 @@ func isNetworkQuery(u string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Whole tokens for the network nouns: the bare substring "сети" is inside
|
||||
// "посетил", so "сколько машин я посетил?" used to read as a request to
|
||||
// scan the LAN. The prefix forms below are stems that have no such
|
||||
// collisions.
|
||||
network := false
|
||||
for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
|
||||
if homeWord(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
@@ -23,7 +27,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg); w != nil {
|
||||
if w := wireNetScan(cfg, nil); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
@@ -36,7 +40,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}})
|
||||
}}, nil)
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
@@ -50,7 +54,7 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}})
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
@@ -109,3 +113,59 @@ func TestIsNetworkQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notingAPI counts the notes a scan writes, and remembers the last one.
|
||||
type notingAPI struct {
|
||||
ipc.CoreAPI
|
||||
n int
|
||||
last string
|
||||
}
|
||||
|
||||
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
||||
a.n++
|
||||
a.last = text
|
||||
return int64(a.n), nil
|
||||
}
|
||||
|
||||
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
||||
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
||||
// answer anybody can use. The addresses belong in the written record.
|
||||
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
api := ¬ingAPI{}
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
||||
}}, api)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
||||
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "нашла 1 устройство") {
|
||||
t.Errorf("reply = %q, want a count", out)
|
||||
}
|
||||
if api.n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1", api.n)
|
||||
}
|
||||
if !strings.Contains(api.last, "127.0.0.1") {
|
||||
t.Errorf("the written record has no addresses: %q", api.last)
|
||||
}
|
||||
|
||||
// A follow-up question inside the TTL reuses the answer: two questions in
|
||||
// a row must not be two sweeps of the LAN.
|
||||
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
}
|
||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||
w.netscan = wireNetScan(cfg)
|
||||
w.netscan = wireNetScan(cfg, coreAPI)
|
||||
matcher := tool.NewMatcher(coreAPI)
|
||||
|
||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@
|
||||
"subnets": ["192.168.1.0/24"],
|
||||
"ports": [22, 80, 443, 8080],
|
||||
"timeout": "400ms",
|
||||
"rate": 50,
|
||||
"rate": 100,
|
||||
"max_hosts": 256,
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
+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) }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/stt
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/tts
|
||||
Reference in New Issue
Block a user