// 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" "strconv" "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. A default // /24 at four ports is 1016 probes, so this is also what decides whether // the shipped configuration fits inside the caller's budget: at 100/s it // takes about ten seconds. Lowering it means a truncated scan, which is // reported rather than hidden, but it is still a worse answer. DefaultRate = 100 // MaxRate — the highest configurable rate. The dial loop floors the ticker // interval at a millisecond, so anything above this was already a lie; say // so at config load instead of silently clamping. MaxRate = 1000 // DefaultMaxHosts — cap on addresses probed in one scan. DefaultMaxHosts = 256 // MaxPrefixHosts — the largest CIDR that may be configured, in addresses. // 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") } if c.Rate > MaxRate { return fmt.Errorf("netscan: rate %d is above the ceiling of %d connections per second", c.Rate, MaxRate) } 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} } // expand lists the scannable addresses of one CIDR, skipping the network and // broadcast address. func expand(cidr string) []netip.Addr { p, err := netip.ParsePrefix(strings.TrimSpace(cidr)) if err != nil { return nil } p = p.Masked() first := p.Addr() var out []netip.Addr for a := first; p.Contains(a); a = a.Next() { // Skip the network address; the broadcast address is skipped by // looking one ahead. if a == first && p.Bits() < 31 { continue } if p.Bits() < 31 && !p.Contains(a.Next()) { continue } out = append(out, a) } return out } // targets expands the configured subnets into addresses, capped at MaxHosts, // and reports whether the cap cut anything off. Deterministic order, so two // scans of an unchanged network read the same. // // The subnets are taken round-robin rather than in order. Consuming MaxHosts // from the first subnet used to leave a second configured LAN 99% unprobed, // with nothing logged: he named two ranges and got an answer about one. // Round-robin spends the budget evenly, so every named range is represented // and the shortfall is reported instead. func (s *Scanner) targets() ([]netip.Addr, bool) { lists := make([][]netip.Addr, 0, len(s.cfg.Subnets)) total := 0 for _, cidr := range s.cfg.Subnets { l := expand(cidr) if len(l) == 0 { continue } lists = append(lists, l) total += len(l) } out := make([]netip.Addr, 0, min(total, s.cfg.MaxHosts)) for i := 0; len(out) < s.cfg.MaxHosts; i++ { took := false for _, l := range lists { if i >= len(l) { continue } if len(out) >= s.cfg.MaxHosts { break } out = append(out, l[i]) took = true } if !took { break } } return out, len(out) < total } // Result is one scan's outcome. type Result struct { // Hosts — what answered, ascending by address. Hosts []Host // Truncated — the scan did not cover every configured address, because // MaxHosts cut the target list or the caller's context expired mid-run. // Callers MUST NOT present a truncated result as the state of the network: // "нашла 6 устройств" is a claim about the LAN, and a scan that stopped at // .238 has not earned it. Truncated bool } // Scan probes every configured address and returns the hosts that answered. // // It takes no target: the range is the configured one, always. Callers pass a // context and nothing else, which is the point — see the package comment. func (s *Scanner) Scan(ctx context.Context) (Result, error) { if len(s.cfg.Subnets) == 0 { return Result{}, 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, truncated := s.targets() // One slot per PROBE, not per host: a worker sends once per open port, so // a subnet with more open ports than addresses used to fill a host-sized // buffer and wedge. Nothing drains this channel until wg.Wait returns, and // the sends carry no select on ctx.Done, so that was a permanent hang of // the calling turn plus a leak of every worker. results := make(chan result, len(targets)*len(s.cfg.Ports)) sem := make(chan struct{}, maxParallel) var wg sync.WaitGroup 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 { truncated = true break scan } select { case <-ctx.Done(): truncated = true 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, strconv.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) if !h.Up() { continue } 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 Result{Hosts: out, Truncated: truncated}, nil } 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() }