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
199 lines
6.7 KiB
Go
199 lines
6.7 KiB
Go
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")
|
|
}
|
|
}
|