Version, authenticate and fully trace ecosystem calls #84

Merged
claude merged 135 commits from overnight/eco-versioned-traces into master 2026-08-01 14:50:26 +02:00
9 changed files with 428 additions and 63 deletions
Showing only changes of commit e4bfcd958f - Show all commits
+10 -6
View File
@@ -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
View File
@@ -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
}
+63 -3
View File
@@ -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 := &notingAPI{}
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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
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")
}
}
Symlink
+1
View File
@@ -0,0 +1 @@
/home/kami/apps/Maven/models/stt
Symlink
+1
View File
@@ -0,0 +1 @@
/home/kami/apps/Maven/models/tts