From a8fcb404be65b1cb3b68660aee22705bca577192 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 06:35:10 +0400 Subject: [PATCH] Scan the LAN, bounded to configured subnets (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/netscan/ discovers hosts on the network Maven is configured to look at: a TCP-connect scan (net.DialTimeout, no raw sockets, no privileges) plus a read of the kernel's ARP cache. Wired as a read-only query source, "network", so "какие устройства в сети?" is answered by a scan instead of by whatever old note happens to be nearest. Scanning is a read, but an unbounded scanner on a home LAN is noisy and easy to point somewhere it should not go, so the package is built around four bounds: - Scan takes NO target argument. The range comes from the config block and from nowhere else, so there is no exported way to scan an arbitrary prefix and nothing an utterance, the router, or a scanned host says can retarget it. That is asserted directly: the test watches every address handed to the dialer and fails if one falls outside the configured prefix. The ARP cache — the one input the network itself populates — is filtered to the configured range for the same reason. - Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and no larger than 1024 addresses. 8.8.8.0/24, 0.0.0.0/0 and 10.0.0.0/8 are refused at config load, not after the packets have left. - Rate-limited to a configured connections-per-second across the whole scan, so it looks like background traffic rather than a portscan. - Bounded in total by MaxHosts, a per-connection timeout, a 20s turn budget and the context; a canceled scan stops dialing immediately. Off unless configured: dark without "enabled": true, and applyDefaults normalises a disabled block to nil. deploy/mavend.json carries it disabled. BLUETOOTH IS NOT SHIPPED, AND IS BLOCKED, NOT SKIPPED. The plan's other half (internal/bluetooth/, RSSI presence probes) needs a bluez stack that is not here: bluetoothctl and hcitool are not installed, bluetoothd is not installed, the bluetooth unit is inactive, and org.bluez is not on the system bus. hci0 exists as a kernel device and nothing can talk to it. The docker deploy is further away still — it would need host networking, the D-Bus system socket passed in, and CAP_NET_ADMIN. Writing an exec wrapper around a binary that does not exist, against an output format nothing here can produce, would be a guess dressed as a feature. It needs a decision about privileging the container before any of it is worth writing. Vikunja #257 --- cmd/mavend/actions_query.go | 21 ++ cmd/mavend/netscan.go | 142 ++++++++++++ cmd/mavend/netscan_test.go | 111 ++++++++++ cmd/mavend/voice.go | 5 + cmd/mavend/voicewire.go | 7 + deploy/mavend.json | 9 + internal/config/config.go | 63 ++++++ internal/netscan/netscan.go | 356 +++++++++++++++++++++++++++++++ internal/netscan/netscan_test.go | 198 +++++++++++++++++ 9 files changed, 912 insertions(+) create mode 100644 cmd/mavend/netscan.go create mode 100644 cmd/mavend/netscan_test.go create mode 100644 internal/netscan/netscan.go create mode 100644 internal/netscan/netscan_test.go 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") + } +}