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

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
This commit is contained in:
kami
2026-08-01 06:35:10 +04:00
parent dc4c5b7841
commit a8fcb404be
9 changed files with 912 additions and 0 deletions
+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")
}
}