// Package arp implements the arp_discovery scanner module. // It sends ARP requests across configured subnets and records // responding hosts (IP + MAC) in the shared SQLite store. package arp import ( "context" "fmt" "log/slog" "net" "net/netip" "time" "github.com/mdlayher/arp" "github.com/cereda-systems/nexus-scanner/internal/config" "github.com/cereda-systems/nexus-scanner/internal/db" ) // Module performs ARP discovery on configured subnets. type Module struct { site string cfg config.ARPConfig store *db.Store log *slog.Logger } // New returns a new ARP discovery module. func New(site string, cfg config.ARPConfig, store *db.Store) *Module { return &Module{ site: site, cfg: cfg, store: store, log: slog.With("module", "arp_discovery"), } } func (m *Module) Name() string { return "arp_discovery" } func (m *Module) Interval() time.Duration { return m.cfg.Interval } // Run executes one full ARP scan cycle across all configured subnets. func (m *Module) Run(ctx context.Context) error { scanID, err := m.store.BeginScan(m.Name()) if err != nil { return fmt.Errorf("begin scan record: %w", err) } scanErr := m.scanAllSubnets(ctx) if err := m.store.EndScan(scanID, scanErr); err != nil { m.log.Error("failed to record scan end", "err", err) } return scanErr } func (m *Module) scanAllSubnets(ctx context.Context) error { iface, err := net.InterfaceByName(m.cfg.Interface) if err != nil { return fmt.Errorf("interface %q: %w", m.cfg.Interface, err) } client, err := arp.Dial(iface) if err != nil { return fmt.Errorf("arp dial on %s: %w", m.cfg.Interface, err) } defer client.Close() for _, subnet := range m.cfg.Subnets { select { case <-ctx.Done(): return ctx.Err() default: } if err := m.scanSubnet(ctx, client, subnet); err != nil { // Log and continue — one failed subnet should not abort others. m.log.Error("subnet scan failed", "subnet", subnet, "err", err) } } return nil } func (m *Module) scanSubnet(ctx context.Context, client *arp.Client, cidr string) error { prefix, err := netip.ParsePrefix(cidr) if err != nil { return fmt.Errorf("parse prefix %q: %w", cidr, err) } prefix = prefix.Masked() targets := hostsInPrefix(prefix) m.log.Info("scanning subnet", "subnet", cidr, "targets", len(targets)) // Send all ARP requests first, then collect responses. // Set a deadline so Read() returns after silence. if err := client.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { return fmt.Errorf("set deadline: %w", err) } for _, ip := range targets { select { case <-ctx.Done(): return ctx.Err() default: } if err := client.Request(ip); err != nil { m.log.Debug("arp request failed", "ip", ip, "err", err) } // Small delay to avoid flooding the network. time.Sleep(2 * time.Millisecond) } // Collect all responses until the deadline fires. found := 0 for { pkt, _, err := client.Read() if err != nil { // Deadline reached — done reading. break } now := time.Now().UTC() host := db.Host{ IP: pkt.SenderIP.String(), MAC: pkt.SenderHardwareAddr.String(), Site: m.site, FirstSeen: now, LastSeen: now, } if err := m.store.UpsertHost(host); err != nil { m.log.Error("upsert host", "ip", host.IP, "err", err) continue } m.log.Info("host discovered", "ip", host.IP, "mac", host.MAC) found++ } m.log.Info("subnet scan done", "subnet", cidr, "found", found) return nil } // hostsInPrefix returns all usable host addresses in a prefix, // excluding the network address and the broadcast address. // // Example: 192.168.0.0/24 → 192.168.0.1 … 192.168.0.254 func hostsInPrefix(prefix netip.Prefix) []netip.Addr { var addrs []netip.Addr // Start at network address + 1 (skip network address itself). addr := prefix.Masked().Addr().Next() for prefix.Contains(addr) { next := addr.Next() // Stop before the broadcast address (last address in prefix). if !prefix.Contains(next) { break } addrs = append(addrs, addr) addr = next } return addrs }