Initial commit: IT Nexus Web-App
This commit is contained in:
182
nexus-scanner/internal/modules/adsync/adsync.go
Normal file
182
nexus-scanner/internal/modules/adsync/adsync.go
Normal 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()
|
||||
}
|
||||
167
nexus-scanner/internal/modules/arp/arp.go
Normal file
167
nexus-scanner/internal/modules/arp/arp.go
Normal 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
|
||||
}
|
||||
230
nexus-scanner/internal/modules/arp/arp_test.go
Normal file
230
nexus-scanner/internal/modules/arp/arp_test.go
Normal 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
|
||||
}
|
||||
94
nexus-scanner/internal/modules/dnsreverse/dnsreverse.go
Normal file
94
nexus-scanner/internal/modules/dnsreverse/dnsreverse.go
Normal 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
|
||||
}
|
||||
129
nexus-scanner/internal/modules/macvendor/macvendor.go
Normal file
129
nexus-scanner/internal/modules/macvendor/macvendor.go
Normal 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
|
||||
}
|
||||
54
nexus-scanner/internal/modules/macvendor/macvendor_test.go
Normal file
54
nexus-scanner/internal/modules/macvendor/macvendor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
130
nexus-scanner/internal/modules/nexusreporter/reporter.go
Normal file
130
nexus-scanner/internal/modules/nexusreporter/reporter.go
Normal 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))
|
||||
}
|
||||
106
nexus-scanner/internal/modules/portscan/portscan.go
Normal file
106
nexus-scanner/internal/modules/portscan/portscan.go
Normal 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
|
||||
}
|
||||
298
nexus-scanner/internal/modules/sitemon/sitemon.go
Normal file
298
nexus-scanner/internal/modules/sitemon/sitemon.go
Normal 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
|
||||
}
|
||||
334
nexus-scanner/internal/modules/snmpmod/snmp.go
Normal file
334
nexus-scanner/internal/modules/snmpmod/snmp.go
Normal 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)
|
||||
}
|
||||
142
nexus-scanner/internal/modules/sysarp/sysarp.go
Normal file
142
nexus-scanner/internal/modules/sysarp/sysarp.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user