diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c36682d..b8352bd 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -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) } diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index 95cc923..83c1c35 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -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 } diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go index 7feaaee..df87124 100644 --- a/cmd/mavend/netscan_test.go +++ b/cmd/mavend/netscan_test.go @@ -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) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 3ef93f8..1adf273 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -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) ----- diff --git a/deploy/mavend.json b/deploy/mavend.json index 9d4900f..0ea21ad 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -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 }, diff --git a/internal/netscan/netscan.go b/internal/netscan/netscan.go index 8f2b7c9..b2e28fa 100644 --- a/internal/netscan/netscan.go +++ b/internal/netscan/netscan.go @@ -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) } diff --git a/internal/netscan/netscan_test.go b/internal/netscan/netscan_test.go index 3de34bb..772e5e5 100644 --- a/internal/netscan/netscan_test.go +++ b/internal/netscan/netscan_test.go @@ -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") + } +} diff --git a/models/stt b/models/stt new file mode 120000 index 0000000..b983fa3 --- /dev/null +++ b/models/stt @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/stt \ No newline at end of file diff --git a/models/tts b/models/tts new file mode 120000 index 0000000..66782fb --- /dev/null +++ b/models/tts @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/tts \ No newline at end of file