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,182 @@
// Package adsync queries Active Directory for computer objects and stores
// them in the local database. It enriches existing hosts with their AD
// computer name and department, and records computers that are in AD but
// not yet seen on the network (useful for finding offline/missing devices).
package adsync
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module queries Active Directory for all computer objects.
type Module struct {
site string
cfg config.ADConfig
store *db.Store
log *slog.Logger
}
// New returns a new AD sync module.
func New(site string, cfg config.ADConfig, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
store: store,
log: slog.With("module", "ad_sync"),
}
}
func (m *Module) Name() string { return "ad_sync" }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes one AD sync cycle.
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.sync(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) sync(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", m.cfg.Server, m.cfg.Port)
var conn *ldap.Conn
var err error
if m.cfg.TLS {
conn, err = ldap.DialURL("ldaps://" + addr)
} else {
conn, err = ldap.DialURL("ldap://" + addr)
}
if err != nil {
return fmt.Errorf("ldap dial %s: %w", addr, err)
}
defer conn.Close()
if err := conn.Bind(m.cfg.BindDN, m.cfg.BindPassword); err != nil {
return fmt.Errorf("ldap bind as %s: %w", m.cfg.BindDN, err)
}
computers, err := m.searchComputers(conn)
if err != nil {
return fmt.Errorf("ldap search: %w", err)
}
m.log.Info("computers found in AD", "count", len(computers))
saved := 0
for _, c := range computers {
if err := m.store.UpsertADComputer(c); err != nil {
m.log.Error("upsert ad computer", "cn", c.CN, "err", err)
continue
}
saved++
m.log.Debug("ad computer synced", "cn", c.CN, "os", c.OS, "dept", c.Department)
}
m.log.Info("ad sync complete", "synced", saved, "total", len(computers))
return nil
}
func (m *Module) searchComputers(conn *ldap.Conn) ([]db.ADComputer, error) {
req := ldap.NewSearchRequest(
m.cfg.SearchBase,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
"(&(objectClass=computer)(objectCategory=Computer))",
[]string{
"cn",
"distinguishedName",
"operatingSystem",
"operatingSystemVersion",
"lastLogonTimestamp",
"whenCreated",
"description",
"managedBy",
},
nil,
)
result, err := conn.SearchWithPaging(req, 500)
if err != nil {
return nil, fmt.Errorf("ldap search computers: %w", err)
}
var computers []db.ADComputer
for _, entry := range result.Entries {
c := db.ADComputer{
CN: entry.GetAttributeValue("cn"),
DN: entry.GetAttributeValue("distinguishedName"),
OS: entry.GetAttributeValue("operatingSystem"),
OSVersion: entry.GetAttributeValue("operatingSystemVersion"),
Owner: entry.GetAttributeValue("description"),
Department: ouFromDN(entry.GetAttributeValue("distinguishedName")),
}
if raw := entry.GetAttributeValue("lastLogonTimestamp"); raw != "" {
c.LastLogon = windowsTimeToGoTime(raw)
}
if raw := entry.GetAttributeValue("whenCreated"); raw != "" {
c.ADCreated = parseLDAPGeneralizedTime(raw)
}
computers = append(computers, c)
}
return computers, nil
}
// ouFromDN extracts the first OU component from a distinguished name.
// "CN=IT-NB-02,OU=Notebooks,OU=IT,DC=winkel,DC=local" → "Notebooks"
func ouFromDN(dn string) string {
for _, part := range strings.Split(dn, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToUpper(part), "OU=") {
return part[3:]
}
}
return ""
}
// windowsTimeToGoTime converts a Windows LDAP timestamp string (100-ns intervals
// since 1601-01-01) to a Go time.Time.
func windowsTimeToGoTime(raw string) time.Time {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n == 0 {
return time.Time{}
}
// Offset between Windows epoch (1601-01-01) and Unix epoch (1970-01-01)
// in 100-nanosecond intervals.
const windowsToUnix int64 = 116444736000000000
unixNano := (n - windowsToUnix) * 100
return time.Unix(0, unixNano).UTC()
}
// parseLDAPGeneralizedTime parses LDAP generalizedTime format: "20240101120000.0Z"
func parseLDAPGeneralizedTime(raw string) time.Time {
// Trim trailing sub-second and timezone info for simple parsing.
raw = strings.TrimSuffix(raw, "Z")
if idx := strings.Index(raw, "."); idx != -1 {
raw = raw[:idx]
}
t, err := time.Parse("20060102150405", raw)
if err != nil {
return time.Time{}
}
return t.UTC()
}

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
}

View File

@@ -0,0 +1,94 @@
// Package dnsreverse implements the dns_reverse module.
// It enriches already-known hosts by performing reverse-DNS lookups
// for every host that has no hostname recorded yet.
package dnsreverse
import (
"context"
"fmt"
"log/slog"
"net"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module enriches hosts in the database with reverse-DNS hostnames.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new dns_reverse module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "dns_reverse"),
}
}
func (m *Module) Name() string { return "dns_reverse" }
func (m *Module) Interval() time.Duration { return 10 * time.Minute }
// Run looks up hostnames for all hosts that have none recorded yet.
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 {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
resolved := 0
for _, h := range hosts {
// Skip hosts that already have a hostname.
if h.Hostname != "" {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
names, err := net.DefaultResolver.LookupAddr(ctx, h.IP)
if err != nil {
// A lookup failure is not fatal — continue with the next host.
m.log.Debug("reverse lookup failed", "ip", h.IP, "err", err)
continue
}
if len(names) == 0 {
continue
}
// LookupAddr returns FQDNs with a trailing dot — strip it.
hostname := strings.TrimSuffix(names[0], ".")
h.Hostname = hostname
if err := m.store.UpsertHost(h); err != nil {
m.log.Error("upsert host", "ip", h.IP, "err", err)
continue
}
m.log.Info("hostname resolved", "ip", h.IP, "hostname", hostname)
resolved++
}
m.log.Info("scan complete", "checked", len(hosts), "resolved", resolved)
return nil
}

View File

@@ -0,0 +1,129 @@
// Package macvendor implements the mac_vendor module.
// It maps the OUI (first 3 bytes) of every known host's MAC address to a
// human-readable vendor name and persists the result in the database.
// The mapping is based on a built-in table; no external files are required.
package macvendor
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// ouiTable maps lowercase OUI prefixes (aa:bb:cc) to vendor names.
// Broadcast / multicast entries are intentionally kept with an empty string
// so they are recognised but not written to the database.
var ouiTable = map[string]string{
"00:0c:29": "VMware",
"00:50:56": "VMware",
"bc:24:11": "Proxmox/Ceph",
"90:1b:0e": "Supermicro",
"90:09:d0": "Synology",
"00:17:c8": "Hewlett-Packard",
"00:be:43": "Ubiquiti",
"e4:43:4b": "Ubiquiti",
"24:6a:0e": "Ubiquiti",
"40:86:cb": "Intel",
"68:c6:ac": "Intel",
"1c:af:4a": "Dell",
"50:81:40": "Dell",
"4c:5f:70": "Lenovo",
"dc:58:bc": "Apple",
"c8:4b:d6": "Kyocera",
"38:d5:7a": "Samsung",
"00:04:a5": "Barco",
"7c:5a:1c": "LANCOM",
"00:0a:b3": "Cisco",
"00:e0:67": "Aten",
"ff:ff:ff": "", // broadcast — leave vendor empty
}
// LookupVendor returns the vendor name for a MAC address.
// The MAC must be in the format aa:bb:cc:dd:ee:ff (colon-separated).
// An empty string is returned when no match is found.
func LookupVendor(mac string) string {
if len(mac) < 8 {
return ""
}
oui := strings.ToLower(mac[:8])
return ouiTable[oui]
}
// Module enriches hosts in the database with OUI-based vendor names.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new mac_vendor module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "mac_vendor"),
}
}
func (m *Module) Name() string { return "mac_vendor" }
// Interval returns 0, which causes the scheduler to run this module once at
// startup and never repeat it automatically.
func (m *Module) Interval() time.Duration { return 0 }
// Run assigns vendor names to all hosts that have none recorded yet.
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 {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
enriched := 0
for _, h := range hosts {
// Skip hosts that already have a vendor assigned.
if h.Vendor != "" {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
vendor := LookupVendor(h.MAC)
if vendor == "" {
// No entry in our table — leave vendor empty.
continue
}
h.Vendor = vendor
if err := m.store.UpsertHost(h); err != nil {
m.log.Error("upsert host", "ip", h.IP, "err", err)
continue
}
m.log.Info("vendor resolved", "ip", h.IP, "mac", h.MAC, "vendor", vendor)
enriched++
}
m.log.Info("scan complete", "checked", len(hosts), "enriched", enriched)
return nil
}

View File

@@ -0,0 +1,54 @@
package macvendor
import "testing"
func TestLookupVendor(t *testing.T) {
tests := []struct {
mac string
want string
}{
// Known OUIs from the built-in table.
{"00:0c:29:1a:2b:3c", "VMware"},
{"00:50:56:ab:cd:ef", "VMware"},
{"bc:24:11:00:00:01", "Proxmox/Ceph"},
{"90:1b:0e:ff:ee:dd", "Supermicro"},
{"90:09:d0:11:22:33", "Synology"},
{"00:17:c8:44:55:66", "Hewlett-Packard"},
{"00:be:43:77:88:99", "Ubiquiti"},
{"e4:43:4b:aa:bb:cc", "Ubiquiti"},
{"24:6a:0e:dd:ee:ff", "Ubiquiti"},
{"40:86:cb:00:11:22", "Intel"},
{"68:c6:ac:33:44:55", "Intel"},
{"1c:af:4a:66:77:88", "Dell"},
{"50:81:40:99:aa:bb", "Dell"},
{"4c:5f:70:cc:dd:ee", "Lenovo"},
{"dc:58:bc:ff:00:11", "Apple"},
{"c8:4b:d6:22:33:44", "Kyocera"},
{"38:d5:7a:55:66:77", "Samsung"},
{"00:04:a5:88:99:aa", "Barco"},
{"7c:5a:1c:bb:cc:dd", "LANCOM"},
{"00:0a:b3:ee:ff:00", "Cisco"},
{"00:e0:67:11:22:33", "Aten"},
// Broadcast — vendor must be empty.
{"ff:ff:ff:ff:ff:ff", ""},
// Unknown OUI — no vendor.
{"de:ad:be:ef:00:01", ""},
// MAC shorter than 8 characters — must not panic.
{"aa:bb", ""},
{"", ""},
// Upper-case input — must be normalised.
{"00:0C:29:1A:2B:3C", "VMware"},
{"BC:24:11:FF:EE:DD", "Proxmox/Ceph"},
}
for _, tc := range tests {
got := LookupVendor(tc.mac)
if got != tc.want {
t.Errorf("LookupVendor(%q) = %q, want %q", tc.mac, got, tc.want)
}
}
}

View File

@@ -0,0 +1,130 @@
// Package nexusreporter pushes newly discovered or changed assets to IT Nexus.
package nexusreporter
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "nexus_reporter"
type Module struct {
site string
cfg config.ReporterConfig
nexus config.Config // full config for URL + APIKey
store *db.Store
client *http.Client
// last hash per IP to detect changes
lastHash map[string][32]byte
}
// New creates a new IT Nexus reporter module.
func New(site string, cfg config.ReporterConfig, nexusCfg config.Config, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
nexus: nexusCfg,
store: store,
client: &http.Client{Timeout: 15 * time.Second},
lastHash: make(map[string][32]byte),
}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
type assetPayload struct {
Site string `json:"site"`
Version string `json:"scanner_version"`
ReportedAt time.Time `json:"reported_at"`
Assets []assetItem `json:"assets"`
}
type assetItem struct {
IP string `json:"ip"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
Vendor string `json:"vendor"`
Status string `json:"status"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
// Run reports changed/new hosts to IT Nexus.
func (m *Module) Run(ctx context.Context) error {
if m.nexus.Nexus.URL == "" || m.nexus.Nexus.APIKey == "" {
slog.Debug("nexus reporter skipped — URL or APIKey not configured")
return nil
}
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
var changed []assetItem
for _, h := range hosts {
hash := hostHash(h)
if prev, seen := m.lastHash[h.IP]; seen && prev == hash {
continue // unchanged
}
m.lastHash[h.IP] = hash
changed = append(changed, assetItem{
IP: h.IP,
MAC: h.MAC,
Hostname: h.Hostname,
Vendor: h.Vendor,
Status: h.Status,
FirstSeen: h.FirstSeen,
LastSeen: h.LastSeen,
})
}
if len(changed) == 0 {
slog.Debug("nexus reporter: no changes")
return nil
}
payload := assetPayload{
Site: m.site,
Version: "1.0",
ReportedAt: time.Now(),
Assets: changed,
}
body, _ := json.Marshal(payload)
url := m.nexus.Nexus.URL + "/api/scanner/assets"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Scanner-Key", m.nexus.Nexus.APIKey)
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("post to nexus: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("nexus returned HTTP %d", resp.StatusCode)
}
slog.Info("nexus reporter: assets reported", "count", len(changed), "url", url)
return nil
}
func hostHash(h db.Host) [32]byte {
s := h.IP + "|" + h.MAC + "|" + h.Hostname + "|" + h.Vendor + "|" + h.Status
return sha256.Sum256([]byte(s))
}

View File

@@ -0,0 +1,106 @@
// Package portscan performs concurrent TCP port scanning on all online hosts.
package portscan
import (
"context"
"fmt"
"log/slog"
"net"
"sort"
"sync"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "port_scan"
// Common ports to scan when none are configured.
var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 3389, 5985, 8080, 8443, 9100}
// Module scans TCP ports on all online hosts.
type Module struct {
site string
cfg config.PortScanConfig
store *db.Store
}
// New creates a new port scan module.
func New(site string, cfg config.PortScanConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run scans all online hosts and stores their open ports.
func (m *Module) Run(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
ports := m.cfg.Ports
if len(ports) == 0 {
ports = defaultPorts
}
timeout := m.cfg.Timeout
if timeout == 0 {
timeout = 500 * time.Millisecond
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
found := 0
for _, h := range hosts {
if h.Status != "online" {
continue
}
open := scanHost(ctx, h.IP, ports, timeout)
sort.Ints(open)
if err := m.store.UpsertHostPorts(h.IP, open); err != nil {
slog.Error("save host ports", "ip", h.IP, "err", err)
}
if len(open) > 0 {
slog.Debug("port scan", "ip", h.IP, "open", open)
found++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("port scan complete", "hosts_with_open_ports", found, "total_hosts", len(hosts))
return nil
}
// scanHost tries all ports concurrently (max 30 goroutines) and returns open ones.
func scanHost(ctx context.Context, ip string, ports []int, timeout time.Duration) []int {
var mu sync.Mutex
var open []int
var wg sync.WaitGroup
sem := make(chan struct{}, 30)
for _, port := range ports {
wg.Add(1)
go func(p int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
addr := fmt.Sprintf("%s:%d", ip, p)
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err == nil {
conn.Close()
mu.Lock()
open = append(open, p)
mu.Unlock()
}
}(port)
}
wg.Wait()
return open
}

View File

@@ -0,0 +1,298 @@
// Package sitemon checks the reachability of configured targets (ping, http, tcp).
// Each scanner instance monitors from its own site perspective, giving you
// per-site uptime visibility across all your locations.
package sitemon
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/smtp"
"os/exec"
"runtime"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "site_monitoring"
// Module runs periodic availability checks and stores results in the DB.
type Module struct {
site string
cfg config.SiteMonConfig
alertCfg config.AlertConfig
nexusCfg config.Config
store *db.Store
client *http.Client
alertHTTP *http.Client
// track previous state per check ID to detect transitions
prevState map[int64]string
}
// New creates a new site monitoring module.
func New(site string, cfg config.SiteMonConfig, alertCfg config.AlertConfig, nexusCfg config.Config, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
alertCfg: alertCfg,
nexusCfg: nexusCfg,
store: store,
prevState: make(map[int64]string),
alertHTTP: &http.Client{Timeout: 10 * time.Second},
client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes all enabled checks once and stores results.
func (m *Module) Run(ctx context.Context) error {
checks, err := m.store.ListMonitorChecks()
if err != nil {
return fmt.Errorf("list checks: %w", err)
}
if len(checks) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
for _, check := range checks {
if !check.Enabled {
continue
}
result := m.runCheck(ctx, check)
if err := m.store.InsertMonitorResult(result); err != nil {
slog.Error("insert monitor result", "check", check.Name, "err", err)
}
slog.Debug("monitor check done",
"name", check.Name, "status", result.Status, "latency_ms", result.LatencyMS)
// Detect state transitions and fire alerts.
if prev, seen := m.prevState[check.ID]; seen && prev != result.Status {
go m.sendAlert(check, result)
}
m.prevState[check.ID] = result.Status
}
_ = m.store.EndScan(scanID, nil)
_ = m.store.CleanupMonitorResults(100)
return nil
}
func (m *Module) runCheck(ctx context.Context, check db.MonitorCheck) db.MonitorResult {
result := db.MonitorResult{
CheckID: check.ID,
CheckedAt: time.Now(),
}
start := time.Now()
var checkErr error
switch check.Type {
case "ping":
checkErr = doPing(ctx, check.Target)
case "http", "https":
checkErr = doHTTP(ctx, m.client, check.Target)
case "tcp":
checkErr = doTCP(ctx, check.Target)
default:
checkErr = fmt.Errorf("unknown type: %s", check.Type)
}
result.LatencyMS = int(time.Since(start).Milliseconds())
if checkErr != nil {
result.Status = "offline"
result.Error = checkErr.Error()
} else {
result.Status = "online"
}
return result
}
func doPing(ctx context.Context, target string) error {
var args []string
if runtime.GOOS == "windows" {
args = []string{"-n", "1", "-w", "2000", target}
} else {
args = []string{"-c", "1", "-W", "2", target}
}
cmd := exec.CommandContext(ctx, "ping", args...)
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("ping: %w", err)
}
lower := strings.ToLower(string(out))
if strings.Contains(lower, "unreachable") ||
strings.Contains(lower, "timed out") ||
strings.Contains(lower, "100% loss") ||
strings.Contains(lower, "100% packet loss") {
return fmt.Errorf("host unreachable")
}
return nil
}
func doHTTP(ctx context.Context, client *http.Client, target string) error {
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
target = "https://" + target
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "NexusScanner/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
func doTCP(ctx context.Context, target string) error {
d := net.Dialer{Timeout: 5 * time.Second}
conn, err := d.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
conn.Close()
return nil
}
/* ── Alerts ───────────────────────────────────────────────────────── */
func (m *Module) sendAlert(check db.MonitorCheck, result db.MonitorResult) {
emoji := "🔴"
word := "OFFLINE"
if result.Status == "online" {
emoji = "✅"
word = "WIEDER ONLINE"
}
subject := fmt.Sprintf("[Nexus Scanner %s] %s %s: %s", m.site, emoji, word, check.Name)
body := fmt.Sprintf(
"Standort: %s\nCheck: %s (%s)\nZiel: %s\nStatus: %s\nZeit: %s\n",
m.site, check.Name, check.Type, check.Target,
strings.ToUpper(result.Status),
result.CheckedAt.Format("02.01.2006 15:04:05"),
)
if result.Error != "" {
body += "Fehler: " + result.Error + "\n"
}
if m.alertCfg.SMTP.Enabled {
if err := m.sendMail(subject, body); err != nil {
slog.Error("alert mail failed", "err", err)
} else {
slog.Info("alert mail sent", "check", check.Name, "status", result.Status)
}
}
if m.alertCfg.NexusEnabled && m.nexusCfg.Nexus.URL != "" {
if err := m.sendNexusAlert(check, result, subject, body); err != nil {
slog.Error("nexus alert failed", "err", err)
} else {
slog.Info("nexus alert sent", "check", check.Name, "status", result.Status)
}
}
}
func (m *Module) sendMail(subject, body string) error {
sc := m.alertCfg.SMTP
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
msg := []byte("From: " + sc.From + "\r\n" +
"To: " + sc.To + "\r\n" +
"Subject: " + subject + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n\r\n" +
body)
var auth smtp.Auth
if sc.Username != "" {
auth = smtp.PlainAuth("", sc.Username, sc.Password, sc.Host)
}
// Try STARTTLS first, fall back to plain.
tlsCfg := &tls.Config{ServerName: sc.Host, InsecureSkipVerify: false}
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dial smtp: %w", err)
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(sc.From); err != nil {
return err
}
if err := c.Rcpt(sc.To); err != nil {
return err
}
wc, err := c.Data()
if err != nil {
return err
}
defer wc.Close()
_, err = wc.Write(msg)
return err
}
func (m *Module) sendNexusAlert(check db.MonitorCheck, result db.MonitorResult, subject, body string) error {
payload := map[string]any{
"site": m.site,
"check": check.Name,
"type": check.Type,
"target": check.Target,
"status": result.Status,
"latency": result.LatencyMS,
"error": result.Error,
"timestamp": result.CheckedAt,
"subject": subject,
"body": body,
}
b, _ := json.Marshal(payload)
url := m.nexusCfg.Nexus.URL + "/api/scanner/alert"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Scanner-Key", m.nexusCfg.Nexus.APIKey)
resp, err := m.alertHTTP.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("nexus returned %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,334 @@
// Package snmpmod polls SNMP targets for device information.
// Supports generic devices, printers (toner/pages) and switches.
package snmpmod
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/gosnmp/gosnmp"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "snmp"
// OIDs queried for every device.
var baseOIDs = map[string]string{
"sysDescr": "1.3.6.1.2.1.1.1.0",
"sysUpTime": "1.3.6.1.2.1.1.3.0",
"sysName": "1.3.6.1.2.1.1.5.0",
"sysLocation": "1.3.6.1.2.1.1.6.0",
"sysContact": "1.3.6.1.2.1.1.4.0",
"ifNumber": "1.3.6.1.2.1.2.1.0",
}
// Switch-specific scalar OIDs (queried via Get).
var switchScalarOIDs = map[string]string{
"cpuLoad": "1.3.6.1.2.1.25.3.3.1.2.1", // hrProcessorLoad (first CPU)
}
// Printer-specific OIDs (Kyocera / most RFC 3805 printers).
var printerOIDs = map[string]string{
"tonerLevel": "1.3.6.1.2.1.43.11.1.1.9.1.1",
"tonerMax": "1.3.6.1.2.1.43.11.1.1.8.1.1",
"pageCount": "1.3.6.1.2.1.43.10.2.1.4.1.1",
"printerStatus": "1.3.6.1.2.1.25.3.5.1.1.1",
}
// Walk base OIDs for interfaces and memory.
const (
oidIfOperStatus = "1.3.6.1.2.1.2.2.1.8" // 1=up, 2=down
oidIfHCInOctets = "1.3.6.1.2.1.31.1.1.1.6" // 64-bit in bytes
oidIfHCOutOctets = "1.3.6.1.2.1.31.1.1.1.10" // 64-bit out bytes
oidIfDescr = "1.3.6.1.2.1.2.2.1.2" // interface name
oidHrStorageDescr = "1.3.6.1.2.1.25.2.3.1.2"
oidHrStorageUsed = "1.3.6.1.2.1.25.2.3.1.6"
oidHrStorageSize = "1.3.6.1.2.1.25.2.3.1.5"
oidHrStorageAlloc = "1.3.6.1.2.1.25.2.3.1.4"
)
// Module polls SNMP targets and stores results.
type Module struct {
site string
cfg config.SNMPConfig
store *db.Store
}
// New creates a new SNMP module.
func New(site string, cfg config.SNMPConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run polls all enabled SNMP targets.
func (m *Module) Run(ctx context.Context) error {
targets, err := m.store.ListSNMPTargets()
if err != nil {
return fmt.Errorf("list snmp targets: %w", err)
}
if len(targets) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
ok := 0
for _, t := range targets {
if !t.Enabled {
continue
}
if err := m.pollTarget(ctx, t); err != nil {
slog.Warn("snmp poll failed", "target", t.Name, "ip", t.IP, "err", err)
} else {
ok++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("snmp poll complete", "ok", ok, "total", len(targets))
return nil
}
func (m *Module) pollTarget(ctx context.Context, t db.SNMPTarget) error {
community := t.Community
if community == "" {
community = m.cfg.Community
}
gs := &gosnmp.GoSNMP{
Target: t.IP,
Port: 161,
Community: community,
Version: snmpVersion(t.Version),
Timeout: 10 * time.Second,
Retries: 1,
MaxOids: 60,
}
if err := gs.Connect(); err != nil {
return fmt.Errorf("connect: %w", err)
}
defer gs.Conn.Close()
now := time.Now()
// --- Base + type-specific scalar GET ---
oids := make(map[string]string)
for k, v := range baseOIDs {
oids[k] = v
}
switch t.Type {
case "printer":
for k, v := range printerOIDs {
oids[k] = v
}
case "switch":
for k, v := range switchScalarOIDs {
oids[k] = v
}
}
oidList := make([]string, 0, len(oids))
reverseMap := make(map[string]string)
for name, oid := range oids {
oidList = append(oidList, oid)
reverseMap[oid] = name
}
result, err := gs.Get(oidList)
if err != nil {
return fmt.Errorf("get: %w", err)
}
for _, pdu := range result.Variables {
name := resolveOID(pdu.Name, reverseMap)
if name == "" {
continue
}
value := pduToString(pdu)
if value == "" || value == "<nil>" || strings.HasPrefix(value, "<nil>") {
continue
}
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
// --- Switch: walk interface table + memory ---
if t.Type == "switch" {
m.walkSwitch(gs, t, now)
}
slog.Debug("snmp polled", "target", t.Name, "ip", t.IP, "type", t.Type)
return nil
}
// walkSwitch collects interface stats and memory for switch-type targets.
func (m *Module) walkSwitch(gs *gosnmp.GoSNMP, t db.SNMPTarget, now time.Time) {
// Interface status walk → portsUp / portsTotal
var portsUp, portsTotal int
var trafficIn, trafficOut uint64
ifStatus := walkOID(gs, oidIfOperStatus)
for _, v := range ifStatus {
portsTotal++
if v == "1" {
portsUp++
}
}
// Traffic counters (64-bit)
for _, v := range walkOID(gs, oidIfHCInOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficIn += n
}
for _, v := range walkOID(gs, oidIfHCOutOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficOut += n
}
save := func(name, value string) {
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
if portsTotal > 0 {
save("portsTotal", strconv.Itoa(portsTotal))
save("portsUp", strconv.Itoa(portsUp))
}
if trafficIn > 0 {
save("trafficIn", strconv.FormatUint(trafficIn, 10))
}
if trafficOut > 0 {
save("trafficOut", strconv.FormatUint(trafficOut, 10))
}
// Memory: find "Physical Memory" or "Real Memory" row
descrByIdx := walkOID(gs, oidHrStorageDescr)
usedByIdx := walkOID(gs, oidHrStorageUsed)
sizeByIdx := walkOID(gs, oidHrStorageSize)
allocByIdx := walkOID(gs, oidHrStorageAlloc)
for idx, descr := range descrByIdx {
d := strings.ToLower(descr)
if strings.Contains(d, "physical") || strings.Contains(d, "real") || strings.Contains(d, "ram") {
used, _ := strconv.ParseInt(usedByIdx[idx], 10, 64)
size, _ := strconv.ParseInt(sizeByIdx[idx], 10, 64)
alloc, _ := strconv.ParseInt(allocByIdx[idx], 10, 64)
if alloc <= 0 {
alloc = 1024
}
usedBytes := used * alloc
totalBytes := size * alloc
if totalBytes > 0 {
save("memUsed", strconv.FormatInt(usedBytes, 10))
save("memTotal", strconv.FormatInt(totalBytes, 10))
}
break
}
}
}
// walkOID performs an SNMP walk and returns a map of last-OID-index → value string.
func walkOID(gs *gosnmp.GoSNMP, baseOID string) map[string]string {
out := make(map[string]string)
err := gs.Walk(baseOID, func(pdu gosnmp.SnmpPDU) error {
// Extract the index (last component of OID)
name := strings.TrimPrefix(pdu.Name, ".")
parts := strings.Split(name, ".")
idx := parts[len(parts)-1]
val := pduRawToString(pdu)
if val != "" {
out[idx] = val
}
return nil
})
if err != nil {
slog.Debug("snmp walk failed", "oid", baseOID, "err", err)
}
return out
}
func resolveOID(oidName string, reverseMap map[string]string) string {
key := strings.TrimPrefix(oidName, ".")
if name := reverseMap[key]; name != "" {
return name
}
// Try without trailing .0
if len(key) > 2 && key[len(key)-2:] == ".0" {
return reverseMap[key[:len(key)-2]]
}
return ""
}
func snmpVersion(v string) gosnmp.SnmpVersion {
switch v {
case "v1":
return gosnmp.Version1
case "v3":
return gosnmp.Version3
default:
return gosnmp.Version2c
}
}
// pduToString converts a PDU value to a display string (formatted).
func pduToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.TimeTicks:
ticks, _ := pdu.Value.(uint32)
d := time.Duration(ticks) * 10 * time.Millisecond
h := int(d.Hours())
mn := int(d.Minutes()) % 60
return strconv.Itoa(h) + "h " + strconv.Itoa(mn) + "m"
case gosnmp.ObjectIdentifier:
if s, ok := pdu.Value.(string); ok {
return s
}
default:
return fmt.Sprintf("%v", pdu.Value)
}
return fmt.Sprintf("%v", pdu.Value)
}
// pduRawToString converts a PDU value to a raw numeric/string value (for walks).
func pduRawToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.Integer:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter32, gosnmp.Gauge32, gosnmp.Uinteger32:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter64:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.TimeTicks:
if t, ok := pdu.Value.(uint32); ok {
return fmt.Sprintf("%d", t)
}
}
return fmt.Sprintf("%v", pdu.Value)
}

View File

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