Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8fcb404be |
@@ -81,6 +81,11 @@ var querySources = []querySource{
|
||||
// wording, so "какая температура на улице?" still reaches the weather
|
||||
// source.
|
||||
{"home", (*reactiveHandler).queryHome},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{"network", (*reactiveHandler).queryNetwork},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
@@ -299,6 +304,22 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
|
||||
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||||
// no confirm turn because nothing is changed, and no way to widen the range
|
||||
// because Scan takes no target — the utterance selects the question, never the
|
||||
// subnet.
|
||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isNetworkQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Claim the turn: "сканирование не настроено" is true, and general
|
||||
// knowledge would answer with an invented list of devices.
|
||||
return "сканирование сети не настроено.", true
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
// longer than this has already failed as a turn, so the scan returns whatever
|
||||
// it found rather than keeping him waiting.
|
||||
const scanBudget = 20 * time.Second
|
||||
|
||||
// scanReadOut — how many hosts she names out loud. The rest are a count: a
|
||||
// spoken list of twenty IP addresses is not an answer.
|
||||
const scanReadOut = 6
|
||||
|
||||
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||
// never puts a discovery packet on the network.
|
||||
//
|
||||
// Unlike the house, a scan is a READ, so it is a query source rather than an
|
||||
// act: there is no allowlist row and no confirm turn, because nothing changes.
|
||||
// What makes that safe is that the range is not an argument — see
|
||||
// internal/netscan's package comment.
|
||||
type netWiring struct {
|
||||
scanner *netscan.Scanner
|
||||
subnets []string
|
||||
}
|
||||
|
||||
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||
func wireNetScan(cfg *config.Config) *netWiring {
|
||||
nc, ok := cfg.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := netscan.Validate(nc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Not fatal: the scanner off is a
|
||||
// working Maven.
|
||||
log.Printf("netscan: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets}
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one line.
|
||||
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
hosts, err := w.scanner.Scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return "в сети никого не нашла.", true
|
||||
}
|
||||
shown := hosts
|
||||
if len(shown) > scanReadOut {
|
||||
shown = shown[:scanReadOut]
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, h := range shown {
|
||||
s := h.Addr
|
||||
if len(h.Ports) > 0 {
|
||||
ps := make([]string, 0, len(h.Ports))
|
||||
for _, p := range h.Ports {
|
||||
ps = append(ps, fmt.Sprintf("%d", p))
|
||||
}
|
||||
s += " (" + strings.Join(ps, ", ") + ")"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; "))
|
||||
if len(hosts) > len(shown) {
|
||||
out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown))
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
// 5+ устройств, and the teens are all the last form.
|
||||
func hostWord(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "устройств"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "устройство"
|
||||
case 2, 3, 4:
|
||||
return "устройства"
|
||||
default:
|
||||
return "устройств"
|
||||
}
|
||||
}
|
||||
|
||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||
// bounds exist to prevent.
|
||||
func isNetworkQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
network := false
|
||||
for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
return false
|
||||
}
|
||||
// An explicit ask to scan, or a phrase that can only be about the LAN.
|
||||
// "кто в сети" carries no device noun but means nothing else.
|
||||
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
|
||||
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"},
|
||||
}},
|
||||
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
||||
"enabled but public": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
||||
}},
|
||||
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var w *netWiring
|
||||
if _, ok := w.scanSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}})
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
}
|
||||
|
||||
// A loopback /32 with nothing listening on the scanned port: the summary must
|
||||
// come back honest rather than inventing a host. This also exercises the real
|
||||
// dialer end to end without touching anything outside this box.
|
||||
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}})
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("empty summary")
|
||||
}
|
||||
// Persona: feminine self-reference, informal address, no pet names.
|
||||
low := strings.ToLower(out)
|
||||
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(low, bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := hostWord(n); got != want {
|
||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
"кто в сети?",
|
||||
"просканируй сеть",
|
||||
"покажи устройства в локальной сети",
|
||||
"сколько машин в сети",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"интернет не работает",
|
||||
"сеть какая-то медленная",
|
||||
"я в сети инстаграма",
|
||||
"что включено дома?",
|
||||
"напомни оплатить интернет",
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,11 @@ type reactiveHandler struct {
|
||||
// act allowlist and tool.Executor, like every other mutating act.
|
||||
home *homeWiring
|
||||
|
||||
// netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the
|
||||
// default. A scan is a read, so it has no allowlist row; what keeps it
|
||||
// safe is that its range comes from config and from nowhere else.
|
||||
netscan *netWiring
|
||||
|
||||
weatherProvider weather.Provider
|
||||
weatherLocation string // default location for weather queries
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ type voiceWiring struct {
|
||||
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||
// other act, so nothing else here has to know about it.
|
||||
home *homeWiring
|
||||
// netscan — the LAN scanner, nil unless the `netscan` block is enabled
|
||||
// (Vikunja #257).
|
||||
netscan *netWiring
|
||||
}
|
||||
|
||||
// close releases the listener + worker conns. Safe to call on nil (when
|
||||
@@ -161,6 +164,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
if w.home != nil {
|
||||
exec = exec.WithHome(w.home.caller())
|
||||
}
|
||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||
w.netscan = wireNetScan(cfg)
|
||||
matcher := tool.NewMatcher(coreAPI)
|
||||
|
||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||
@@ -245,6 +251,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||
// capability, and capabilities are off unless configured.
|
||||
crawler: onDemandCrawler(cfg),
|
||||
|
||||
@@ -56,6 +56,15 @@
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"netscan": {
|
||||
"subnets": ["192.168.1.0/24"],
|
||||
"ports": [22, 80, 443, 8080],
|
||||
"timeout": "400ms",
|
||||
"rate": 50,
|
||||
"max_hosts": 256,
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"nexus": { "url": "http://nexus:9740" },
|
||||
"praxis": { "url": "http://praxis:8989" },
|
||||
"hexis": { "url": "http://hexis:9741" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user