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
143 lines
4.4 KiB
Go
143 lines
4.4 KiB
Go
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
|
|
}
|