168 lines
4.0 KiB
Go
168 lines
4.0 KiB
Go
// 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
|
|
}
|