diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index fa506bf..449d404 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -81,6 +81,11 @@ var querySources = []querySource{ // wording, so "какая температура на улице?" still reaches the weather // source. {"home", (*reactiveHandler).queryHome}, + // Next to "home" and for the same reason: "какие устройства в сети?" is a + // question about the LAN, and the recall pass would otherwise answer it + // from an old note about the router. Its matcher needs a network word plus + // an ask plus a device noun, so "интернет не работает" is untouched. + {"network", (*reactiveHandler).queryNetwork}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, @@ -299,6 +304,22 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, return h.home.homeSummary(ctxH) } +// queryNetwork answers a question about the LAN with a bounded scan. There is +// no confirm turn because nothing is changed, and no way to widen the range +// because Scan takes no target — the utterance selects the question, never the +// subnet. +func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) { + if !isNetworkQuery(t.dec.Utterance) { + return "", false + } + if h.netscan == nil { + // Claim the turn: "сканирование не настроено" is true, and general + // knowledge would answer with an invented list of devices. + return "сканирование сети не настроено.", true + } + return h.netscan.scanSummary(ctx) +} + func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { if !isWeatherQuery(t.dec.Utterance) { return "", false diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go new file mode 100644 index 0000000..95cc923 --- /dev/null +++ b/cmd/mavend/netscan.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/kami/maven/internal/config" + "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 + +// 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 + +// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven +// never puts a discovery packet on the network. +// +// Unlike the house, a scan is a READ, so it is a query source rather than an +// act: there is no allowlist row and no confirm turn, because nothing changes. +// What makes that safe is that the range is not an argument — see +// internal/netscan's package comment. +type netWiring struct { + scanner *netscan.Scanner + subnets []string +} + +// wireNetScan builds the scanner. nil unless the block is enabled and valid. +func wireNetScan(cfg *config.Config) *netWiring { + nc, ok := cfg.NetScanner() + if !ok { + return nil + } + if err := netscan.Validate(nc); err != nil { + // config.validate already ran this, so reaching here is a programming + // error rather than a config one. Not fatal: the scanner off is a + // working Maven. + log.Printf("netscan: not wired: %v", err) + return nil + } + return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets} +} + +// scanSummary answers "какие устройства в сети?" in one line. +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) + if err != nil { + log.Printf("netscan: scan: %v", err) + return "не получилось просканировать сеть.", true + } + if len(hosts) == 0 { + return "в сети никого не нашла.", true + } + shown := 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 { + ps := make([]string, 0, len(h.Ports)) + for _, p := range h.Ports { + ps = append(ps, fmt.Sprintf("%d", p)) + } + s += " (" + strings.Join(ps, ", ") + ")" + } + parts = append(parts, 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)) + } + return out + ".", true +} + +// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства, +// 5+ устройств, and the teens are all the last form. +func hostWord(n int) string { + if n%100 >= 11 && n%100 <= 14 { + return "устройств" + } + switch n % 10 { + case 1: + return "устройство" + case 2, 3, 4: + return "устройства" + default: + return "устройств" + } +} + +// isNetworkQuery recognises a question about the LAN, narrowly. It needs a +// network word AND an ask: "интернет не работает" is a complaint, not a request +// to scan, and a scan she runs unasked is exactly the noisy behaviour the +// bounds exist to prevent. +func isNetworkQuery(u string) bool { + s := strings.ToLower(strings.TrimSpace(u)) + if s == "" { + return false + } + network := false + for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} { + if strings.Contains(s, w) { + network = true + break + } + } + if !network { + return false + } + // An explicit ask to scan, or a phrase that can only be about the LAN. + // "кто в сети" carries no device noun but means nothing else. + for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} { + if strings.Contains(s, w) { + return true + } + } + ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") || + homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи") + if !ask { + return false + } + for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} { + if strings.Contains(s, w) { + return true + } + } + return false +} diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go new file mode 100644 index 0000000..7feaaee --- /dev/null +++ b/cmd/mavend/netscan_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/config" +) + +func TestWireNetScanOffUnlessEnabled(t *testing.T) { + for name, cfg := range map[string]*config.Config{ + "no block": {}, + "written but dark": {NetScan: &config.NetScanConfig{ + Subnets: []string{"192.168.1.0/24"}, + }}, + "enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}}, + "enabled but public": {NetScan: &config.NetScanConfig{ + Subnets: []string{"8.8.8.0/24"}, Enabled: true, + }}, + "enabled but far too wide": {NetScan: &config.NetScanConfig{ + Subnets: []string{"10.0.0.0/8"}, Enabled: true, + }}, + } { + t.Run(name, func(t *testing.T) { + if w := wireNetScan(cfg); w != nil { + t.Fatal("the scanner must not wire for this config") + } + }) + } + + var w *netWiring + if _, ok := w.scanSummary(context.Background()); ok { + t.Fatal("a nil wiring must not claim a query") + } + + ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ + Subnets: []string{"192.168.1.0/24"}, Enabled: true, + }}) + if ok == nil { + t.Fatal("a valid enabled block should wire") + } +} + +// A loopback /32 with nothing listening on the scanned port: the summary must +// come back honest rather than inventing a host. This also exercises the real +// dialer end to end without touching anything outside this box. +func TestScanSummaryOnAnEmptyRange(t *testing.T) { + w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ + // 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, + }}) + 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 out == "" { + t.Fatal("empty summary") + } + // Persona: feminine self-reference, informal address, no pet names. + low := strings.ToLower(out) + for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} { + if strings.Contains(low, bad) { + t.Errorf("persona violation %q in %q", bad, out) + } + } +} + +func TestHostWordAgreesWithTheCount(t *testing.T) { + for n, want := range map[int]string{ + 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств", + 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства", + 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств", + } { + if got := hostWord(n); got != want { + t.Errorf("hostWord(%d) = %q, want %q", n, got, want) + } + } +} + +func TestIsNetworkQuery(t *testing.T) { + yes := []string{ + "какие устройства в сети?", + "кто в сети?", + "просканируй сеть", + "покажи устройства в локальной сети", + "сколько машин в сети", + } + no := []string{ + "", + "интернет не работает", + "сеть какая-то медленная", + "я в сети инстаграма", + "что включено дома?", + "напомни оплатить интернет", + } + for _, u := range yes { + if !isNetworkQuery(u) { + t.Errorf("isNetworkQuery(%q) = false, want true", u) + } + } + for _, u := range no { + if isNetworkQuery(u) { + t.Errorf("isNetworkQuery(%q) = true, want false", u) + } + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 1850ae9..9d21a06 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -98,6 +98,11 @@ type reactiveHandler struct { // act allowlist and tool.Executor, like every other mutating act. home *homeWiring + // netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the + // default. A scan is a read, so it has no allowlist row; what keeps it + // safe is that its range comes from config and from nowhere else. + netscan *netWiring + weatherProvider weather.Provider weatherLocation string // default location for weather queries diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 1ca9d8b..3ef93f8 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -53,6 +53,9 @@ type voiceWiring struct { // enabled (Vikunja #256). Its devices land in the same allowlist as every // other act, so nothing else here has to know about it. home *homeWiring + // netscan — the LAN scanner, nil unless the `netscan` block is enabled + // (Vikunja #257). + netscan *netWiring } // close releases the listener + worker conns. Safe to call on nil (when @@ -161,6 +164,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if w.home != nil { exec = exec.WithHome(w.home.caller()) } + // 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) matcher := tool.NewMatcher(coreAPI) // ----- weather provider (Open-Meteo when configured, Stub otherwise) ----- @@ -245,6 +251,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem now: time.Now, feedsOn: cfg.Feeds != nil, home: w.home, + netscan: w.netscan, // nil unless `crawl.on_demand` is on: reading a page he names is a // capability, and capabilities are off unless configured. crawler: onDemandCrawler(cfg), diff --git a/deploy/mavend.json b/deploy/mavend.json index 7f1449f..9d4900f 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -56,6 +56,15 @@ "enabled": false }, + "netscan": { + "subnets": ["192.168.1.0/24"], + "ports": [22, 80, 443, 8080], + "timeout": "400ms", + "rate": 50, + "max_hosts": 256, + "enabled": false + }, + "nexus": { "url": "http://nexus:9740" }, "praxis": { "url": "http://praxis:8989" }, "hexis": { "url": "http://hexis:9741" }, diff --git a/internal/config/config.go b/internal/config/config.go index 0191662..a679351 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/morning" + "github.com/kami/maven/internal/netscan" "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/update" "github.com/robfig/cron/v3" @@ -243,6 +244,11 @@ type Config struct { // disabled ⇒ Maven neither reads the house nor touches it, and no house row // exists in the act allowlist. See SmartHomeConfig. SmartHome *SmartHomeConfig `json:"smarthome,omitempty"` + + // NetScan — the LAN scanner (Vikunja #257). nil / absent / disabled ⇒ + // Maven never puts a packet on the network looking for hosts. See + // NetScanConfig. + NetScan *NetScanConfig `json:"netscan,omitempty"` } // MCPConfig — the MCP client block. Servers are dark until one has @@ -324,6 +330,51 @@ func (c *Config) SmartHomeClient() (smarthome.Config, bool) { }, true } +// NetScanConfig — the LAN scanner block (Vikunja #257). Dark until +// `"enabled": true`. +// +// The important field is Subnets, and it is the ONLY source of a scan target. +// Nothing an utterance, a router or a scanned host says can widen or move the +// range: internal/netscan.Scanner.Scan takes no target argument at all. Each +// subnet must be private and no larger than netscan.MaxPrefixHosts addresses +// (a /22), enforced at config load rather than at the first spoken scan. +type NetScanConfig struct { + // Subnets — CIDRs to scan, "192.168.1.0/24". + Subnets []string `json:"subnets,omitempty"` + + // Ports — TCP ports to try per host. Empty ⇒ 22, 80, 443, 8080. + Ports []int `json:"ports,omitempty"` + + // Timeout — per-connection budget. 0 ⇒ 400ms. + Timeout Duration `json:"timeout,omitempty"` + + // Rate — connections per second across the whole scan. 0 ⇒ 50. Low on + // purpose: a scan should look like background traffic, not a portscan. + Rate int `json:"rate,omitempty"` + + // MaxHosts — cap on addresses probed per scan. 0 ⇒ 256. + MaxHosts int `json:"max_hosts,omitempty"` + + // Enabled — false (the default) keeps a written block dark. + Enabled bool `json:"enabled,omitempty"` +} + +// NetScanner maps the config block onto the netscan package's own type. +// ok=false when absent or disabled, so validation and daemon wiring cannot +// drift on the mapping. +func (c *Config) NetScanner() (netscan.Config, bool) { + if c.NetScan == nil || !c.NetScan.Enabled { + return netscan.Config{}, false + } + return netscan.Config{ + Subnets: c.NetScan.Subnets, + Ports: c.NetScan.Ports, + Timeout: time.Duration(c.NetScan.Timeout), + Rate: c.NetScan.Rate, + MaxHosts: c.NetScan.MaxHosts, + }, true +} + // MCPServerConfig — one MCP server. type MCPServerConfig struct { // Name — the local handle. It prefixes every tool this server contributes @@ -1188,6 +1239,11 @@ func (c *Config) applyDefaults() { c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh) } + // Same rule for the scanner. + if c.NetScan != nil && !c.NetScan.Enabled { + c.NetScan = nil + } + // Same rule for the crawler: a block that neither answers on demand nor // watches anything has nothing to do, so it is normalised to "off". if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 { @@ -1308,6 +1364,13 @@ func (c *Config) validate() error { return err } } + // A scanner pointed at the public internet, or at a /8, fails here rather + // than after the packets have already left. + if nc, ok := c.NetScanner(); ok { + if err := netscan.Validate(nc); err != nil { + return err + } + } if len(c.MorningRoutines) > 0 { if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil { return err diff --git a/internal/netscan/netscan.go b/internal/netscan/netscan.go new file mode 100644 index 0000000..8f2b7c9 --- /dev/null +++ b/internal/netscan/netscan.go @@ -0,0 +1,356 @@ +// Package netscan discovers hosts on the LAN Maven is configured to look at +// (Vikunja #257, docs/plans/12-bluetooth-network-scan.md). +// +// A scan is a read, but an unbounded scanner on a home network is noisy and is +// trivially pointed somewhere it should not go, so the whole package is built +// around four rules: +// +// - The target range NEVER comes from an utterance, a router, an LLM or a +// device. Scan takes no target argument at all: it reads only the CIDRs in +// the config block. There is deliberately no exported way to scan an +// arbitrary range, so no amount of prompt injection or a rogue reply from a +// scanned host can retarget it. +// - Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and +// no larger than MaxPrefixHosts addresses. Scanning the public internet +// from his flat is not a thing Maven does, and /8 is not a home LAN. +// - Rate-limited. Connections leave at a fixed rate, so a scan looks like +// background traffic rather than a portscan to anything watching. +// - Bounded in total. MaxHosts, a per-connection timeout and the caller's +// context all cap the work; a scan that runs long returns what it has. +// +// It is a TCP-connect scan (net.DialTimeout) and an ARP-table read. No raw +// sockets, no SYN scan, no privileges: mavend does not run as root and this +// does not ask it to. +package netscan + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "net/netip" + "os" + "sort" + "strings" + "sync" + "time" +) + +// DefaultPorts — what a scan looks at when the config names nothing. Chosen to +// answer "what is this box" on a home network, not to find a way in. +var DefaultPorts = []int{22, 80, 443, 8080} + +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 + // DefaultMaxHosts — cap on addresses probed in one scan. + DefaultMaxHosts = 256 + // MaxPrefixHosts — the largest CIDR that may be configured, in addresses. + // 1024 is a /22: generous for a flat, and far short of anything that would + // take minutes or wake up a neighbour's IDS. + MaxPrefixHosts = 1024 + // maxParallel — in-flight dials. The rate limiter is the real throttle; + // this only stops a slow subnet from piling up file descriptors. + maxParallel = 16 + // arpFile — the kernel's ARP cache. Reading it is free and needs no packet. + arpFile = "/proc/net/arp" +) + +var ( + // ErrNotConfigured — no netscan block, or it is disabled. + ErrNotConfigured = errors.New("netscan: not configured") + // ErrNoSubnets — enabled with nothing to scan. + ErrNoSubnets = errors.New("netscan: no subnets configured") +) + +// Config — the bounds of every scan. There is nothing here that can be +// overridden at call time. +type Config struct { + // Subnets — the ONLY ranges that are ever probed, as CIDRs. Each must be + // private and no bigger than MaxPrefixHosts. + Subnets []string + // Ports — TCP ports to try on each host. Empty ⇒ DefaultPorts. + Ports []int + // Timeout — per-connection budget. 0 ⇒ DefaultTimeout. + Timeout time.Duration + // Rate — connections per second. 0 ⇒ DefaultRate. + Rate int + // MaxHosts — cap on addresses probed per scan. 0 ⇒ DefaultMaxHosts. + MaxHosts int +} + +// Host is one machine the scan saw. +type Host struct { + // Addr — the IP. + Addr string + // MAC — from the ARP cache, empty when the kernel has no entry. + MAC string + // Ports — open TCP ports, ascending. + Ports []int +} + +// Up reports whether anything at all answered for this host. +func (h Host) Up() bool { return len(h.Ports) > 0 || h.MAC != "" } + +// Validate rejects a block that cannot safely run, at config-load time rather +// than at the first spoken scan. This is the guard the whole package rides on: +// if it passes, every later scan is inside these bounds by construction. +func Validate(c Config) error { + if len(c.Subnets) == 0 { + return ErrNoSubnets + } + for _, s := range c.Subnets { + p, err := netip.ParsePrefix(strings.TrimSpace(s)) + if err != nil { + return fmt.Errorf("netscan: subnet %q: %w", s, err) + } + if !p.Addr().Is4() { + return fmt.Errorf("netscan: subnet %q: only IPv4 is scanned", s) + } + if !isPrivate(p.Addr()) { + return fmt.Errorf("netscan: subnet %q is not a private range: Maven does not scan the public internet", s) + } + if n := prefixHosts(p); n > MaxPrefixHosts { + return fmt.Errorf("netscan: subnet %q covers %d addresses, limit is %d: narrow the prefix", s, n, MaxPrefixHosts) + } + } + for _, port := range c.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("netscan: port %d out of range", port) + } + } + if c.Rate < 0 || c.MaxHosts < 0 || c.Timeout < 0 { + return errors.New("netscan: rate, max_hosts and timeout must not be negative") + } + return nil +} + +// isPrivate — RFC1918, CGNAT and link-local. Loopback counts: scanning this box +// is harmless and is how the tests run. +func isPrivate(a netip.Addr) bool { + if a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() { + return true + } + // 100.64.0.0/10, the carrier-grade NAT range Tailscale hands out. + cgnat := netip.MustParsePrefix("100.64.0.0/10") + return cgnat.Contains(a) +} + +// prefixHosts — addresses covered by a v4 prefix. +func prefixHosts(p netip.Prefix) int { + bits := 32 - p.Bits() + if bits >= 31 { + return MaxPrefixHosts + 1 + } + return 1 << bits +} + +// Scanner probes the configured subnets. Build it with New; the config it holds +// is the config it was validated with, and nothing mutates it afterwards. +type Scanner struct { + cfg Config + // dial is the connect seam; tests swap it. + dial func(ctx context.Context, addr string, timeout time.Duration) bool + // arp is the ARP-cache seam; tests swap it. + arp func() (map[string]string, error) +} + +// New builds a scanner. Validate first — this does not. +func New(cfg Config) *Scanner { + if len(cfg.Ports) == 0 { + cfg.Ports = append([]int(nil), DefaultPorts...) + } + if cfg.Timeout <= 0 { + cfg.Timeout = DefaultTimeout + } + if cfg.Rate <= 0 { + cfg.Rate = DefaultRate + } + if cfg.MaxHosts <= 0 { + cfg.MaxHosts = DefaultMaxHosts + } + 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 { + var out []netip.Addr + for _, cidr := range s.cfg.Subnets { + p, err := netip.ParsePrefix(strings.TrimSpace(cidr)) + if err != nil { + 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) + } + } + return out +} + +// 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) { + if len(s.cfg.Subnets) == 0 { + return nil, ErrNoSubnets + } + arp, err := s.arp() + if err != nil { + // A missing /proc/net/arp costs MAC addresses, not the scan. + arp = map[string]string{} + } + + // One token per connection, at Rate per second, shared by every worker. + interval := time.Second / time.Duration(s.cfg.Rate) + if interval <= 0 { + interval = time.Millisecond + } + tick := time.NewTicker(interval) + defer tick.Stop() + + type result struct { + addr string + ports []int + } + targets := s.targets() + results := make(chan result, len(targets)) + sem := make(chan struct{}, maxParallel) + var wg sync.WaitGroup + +scan: + for _, a := range targets { + addr := a.String() + for _, port := range s.cfg.Ports { + // Checked before the select as well as inside it: select picks + // randomly among ready cases, so at a high rate the ticker would + // sometimes win over an already-canceled context and let one more + // probe out. + if ctx.Err() != nil { + break scan + } + select { + case <-ctx.Done(): + break scan + case <-tick.C: + } + sem <- struct{}{} + wg.Add(1) + go func(addr string, port int) { + defer wg.Done() + defer func() { <-sem }() + if s.dial(ctx, net.JoinHostPort(addr, itoa(port)), s.cfg.Timeout) { + results <- result{addr: addr, ports: []int{port}} + } + }(addr, port) + } + } + wg.Wait() + close(results) + + byAddr := map[string]*Host{} + for r := range results { + h := byAddr[r.addr] + if h == nil { + h = &Host{Addr: r.addr} + byAddr[r.addr] = h + } + h.Ports = append(h.Ports, r.ports...) + } + // A host in the ARP cache is up even with every port closed — it answered + // an ARP request, which is the cheapest liveness signal there is. + for _, a := range targets { + addr := a.String() + mac, ok := arp[addr] + if !ok { + continue + } + if byAddr[addr] == nil { + byAddr[addr] = &Host{Addr: addr} + } + byAddr[addr].MAC = mac + } + + out := make([]Host, 0, len(byAddr)) + for _, h := range byAddr { + sort.Ints(h.Ports) + out = append(out, *h) + } + sort.Slice(out, func(i, j int) bool { + ai, _ := netip.ParseAddr(out[i].Addr) + aj, _ := netip.ParseAddr(out[j].Addr) + return ai.Less(aj) + }) + return out, nil +} + +func itoa(n int) string { return fmt.Sprintf("%d", n) } + +func dialTCP(ctx context.Context, addr string, timeout time.Duration) bool { + d := net.Dialer{Timeout: timeout} + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + c, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return false + } + _ = c.Close() + return true +} + +func readARP() (map[string]string, error) { + f, err := os.Open(arpFile) + if err != nil { + return nil, err + } + defer f.Close() + return parseARP(f) +} + +// parseARP reads the kernel's ARP table. Incomplete entries (all-zero MAC, +// flags 0x0) are dropped: they mean "we asked and nobody answered", which is +// the opposite of a discovered host. +func parseARP(r io.Reader) (map[string]string, error) { + out := map[string]string{} + sc := bufio.NewScanner(r) + first := true + for sc.Scan() { + if first { // header row + first = false + continue + } + f := strings.Fields(sc.Text()) + if len(f) < 4 { + continue + } + ip, flags, mac := f[0], f[2], f[3] + if flags == "0x0" || mac == "00:00:00:00:00:00" { + continue + } + if _, err := netip.ParseAddr(ip); err != nil { + continue + } + out[ip] = mac + } + return out, sc.Err() +} diff --git a/internal/netscan/netscan_test.go b/internal/netscan/netscan_test.go new file mode 100644 index 0000000..3de34bb --- /dev/null +++ b/internal/netscan/netscan_test.go @@ -0,0 +1,198 @@ +package netscan + +import ( + "context" + "errors" + "net/netip" + "strings" + "sync" + "testing" + "time" +) + +func TestValidateBounds(t *testing.T) { + ok := []Config{ + {Subnets: []string{"192.168.1.0/24"}}, + {Subnets: []string{"10.0.0.0/24", "172.16.5.0/28"}, Ports: []int{22, 80}}, + {Subnets: []string{"127.0.0.1/32"}}, + {Subnets: []string{"100.64.1.0/24"}}, // CGNAT / tailnet + } + for _, c := range ok { + if err := Validate(c); err != nil { + t.Errorf("Validate(%v) = %v, want nil", c.Subnets, err) + } + } + + bad := map[string]Config{ + "nothing to scan": {}, + "public range": {Subnets: []string{"8.8.8.0/24"}}, + "whole internet": {Subnets: []string{"0.0.0.0/0"}}, + "a slash-8 is not a flat": {Subnets: []string{"10.0.0.0/8"}}, + "a /16 is too big": {Subnets: []string{"192.168.0.0/16"}}, + "not a cidr": {Subnets: []string{"192.168.1.1"}}, + "ipv6": {Subnets: []string{"fd00::/120"}}, + "garbage": {Subnets: []string{"выключи свет"}}, + "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}, + } + for name, c := range bad { + if err := Validate(c); err == nil { + t.Errorf("Validate(%s) = nil, want an error", strings.ReplaceAll(name, "\n", " ")) + } + } + if !errors.Is(Validate(Config{}), ErrNoSubnets) { + t.Error("an empty block should report ErrNoSubnets") + } +} + +// The whole safety story: a scanner probes its configured range and nothing +// else. There is no API that takes a target, so this test asserts the negative +// by watching every address the dialer was handed. +func TestScanOnlyTouchesConfiguredSubnet(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 10000}) + inside := netip.MustParsePrefix("192.168.9.0/29") + + var mu sync.Mutex + var seen []string + s.dial = func(_ context.Context, addr string, _ time.Duration) bool { + mu.Lock() + seen = append(seen, addr) + mu.Unlock() + return addr == "192.168.9.3:80" + } + s.arp = func() (map[string]string, error) { return map[string]string{}, nil } + + hosts, err := s.Scan(context.Background()) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(hosts) != 1 || hosts[0].Addr != "192.168.9.3" || len(hosts[0].Ports) != 1 { + t.Fatalf("hosts = %+v", hosts) + } + // A /29 is 8 addresses; network (.0) and broadcast (.7) are skipped. + if len(seen) != 6 { + t.Errorf("probed %d addresses, want 6 (a /29 minus network and broadcast): %v", len(seen), seen) + } + for _, a := range seen { + host, _, _ := strings.Cut(a, ":") + ip, err := netip.ParseAddr(host) + if err != nil || !inside.Contains(ip) { + t.Errorf("probed %q, which is outside the configured subnet", a) + } + } +} + +func TestScanHonoursMaxHosts(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 10000, MaxHosts: 3}) + var mu sync.Mutex + n := 0 + s.dial = func(_ context.Context, _ string, _ time.Duration) bool { + mu.Lock() + n++ + mu.Unlock() + return false + } + s.arp = func() (map[string]string, error) { return nil, nil } + if _, err := s.Scan(context.Background()); err != nil { + t.Fatal(err) + } + if n != 3 { + t.Errorf("dialed %d times, want 3 (MaxHosts)", n) + } +} + +// The rate limiter must actually gate: 6 probes at 200/s cannot finish in less +// than ~25ms. Asserted loosely, since a CI box is not a stopwatch. +func TestScanIsRateLimited(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.9.0/29"}, 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 } + start := time.Now() + if _, err := s.Scan(context.Background()); err != nil { + t.Fatal(err) + } + if el := time.Since(start); el < 20*time.Millisecond { + t.Errorf("6 probes at 200/s took %v: the rate limiter is not gating", el) + } +} + +func TestScanStopsOnCanceledContext(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 10000}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.dial = func(context.Context, string, time.Duration) bool { + t.Error("a canceled scan still dialed") + return false + } + s.arp = func() (map[string]string, error) { return nil, nil } + if _, err := s.Scan(ctx); err != nil { + t.Fatal(err) + } +} + +// A host with every port closed but an ARP entry is still up. A host outside +// the configured range must not be reported even if the kernel knows it — +// otherwise the ARP cache, which is populated by the network rather than by +// Maven, would widen the answer past what he configured. +func TestARPFillsMACWithinTheConfiguredRangeOnly(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: 10000}) + s.dial = func(context.Context, string, time.Duration) bool { return false } + s.arp = func() (map[string]string, error) { + return map[string]string{ + "192.168.9.2": "aa:bb:cc:dd:ee:ff", + "10.9.9.9": "11:22:33:44:55:66", + }, nil + } + hosts, err := s.Scan(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(hosts) != 1 { + t.Fatalf("hosts = %+v", hosts) + } + if hosts[0].Addr != "192.168.9.2" || hosts[0].MAC != "aa:bb:cc:dd:ee:ff" { + t.Errorf("host = %+v", hosts[0]) + } + if !hosts[0].Up() { + t.Error("an ARP entry with no open port is still a live host") + } +} + +const arpFixture = `IP address HW type Flags HW address Mask Device +192.168.1.1 0x1 0x2 3c:84:6a:11:22:33 * wlp1s0 +192.168.1.50 0x1 0x2 b8:27:eb:44:55:66 * wlp1s0 +192.168.1.77 0x1 0x0 00:00:00:00:00:00 * wlp1s0 +not-an-ip 0x1 0x2 de:ad:be:ef:00:01 * wlp1s0 +short line +` + +func TestParseARP(t *testing.T) { + got, err := parseARP(strings.NewReader(arpFixture)) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want 2: %v", len(got), got) + } + if got["192.168.1.1"] != "3c:84:6a:11:22:33" || got["192.168.1.50"] != "b8:27:eb:44:55:66" { + t.Errorf("entries = %v", got) + } + if _, ok := got["192.168.1.77"]; ok { + t.Error("an incomplete ARP entry (flags 0x0) is not a discovered host") + } +} + +func TestNewAppliesDefaults(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.1.0/24"}}) + if len(s.cfg.Ports) != len(DefaultPorts) || s.cfg.Rate != DefaultRate || + s.cfg.MaxHosts != DefaultMaxHosts || s.cfg.Timeout != DefaultTimeout { + t.Errorf("defaults not applied: %+v", s.cfg) + } + // The defaults must not alias the package slice, or a second scanner could + // rewrite DefaultPorts through it. + s.cfg.Ports[0] = 9999 + if DefaultPorts[0] == 9999 { + t.Error("New aliased DefaultPorts") + } +}