a8fcb404be
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
357 lines
10 KiB
Go
357 lines
10 KiB
Go
// 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()
|
|
}
|