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