// Package sysarp implements a cross-platform ARP-table reader. // It runs "arp -a" (available on Windows, Linux and macOS) and // writes every discovered host into the shared SQLite store. // Unlike the arp_discovery module it does NOT send packets — // it reads what the OS already knows, so no raw-socket privilege is needed. package sysarp import ( "bufio" "bytes" "context" "fmt" "log/slog" "os/exec" "regexp" "runtime" "strings" "time" "github.com/cereda-systems/nexus-scanner/internal/db" ) // Module reads the OS ARP cache and persists discovered hosts. type Module struct { site string store *db.Store log *slog.Logger } // New returns a new sys_arp module. func New(site string, store *db.Store) *Module { return &Module{ site: site, store: store, log: slog.With("module", "sys_arp"), } } func (m *Module) Name() string { return "sys_arp" } func (m *Module) Interval() time.Duration { return 2 * time.Minute } // Run executes one read of the ARP cache. func (m *Module) Run(ctx context.Context) error { scanID, err := m.store.BeginScan(m.Name()) if err != nil { return fmt.Errorf("begin scan: %w", err) } scanErr := m.scan(ctx) if endErr := m.store.EndScan(scanID, scanErr); endErr != nil { m.log.Error("record scan end", "err", endErr) } return scanErr } func (m *Module) scan(ctx context.Context) error { out, err := exec.CommandContext(ctx, "arp", "-a").Output() if err != nil { return fmt.Errorf("arp -a failed: %w", err) } entries := parseARPOutput(out) m.log.Info("ARP cache read", "entries", len(entries)) now := time.Now().UTC() saved := 0 for _, e := range entries { if err := m.store.UpsertHost(db.Host{ IP: e.ip, MAC: e.mac, Site: m.site, FirstSeen: now, LastSeen: now, }); err != nil { m.log.Error("upsert host", "ip", e.ip, "err", err) continue } m.log.Info("host found", "ip", e.ip, "mac", e.mac) saved++ } m.log.Info("scan complete", "found", len(entries), "saved", saved) return nil } type arpEntry struct{ ip, mac string } // Windows: " 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic" var reWindows = regexp.MustCompile(`^\s+([\d.]+)\s+([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})\s+\S+`) // Linux/macOS: "? (192.168.0.1) at aa:bb:cc:dd:ee:ff [ether] on eth0" var reUnix = regexp.MustCompile(`\(([\d.]+)\) at ([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})`) // skipPrefixes are IPs that are multicast / broadcast — not real hosts. var skipPrefixes = []string{"224.", "239.", "255.", "ff02"} func parseARPOutput(data []byte) []arpEntry { var entries []arpEntry seen := make(map[string]struct{}) scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { line := scanner.Text() var ip, mac string if runtime.GOOS == "windows" { m := reWindows.FindStringSubmatch(line) if m == nil { continue } ip = m[1] // Normalise Windows dashes to colons: aa-bb-cc → aa:bb:cc mac = strings.ReplaceAll(strings.ToLower(m[2]), "-", ":") } else { m := reUnix.FindStringSubmatch(line) if m == nil { continue } ip = m[1] mac = strings.ToLower(m[2]) } // Skip multicast, broadcast and already-seen IPs. skip := false for _, pfx := range skipPrefixes { if strings.HasPrefix(ip, pfx) || strings.HasPrefix(mac, pfx) { skip = true break } } if skip { continue } if _, dup := seen[ip]; dup { continue } seen[ip] = struct{}{} entries = append(entries, arpEntry{ip: ip, mac: mac}) } return entries }