Files
Maven/cmd/mavend/netscan.go
T
kami e4bfcd958f netscan: stop the scan wedging, and stop it overstating the LAN
The results channel was sized by the number of hosts while each worker
sends once per open port, so a subnet with more open ports than
addresses filled the buffer and blocked a worker forever. Nothing drains
the channel until wg.Wait returns and the sends have no ctx.Done case,
so the calling turn hung for the life of the process. Size it by probes.

Three more claims the scanner could not back. MaxHosts was spent in
order, so the second of two configured subnets got two addresses out of
254 with nothing logged. A run cut short by the cap or the deadline came
back indistinguishable from a complete one, and the shipped defaults
never fit the budget, so every scan was silently truncated at the top of
the range. Scan now reports truncation, targets are taken round-robin,
and the default rate and the budget are consistent with a /24.

The spoken reply read dotted quads out loud on the voice path. It now
says how many devices and what shape, and writes the address list as a
note, which is also the only record that Maven put packets on the LAN.
The network noun is matched whole so posetil is not a scan, the rate has
a stated ceiling, and a repeat question inside two minutes reuses the
answer.

Both query sources claimed the turn when the capability was off, which
let an unconfigured scanner and an unconfigured house swallow questions
that used to reach recall. Both now fall through.
Found in review of #81 and #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:01:01 +04:00

279 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"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.
//
// It has to be consistent with the shipped defaults or every scan is truncated:
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
// second is a little over ten seconds plus the tail dials. 30s leaves room for
// that without pretending a slower rate would fit.
const scanBudget = 30 * time.Second
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
// to be two full sweeps of the LAN, up to a thousand connections each. The
// network does not change on the scale of a follow-up question, and the cheapest
// packet is the one not sent.
const scanCacheTTL = 2 * time.Minute
// scanReadOut — how many hosts go into the written record's first lines before
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
const scanReadOut = 20
// 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
// api — where the address list is WRITTEN. The spoken answer is a count
// and a shape, so the detail has to land somewhere readable; a note under
// source "scan:lan" puts it on /history and, through the intake decorator,
// on /events. It is also the only record that Maven put packets on the LAN
// at all. nil ⇒ nothing is written, which is what the tests use.
api ipc.CoreAPI
now func() time.Time
mu sync.Mutex
cached netscan.Result
cachedAt time.Time
}
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *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, api: api, now: time.Now}
}
// scan runs a scan, or reuses one younger than scanCacheTTL.
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := w.now()
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
return w.cached, nil
}
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
defer cancel()
res, err := w.scanner.Scan(scanCtx)
if err != nil {
return res, err
}
w.cached, w.cachedAt = res, now
// Written on a fresh scan only: the record is a trace of packets going out,
// so a cached answer must not forge a second one.
w.writeScanRecord(ctx, res)
return res, nil
}
// scanSummary answers "какие устройства в сети?" in one spoken line.
//
// It does NOT read addresses out. This is the query path, so the reply goes to
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
// a digit stream nobody can follow through a speaker. She says how many and
// what shape they are; the addresses go into a note (see writeScanRecord).
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
if w == nil {
return "", false
}
res, err := w.scan(ctx)
if err != nil {
log.Printf("netscan: scan: %v", err)
return "не получилось просканировать сеть.", true
}
// A truncated run is not a statement about the LAN. Saying "нашла 6
// устройств" after stopping two thirds of the way through the range is a
// false claim, and the addresses at the end are the ones that go missing.
tail := ""
if res.Truncated {
tail = ", но успела посмотреть не всю сеть"
}
if len(res.Hosts) == 0 {
return "в сети никого не нашла" + tail + ".", true
}
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape
}
out += tail
if w.api != nil {
out += ". список записала"
}
return out + ".", true
}
// scanShape describes the hosts by what they answer on, which is the part of
// the answer that carries meaning out loud: "два с вебом" says more about the
// flat than four octets do.
func scanShape(hosts []netscan.Host) string {
var web, ssh, quiet int
for _, h := range hosts {
hasWeb, hasSSH := false, false
for _, p := range h.Ports {
switch p {
case 80, 443, 8080:
hasWeb = true
case 22:
hasSSH = true
}
}
if hasWeb {
web++
}
if hasSSH {
ssh++
}
// No open port at all: seen only through the ARP cache.
if len(h.Ports) == 0 {
quiet++
}
}
var parts []string
if web > 0 {
parts = append(parts, fmt.Sprintf("%d с вебом", web))
}
if ssh > 0 {
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
}
if quiet > 0 {
parts = append(parts, fmt.Sprintf("%d молча", quiet))
}
if len(parts) == 0 {
return ""
}
return "из них " + strings.Join(parts, ", ")
}
// writeScanRecord stores the address list as a note. This is both where the
// detail becomes readable and the only trace that a scan happened at all: a
// scan is a read, but "when did she last put packets on the LAN" deserves an
// answer.
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil {
return
}
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
shown := res.Hosts
if len(shown) > scanReadOut {
shown = shown[:scanReadOut]
}
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, ", ") + ")"
}
if h.MAC != "" {
s += " " + h.MAC
}
lines = append(lines, s)
}
if len(res.Hosts) > len(shown) {
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
}
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
log.Printf("netscan: write scan note: %v", err)
}
}
// 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
}
// Whole tokens for the network nouns: the bare substring "сети" is inside
// "посетил", so "сколько машин я посетил?" used to read as a request to
// scan the LAN. The prefix forms below are stems that have no such
// collisions.
network := false
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
if homeWord(s, w) {
network = true
break
}
}
if !network {
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
}