Version, authenticate and fully trace ecosystem calls #84
@@ -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