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" "github.com/kami/maven/internal/phraser" ) // 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 phraser.Q(phraser.QueryFailNetscan, nil), 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 phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true } out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(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), phraser.Devices(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) } } // 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 }