// 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)) }