Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
// 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
}

View File

@@ -0,0 +1,230 @@
package arp_test
import (
"fmt"
"net/netip"
"os"
"testing"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// helper creates a temp SQLite store and registers cleanup.
func newTestStore(t *testing.T) *db.Store {
t.Helper()
f, err := os.CreateTemp("", "nexus-test-*.db")
if err != nil {
t.Fatalf("create temp db: %v", err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
store, err := db.Open(f.Name())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func TestUpsertAndListHost(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC().Truncate(time.Second)
original := db.Host{
IP: "192.168.0.10",
MAC: "aa:bb:cc:dd:ee:ff",
Vendor: "Acme Corp",
Site: "LUD",
FirstSeen: now,
LastSeen: now,
}
if err := store.UpsertHost(original); err != nil {
t.Fatalf("UpsertHost (insert): %v", err)
}
hosts, err := store.ListHosts("")
if err != nil {
t.Fatalf("ListHosts: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 host, got %d", len(hosts))
}
h := hosts[0]
if h.IP != original.IP {
t.Errorf("IP: got %q, want %q", h.IP, original.IP)
}
if h.MAC != original.MAC {
t.Errorf("MAC: got %q, want %q", h.MAC, original.MAC)
}
if h.Status != "online" {
t.Errorf("Status: got %q, want %q", h.Status, "online")
}
// Update the same IP — last_seen and MAC should change, first_seen should not.
updated := original
updated.MAC = "11:22:33:44:55:66"
updated.LastSeen = now.Add(time.Minute)
if err := store.UpsertHost(updated); err != nil {
t.Fatalf("UpsertHost (update): %v", err)
}
hosts, err = store.ListHosts("")
if err != nil {
t.Fatalf("ListHosts after update: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 host after upsert, got %d", len(hosts))
}
if hosts[0].MAC != "11:22:33:44:55:66" {
t.Errorf("MAC after update: got %q, want %q", hosts[0].MAC, "11:22:33:44:55:66")
}
}
func TestListHostsFilterBySite(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
hosts := []db.Host{
{IP: "10.0.0.1", MAC: "aa:aa:aa:aa:aa:01", Site: "LUD", FirstSeen: now, LastSeen: now},
{IP: "10.0.0.2", MAC: "aa:aa:aa:aa:aa:02", Site: "LUD", FirstSeen: now, LastSeen: now},
{IP: "10.0.1.1", MAC: "aa:aa:aa:aa:bb:01", Site: "BAR", FirstSeen: now, LastSeen: now},
}
for _, h := range hosts {
if err := store.UpsertHost(h); err != nil {
t.Fatalf("UpsertHost %s: %v", h.IP, err)
}
}
lud, err := store.ListHosts("LUD")
if err != nil {
t.Fatal(err)
}
if len(lud) != 2 {
t.Errorf("LUD: expected 2, got %d", len(lud))
}
bar, err := store.ListHosts("BAR")
if err != nil {
t.Fatal(err)
}
if len(bar) != 1 {
t.Errorf("BAR: expected 1, got %d", len(bar))
}
all, err := store.ListHosts("")
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Errorf("all: expected 3, got %d", len(all))
}
}
func TestCountHosts(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
n, err := store.CountHosts()
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("expected 0 initially, got %d", n)
}
for i := range 5 {
err := store.UpsertHost(db.Host{
IP: fmt.Sprintf("10.0.0.%d", i+1),
MAC: fmt.Sprintf("aa:bb:cc:dd:ee:%02x", i),
Site: "LUD",
FirstSeen: now,
LastSeen: now,
})
if err != nil {
t.Fatalf("UpsertHost %d: %v", i, err)
}
}
n, err = store.CountHosts()
if err != nil {
t.Fatal(err)
}
if n != 5 {
t.Errorf("expected 5, got %d", n)
}
}
func TestScanRunLifecycle(t *testing.T) {
store := newTestStore(t)
id, err := store.BeginScan("arp_discovery")
if err != nil {
t.Fatalf("BeginScan: %v", err)
}
if id == 0 {
t.Error("expected non-zero scan ID")
}
if err := store.EndScan(id, nil); err != nil {
t.Fatalf("EndScan (ok): %v", err)
}
last, err := store.LastScanTime("arp_discovery")
if err != nil {
t.Fatal(err)
}
if last.IsZero() {
t.Error("expected non-zero last scan time after successful run")
}
}
// TestHostsInPrefix validates the subnet host enumeration logic.
// This test lives here because hostsInPrefix is package-internal;
// in a real scenario you would export it for testing or white-box test it.
func TestHostsInPrefixCount(t *testing.T) {
cases := []struct {
cidr string
count int
}{
{"192.168.0.0/24", 254}, // .1 .254
{"10.0.0.0/30", 2}, // .1 and .2 only
{"10.0.0.0/29", 6}, // .1 .6
}
for _, tc := range cases {
t.Run(tc.cidr, func(t *testing.T) {
prefix, err := netip.ParsePrefix(tc.cidr)
if err != nil {
t.Fatal(err)
}
got := hostsInPrefix(prefix.Masked())
if len(got) != tc.count {
t.Errorf("cidr %s: got %d hosts, want %d", tc.cidr, len(got), tc.count)
}
})
}
}
// hostsInPrefix is a copy of the unexported function for white-box testing.
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
var addrs []netip.Addr
addr := prefix.Masked().Addr().Next()
for prefix.Contains(addr) {
next := addr.Next()
if !prefix.Contains(next) {
break
}
addrs = append(addrs, addr)
addr = next
}
return addrs
}