// 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() }