Scan the LAN, bounded to configured subnets (#257) #81

Closed
claude wants to merge 1 commits from overnight/netscan into overnight/smarthome
9 changed files with 912 additions and 0 deletions
+21
View File
@@ -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
+142
View File
@@ -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
}
+111
View File
@@ -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)
}
}
}
+5
View File
@@ -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
+7
View File
@@ -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),
+9
View File
@@ -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" },
+63
View File
@@ -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
+356
View File
@@ -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()
}
+198
View File
@@ -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")
}
}