Files
IT-Nexus/nexus-scanner/internal/web/server.go

1295 lines
36 KiB
Go

package web
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
"github.com/cereda-systems/nexus-scanner/internal/module"
)
// LogEntry is a single captured log line shown on the /logs page.
type LogEntry struct {
Time string
Level string
LevelClass string
Message string
}
// LogBuffer is a fixed-size in-memory ring of log entries.
type LogBuffer struct {
mu sync.Mutex
entries []LogEntry
max int
}
// NewLogBuffer creates a log ring buffer with capacity max.
func NewLogBuffer(max int) *LogBuffer { return &LogBuffer{max: max} }
func (b *LogBuffer) Add(e LogEntry) {
b.mu.Lock()
defer b.mu.Unlock()
b.entries = append(b.entries, e)
if len(b.entries) > b.max {
b.entries = b.entries[len(b.entries)-b.max:]
}
}
func (b *LogBuffer) All(level string) []LogEntry {
b.mu.Lock()
defer b.mu.Unlock()
if level == "" {
out := make([]LogEntry, len(b.entries))
copy(out, b.entries)
return out
}
var out []LogEntry
for _, e := range b.entries {
if e.Level == level {
out = append(out, e)
}
}
return out
}
func (b *LogBuffer) Clear() {
b.mu.Lock()
defer b.mu.Unlock()
b.entries = b.entries[:0]
}
// NewSlogHandler wraps an existing handler and mirrors records into buf.
func NewSlogHandler(inner slog.Handler, buf *LogBuffer) *SlogHandler {
return &SlogHandler{inner: inner, buffer: buf}
}
// SlogHandler captures log records into a LogBuffer.
type SlogHandler struct {
inner slog.Handler
buffer *LogBuffer
}
func (h *SlogHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &SlogHandler{inner: h.inner.WithAttrs(attrs), buffer: h.buffer}
}
func (h *SlogHandler) WithGroup(name string) slog.Handler {
return &SlogHandler{inner: h.inner.WithGroup(name), buffer: h.buffer}
}
func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
lvl := r.Level.String()
cls := strings.ToLower(lvl)
if cls == "debug" {
cls = "ok"
}
h.buffer.Add(LogEntry{
Time: r.Time.Format("15:04:05"),
Level: lvl,
LevelClass: cls,
Message: r.Message,
})
return h.inner.Handle(ctx, r)
}
// sessionStore is a simple in-memory session store.
type sessionStore struct {
mu sync.Mutex
sessions map[string]time.Time
}
func (s *sessionStore) Create() string {
b := make([]byte, 16)
rand.Read(b)
id := hex.EncodeToString(b)
s.mu.Lock()
s.sessions[id] = time.Now().Add(24 * time.Hour)
s.mu.Unlock()
return id
}
func (s *sessionStore) Valid(id string) bool {
s.mu.Lock()
exp, ok := s.sessions[id]
s.mu.Unlock()
return ok && time.Now().Before(exp)
}
func (s *sessionStore) Delete(id string) {
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
// ModuleInfo is used in templates.
type ModuleInfo struct {
Name string
DisplayName string
Description string
Interval string
Enabled bool
LastRun string
}
// HostView extends db.Host with formatted fields for templates.
type HostView struct {
db.Host
LastSeenFmt string
OpenPorts []int
}
// Server is the embedded HTTP server.
type Server struct {
addr string
cfgPath string // path to config.yaml on disk (for saving after setup)
cfg *config.Config
store *db.Store
registry *module.Registry
sessions *sessionStore
logBuf *LogBuffer
tmpl *template.Template
mux *http.ServeMux
scanMu sync.Mutex
scanning bool
}
// NewServer creates and wires up the server.
func NewServer(cfgPath string, cfg *config.Config, store *db.Store, registry *module.Registry, logBuf *LogBuffer) *Server {
s := &Server{
addr: cfg.Web.Addr,
cfgPath: cfgPath,
cfg: cfg,
store: store,
registry: registry,
sessions: &sessionStore{sessions: make(map[string]time.Time)},
logBuf: logBuf,
mux: http.NewServeMux(),
}
s.loadTemplates()
s.routes()
return s
}
// LogBuffer returns the server's log buffer so main.go can wire slog into it.
func (s *Server) LogBuffer() *LogBuffer { return s.logBuf }
func (s *Server) loadTemplates() {
funcMap := template.FuncMap{
"inc": func(i int) int { return i + 1 },
"dec": func(i int) int { return i - 1 },
"mul": func(a, b int) int { return a * b },
"formatTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
}
t := template.New("").Funcs(funcMap)
for _, name := range []string{
"templates/layout.html",
"templates/login.html",
"templates/setup.html",
"templates/dashboard.html",
"templates/assets.html",
"templates/modules.html",
"templates/logs.html",
"templates/settings.html",
"templates/monitoring.html",
"templates/snmp.html",
} {
template.Must(t.ParseFS(webFS, name))
}
s.tmpl = t
}
func (s *Server) routes() {
s.mux.Handle("GET /static/", http.FileServer(http.FS(webFS)))
s.mux.HandleFunc("GET /healthz", s.handleHealthz)
// Setup wizard — only accessible before setup_complete=true
s.mux.HandleFunc("GET /setup", s.setupGuard(s.handleSetupForm))
s.mux.HandleFunc("POST /setup", s.setupGuard(s.handleSetupPost))
s.mux.HandleFunc("POST /setup/complete", s.setupGuard(s.handleSetupComplete))
// Login (only accessible after setup)
s.mux.HandleFunc("GET /login", s.requireSetup(s.handleLoginForm))
s.mux.HandleFunc("POST /login", s.requireSetup(s.handleLogin))
s.mux.HandleFunc("GET /logout", s.handleLogout)
// Protected app pages
s.mux.HandleFunc("GET /", s.requireSetup(s.auth(s.handleRoot)))
s.mux.HandleFunc("GET /dashboard", s.requireSetup(s.auth(s.handleDashboard)))
s.mux.HandleFunc("GET /assets", s.requireSetup(s.auth(s.handleAssets)))
s.mux.HandleFunc("GET /assets/export.csv", s.requireSetup(s.auth(s.handleAssetsCSV)))
s.mux.HandleFunc("GET /modules", s.requireSetup(s.auth(s.handleModules)))
s.mux.HandleFunc("POST /modules/{name}/toggle", s.requireSetup(s.auth(s.handleModuleToggle)))
s.mux.HandleFunc("POST /modules/arp_discovery/config", s.requireSetup(s.auth(s.handleARPConfig)))
s.mux.HandleFunc("GET /snmp", s.requireSetup(s.auth(s.handleSNMP)))
s.mux.HandleFunc("POST /snmp/targets/add", s.requireSetup(s.auth(s.handleSNMPAddTarget)))
s.mux.HandleFunc("POST /snmp/targets/{id}/delete", s.requireSetup(s.auth(s.handleSNMPDeleteTarget)))
s.mux.HandleFunc("GET /monitoring", s.requireSetup(s.auth(s.handleMonitoring)))
s.mux.HandleFunc("POST /monitoring/checks/add", s.requireSetup(s.auth(s.handleMonitoringAddCheck)))
s.mux.HandleFunc("POST /monitoring/checks/{id}/delete", s.requireSetup(s.auth(s.handleMonitoringDeleteCheck)))
s.mux.HandleFunc("POST /monitoring/scan", s.requireSetup(s.auth(s.handleMonitoringScan)))
s.mux.HandleFunc("GET /ad", s.requireSetup(s.auth(s.handleAD)))
s.mux.HandleFunc("GET /logs", s.requireSetup(s.auth(s.handleLogs)))
s.mux.HandleFunc("GET /logs/rows", s.requireSetup(s.auth(s.handleLogsRows)))
s.mux.HandleFunc("POST /logs/clear", s.requireSetup(s.auth(s.handleLogsClear)))
s.mux.HandleFunc("GET /settings", s.requireSetup(s.auth(s.handleSettings)))
s.mux.HandleFunc("POST /settings", s.requireSetup(s.auth(s.handleSettingsSave)))
s.mux.HandleFunc("POST /scan/start", s.requireSetup(s.auth(s.handleScanStart)))
}
// requireSetup redirects to /setup if first-run wizard hasn't been completed.
func (s *Server) requireSetup(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.cfg.SetupComplete {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
next(w, r)
}
}
// setupGuard redirects to /dashboard if setup is already done.
func (s *Server) setupGuard(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.SetupComplete {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ListenAndServe starts the HTTP server and blocks until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error {
srv := &http.Server{
Addr: s.addr,
Handler: s.mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutCtx)
}()
slog.Info("web server listening", "addr", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("web: %w", err)
}
return nil
}
/* ── Auth ─────────────────────────────────────────────────────────── */
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("nx_session")
if err != nil || !s.sessions.Valid(cookie.Value) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
// Already logged in?
if cookie, err := r.Cookie("nx_session"); err == nil && s.sessions.Valid(cookie.Value) {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
s.renderLogin(w, "")
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
pw := r.FormValue("password")
if pw == "" || pw != s.cfg.Web.Token {
s.renderLogin(w, "Falsches Passwort.")
return
}
id := s.sessions.Create()
http.SetCookie(w, &http.Cookie{
Name: "nx_session", Value: id,
Path: "/", MaxAge: 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("nx_session"); err == nil {
s.sessions.Delete(cookie.Value)
}
http.SetCookie(w, &http.Cookie{Name: "nx_session", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
s.tmpl.ExecuteTemplate(w, "login", map[string]any{
"Error": errMsg,
})
}
/* ── Setup ────────────────────────────────────────────────────────── */
func (s *Server) handleSetupForm(w http.ResponseWriter, r *http.Request) {
stepStr := r.URL.Query().Get("step")
step, _ := strconv.Atoi(stepStr)
s.renderSetup(w, step, nil, nil)
}
func (s *Server) handleSetupPost(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
step, _ := strconv.Atoi(r.FormValue("step"))
next, _ := strconv.Atoi(r.FormValue("next"))
switch step {
case 1: // validate subnet
subnet := r.FormValue("subnet")
if !strings.Contains(subnet, "/") {
s.renderSetup(w, step, fmt.Errorf("Ungültige CIDR-Notation (Beispiel: 192.168.0.0/24)"), r.Form)
return
}
case 3: // validate password
pw := r.FormValue("password")
confirm := r.FormValue("confirm")
if len(pw) < 6 {
s.renderSetup(w, step, fmt.Errorf("Passwort muss mindestens 6 Zeichen haben"), r.Form)
return
}
if pw != confirm {
s.renderSetup(w, step, fmt.Errorf("Passwörter stimmen nicht überein"), r.Form)
return
}
}
s.renderSetup(w, next, nil, r.Form)
}
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
// Apply wizard values to the in-memory config.
if site := r.FormValue("site"); site != "" {
s.cfg.Site = site
}
if subnet := r.FormValue("subnet"); subnet != "" {
s.cfg.Modules.ARPDiscovery.Subnets = []string{subnet}
s.cfg.Modules.ARPDiscovery.Enabled = true
}
if iface := r.FormValue("interface"); iface != "" {
s.cfg.Modules.ARPDiscovery.Interface = iface
}
if url := r.FormValue("nexus_url"); url != "" {
s.cfg.Nexus.URL = url
}
if key := r.FormValue("nexus_api_key"); key != "" {
s.cfg.Nexus.APIKey = key
}
if pw := r.FormValue("password"); len(pw) >= 6 {
s.cfg.Web.Token = pw
}
s.cfg.SetupComplete = true
// Persist to disk so the wizard doesn't re-appear after restart.
if err := config.Save(s.cfgPath, s.cfg); err != nil {
slog.Error("save config after setup", "err", err)
// Don't block the user — continue anyway.
} else {
slog.Info("config saved after setup", "path", s.cfgPath, "site", s.cfg.Site)
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) renderSetup(w http.ResponseWriter, step int, err error, form map[string][]string) {
steps := []int{0, 1, 2, 3, 4}
// Detect available network interfaces (up, non-loopback, with IP).
ifaces := detectInterfaces()
selectedIface := getForm(form, "interface")
if selectedIface == "" && len(ifaces) > 0 {
selectedIface = ifaces[0]
}
data := map[string]any{
"Step": step,
"Steps": steps,
"Error": func() string {
if err != nil {
return err.Error()
}
return ""
}(),
"Site": getForm(form, "site"),
"Subnet": getForm(form, "subnet"),
"Interface": selectedIface,
"Interfaces": ifaces,
"NexusURL": getForm(form, "nexus_url"),
"NexusAPIKey": getForm(form, "nexus_api_key"),
"Password": getForm(form, "password"),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
s.tmpl.ExecuteTemplate(w, "setup", data)
}
/* ── Pages ─────────────────────────────────────────────────────────── */
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountHosts()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "hosts": count, "time": time.Now().UTC(),
})
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
hosts, _ := s.store.ListHosts("")
count, _ := s.store.CountHosts()
online, offline := 0, 0
for _, h := range hosts {
if h.Status == "online" {
online++
} else {
offline++
}
}
recent := hosts
if len(recent) > 5 {
recent = recent[:5]
}
recentViews := make([]HostView, len(recent))
for i, h := range recent {
recentViews[i] = HostView{Host: h, LastSeenFmt: formatAgo(h.LastSeen)}
}
lastScan, _ := s.store.LastScanTime("sys_arp")
lastScanStr := "—"
if !lastScan.IsZero() {
lastScanStr = formatAgo(lastScan)
}
// Count hosts added in last hour as "recent"
recentCount := 0
for _, h := range hosts {
if time.Since(h.FirstSeen) < time.Hour {
recentCount++
}
}
s.scanMu.Lock()
scanning := s.scanning
s.scanMu.Unlock()
s.renderPage(w, "dashboard", map[string]any{
"Page": "dashboard",
"Title": "Übersicht",
"Scanning": scanning,
"HostCount": count,
"OnlineCount": online,
"OfflineCount": offline,
"RecentCount": recentCount,
"LastScan": lastScanStr,
"ARPInterval": s.cfg.Modules.ARPDiscovery.Interval.String(),
"RecentHosts": recentViews,
"Modules": s.moduleInfos(),
})
}
func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
siteF := r.URL.Query().Get("site")
statusF := r.URL.Query().Get("status")
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
const perPage = 20
all, _ := s.store.ListHosts(siteF)
// Filter
var filtered []db.Host
for _, h := range all {
if q != "" {
lower := strings.ToLower(q)
if !strings.Contains(strings.ToLower(h.IP), lower) &&
!strings.Contains(strings.ToLower(h.MAC), lower) &&
!strings.Contains(strings.ToLower(h.Hostname), lower) {
continue
}
}
if statusF != "" && h.Status != statusF {
continue
}
filtered = append(filtered, h)
}
total := len(filtered)
totalPages := (total + perPage - 1) / perPage
start := (page - 1) * perPage
end := start + perPage
if end > total {
end = total
}
var pageHosts []db.Host
if start < total {
pageHosts = filtered[start:end]
}
allPorts, _ := s.store.ListAllHostPorts()
views := make([]HostView, len(pageHosts))
for i, h := range pageHosts {
views[i] = HostView{Host: h, LastSeenFmt: formatAgo(h.LastSeen), OpenPorts: allPorts[h.IP]}
}
pages := make([]int, totalPages)
for i := range pages {
pages[i] = i + 1
}
qs := fmt.Sprintf("q=%s&site=%s&status=%s", q, siteF, statusF)
lastScan, _ := s.store.LastScanTime("sys_arp")
lastScanStr := "gerade eben"
if !lastScan.IsZero() {
lastScanStr = formatAgo(lastScan)
}
s.renderPage(w, "assets", map[string]any{
"Page": "assets",
"Title": "Assets",
"Hosts": views,
"TotalHosts": total,
"LastScan": lastScanStr,
"SiteFilter": siteF,
"StatusFilter": statusF,
"Query": q,
"PageNum": page,
"TotalPages": totalPages,
"Pages": pages,
"QueryString": qs,
})
}
func (s *Server) handleAssetsCSV(w http.ResponseWriter, r *http.Request) {
hosts, _ := s.store.ListHosts("")
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="assets.csv"`)
fmt.Fprintln(w, "IP,MAC,Hersteller,Hostname,Standort,Zuletzt gesehen,Status")
for _, h := range hosts {
fmt.Fprintf(w, "%s,%s,%s,%s,%s,%s,%s\n",
h.IP, h.MAC, h.Vendor, h.Hostname, h.Site,
h.LastSeen.Format("2006-01-02 15:04:05"), h.Status)
}
}
type ADComputerView struct {
db.ADComputer
SeenInNetwork bool
}
func (s *Server) handleAD(w http.ResponseWriter, r *http.Request) {
computers, _ := s.store.ListADComputers()
hosts, _ := s.store.ListHosts("")
// Build hostname set for quick lookup.
knownHostnames := make(map[string]struct{})
for _, h := range hosts {
if h.Hostname != "" {
knownHostnames[strings.ToLower(h.Hostname)] = struct{}{}
}
}
views := make([]ADComputerView, len(computers))
seen, missing := 0, 0
for i, c := range computers {
_, inNet := knownHostnames[strings.ToLower(c.CN)]
views[i] = ADComputerView{ADComputer: c, SeenInNetwork: inNet}
if inNet {
seen++
} else {
missing++
}
}
funcMap := template.FuncMap{
"inc": func(i int) int { return i + 1 },
"dec": func(i int) int { return i - 1 },
"formatTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
"not": func(b bool) bool { return !b },
}
t, err := template.New("").Funcs(funcMap).ParseFS(webFS,
"templates/layout.html",
"templates/ad.html",
)
if err != nil {
http.Error(w, "template error", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.ExecuteTemplate(w, "layout", map[string]any{
"Page": "ad",
"Title": "Active Directory",
"SiteID": s.cfg.Site,
"Addr": s.cfg.Web.Addr,
"Computers": views,
"Domain": s.cfg.Modules.ADSync.SearchBase,
"ADEnabled": s.cfg.Modules.ADSync.Enabled,
"SeenCount": seen,
"MissingCount": missing,
})
}
func (s *Server) handleModules(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, "modules", map[string]any{
"Page": "modules",
"Title": "Module",
"Modules": s.moduleInfos(),
"ARPSubnets": s.cfg.Modules.ARPDiscovery.Subnets,
"ARPInterface": s.cfg.Modules.ARPDiscovery.Interface,
})
}
func (s *Server) handleModuleToggle(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
current := s.store.GetModuleEnabled(name)
enabled := !current
if err := s.store.SetModuleEnabled(name, enabled); err != nil {
slog.Error("toggle module", "module", name, "err", err)
} else {
slog.Info("module toggled", "module", name, "enabled", enabled)
}
http.Redirect(w, r, "/modules", http.StatusSeeOther)
}
func (s *Server) handleARPConfig(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
subnets := r.Form["subnet"]
iface := r.FormValue("interface")
if len(subnets) > 0 {
s.cfg.Modules.ARPDiscovery.Subnets = subnets
}
if iface != "" {
s.cfg.Modules.ARPDiscovery.Interface = iface
}
if err := config.Save(s.cfgPath, s.cfg); err != nil {
slog.Error("save arp config", "err", err)
} else {
slog.Info("arp config saved", "subnets", subnets, "interface", iface)
}
http.Redirect(w, r, "/modules", http.StatusSeeOther)
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
level := r.URL.Query().Get("level")
logs := s.logBuf.All(level)
reverseLogs(logs)
s.renderPage(w, "logs", map[string]any{
"Page": "logs",
"Title": "Logs",
"Logs": logs,
"LevelFilter": level,
})
}
func (s *Server) handleLogsRows(w http.ResponseWriter, r *http.Request) {
level := r.URL.Query().Get("level")
logs := s.logBuf.All(level)
reverseLogs(logs)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
s.tmpl.ExecuteTemplate(w, "log-rows", map[string]any{
"Logs": logs,
"LevelFilter": level,
})
}
func (s *Server) handleLogsClear(w http.ResponseWriter, r *http.Request) {
s.logBuf.Clear()
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) settingsData(extra map[string]any) map[string]any {
data := map[string]any{
"Page": "settings",
"Title": "Einstellungen",
"Site": s.cfg.Site,
"NexusURL": s.cfg.Nexus.URL,
"NexusAPIKey": s.cfg.Nexus.APIKey,
"ARPInterval": s.cfg.Modules.ARPDiscovery.Interval.String(),
"ADEnabled": s.cfg.Modules.ADSync.Enabled,
"ADServer": s.cfg.Modules.ADSync.Server,
"ADPort": s.cfg.Modules.ADSync.Port,
"ADBindDN": s.cfg.Modules.ADSync.BindDN,
"ADBindPW": s.cfg.Modules.ADSync.BindPassword,
"ADSearchBase": s.cfg.Modules.ADSync.SearchBase,
"ADInterval": s.cfg.Modules.ADSync.Interval.String(),
"OfflineAfter": s.cfg.OfflineAfter.String(),
"AlertNexus": s.cfg.Alert.NexusEnabled,
"SMTPEnabled": s.cfg.Alert.SMTP.Enabled,
"SMTPHost": s.cfg.Alert.SMTP.Host,
"SMTPPort": s.cfg.Alert.SMTP.Port,
"SMTPUser": s.cfg.Alert.SMTP.Username,
"SMTPPass": s.cfg.Alert.SMTP.Password,
"SMTPFrom": s.cfg.Alert.SMTP.From,
"SMTPTo": s.cfg.Alert.SMTP.To,
}
for k, v := range extra {
data[k] = v
}
return data
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, "settings", s.settingsData(nil))
}
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
// Apply general settings.
if site := r.FormValue("site"); site != "" {
s.cfg.Site = site
}
s.cfg.Nexus.URL = r.FormValue("nexus_url")
if key := r.FormValue("nexus_api_key"); key != "" {
s.cfg.Nexus.APIKey = key
}
// Apply AD settings.
s.cfg.Modules.ADSync.Enabled = r.FormValue("ad_enabled") == "on"
s.cfg.Modules.ADSync.Server = r.FormValue("ad_server")
s.cfg.Modules.ADSync.BindDN = r.FormValue("ad_bind_dn")
if pw := r.FormValue("ad_bind_password"); pw != "" {
s.cfg.Modules.ADSync.BindPassword = pw
}
if sb := r.FormValue("ad_search_base"); sb != "" {
s.cfg.Modules.ADSync.SearchBase = sb
}
if port := r.FormValue("ad_port"); port != "" {
if p, err := strconv.Atoi(port); err == nil && p > 0 {
s.cfg.Modules.ADSync.Port = p
}
}
if iv := r.FormValue("ad_interval"); iv != "" {
if d, err := time.ParseDuration(iv); err == nil {
s.cfg.Modules.ADSync.Interval = d
}
}
// offline_after
if oa := r.FormValue("offline_after"); oa != "" {
if d, err := time.ParseDuration(oa); err == nil && d > 0 {
s.cfg.OfflineAfter = d
}
}
// Apply alert settings.
s.cfg.Alert.NexusEnabled = r.FormValue("alert_nexus") == "on"
s.cfg.Alert.SMTP.Enabled = r.FormValue("smtp_enabled") == "on"
s.cfg.Alert.SMTP.Host = r.FormValue("smtp_host")
s.cfg.Alert.SMTP.Username = r.FormValue("smtp_user")
if p := r.FormValue("smtp_pass"); p != "" {
s.cfg.Alert.SMTP.Password = p
}
s.cfg.Alert.SMTP.From = r.FormValue("smtp_from")
s.cfg.Alert.SMTP.To = r.FormValue("smtp_to")
if port := r.FormValue("smtp_port"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
s.cfg.Alert.SMTP.Port = p
}
}
// Apply password change.
if pw := r.FormValue("new_password"); len(pw) >= 6 {
if pw == r.FormValue("confirm_password") {
s.cfg.Web.Token = pw
}
}
// Persist to disk.
var saveErr string
if err := config.Save(s.cfgPath, s.cfg); err != nil {
slog.Error("save config", "err", err)
saveErr = err.Error()
} else {
slog.Info("settings saved", "site", s.cfg.Site, "ad_enabled", s.cfg.Modules.ADSync.Enabled)
}
s.renderPage(w, "settings", s.settingsData(map[string]any{
"Saved": saveErr == "",
"SaveErr": saveErr,
}))
}
func (s *Server) handleScanStart(w http.ResponseWriter, r *http.Request) {
s.scanMu.Lock()
if !s.scanning {
s.scanning = true
go func() {
slog.Info("manual scan triggered")
ctx := context.Background()
for _, m := range s.registry.All() {
if err := m.Run(ctx); err != nil {
slog.Error("manual scan error", "module", m.Name(), "err", err)
}
}
s.scanMu.Lock()
s.scanning = false
s.scanMu.Unlock()
slog.Info("manual scan completed")
}()
}
s.scanMu.Unlock()
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
/* ── SNMP ──────────────────────────────────────────────────────────── */
type SNMPTargetView struct {
Target db.SNMPTarget
HasData bool
SysName string
SysDescr string
SysUpTime string
SysLocation string
SysContact string
TonerLevel string
TonerMax string
TonerPct int
PageCount string
PrinterStatus string
Interval string
// Switch fields
PortsTotal string
PortsUp string
TrafficIn string
TrafficOut string
MemUsed string
MemTotal string
MemPct int
CpuLoad string
IfNumber string
// Raw OID map for detail modal
AllValues map[string]string
}
func (s *Server) handleSNMP(w http.ResponseWriter, r *http.Request) {
targets, _ := s.store.ListSNMPTargets()
results, _ := s.store.LatestSNMPResults()
var views []SNMPTargetView
for _, t := range targets {
v := SNMPTargetView{Target: t, Interval: s.cfg.Modules.SNMP.Interval.String()}
if vals, ok := results[t.ID]; ok && len(vals) > 0 {
v.HasData = true
v.AllValues = vals
v.SysName = vals["sysName"]
v.SysDescr = vals["sysDescr"]
v.SysUpTime = vals["sysUpTime"]
v.SysLocation = vals["sysLocation"]
v.SysContact = vals["sysContact"]
v.TonerLevel = vals["tonerLevel"]
v.TonerMax = vals["tonerMax"]
v.PageCount = vals["pageCount"]
switch vals["printerStatus"] {
case "3":
v.PrinterStatus = "Bereit"
case "4":
v.PrinterStatus = "Druckt"
case "5":
v.PrinterStatus = "Aufwärmen"
default:
v.PrinterStatus = ""
}
v.PortsTotal = vals["portsTotal"]
v.PortsUp = vals["portsUp"]
v.IfNumber = vals["ifNumber"]
if cpu := vals["cpuLoad"]; cpu != "" && cpu != "<nil>" && !strings.Contains(cpu, "nil") {
v.CpuLoad = cpu
}
if v.TonerLevel != "" {
level, _ := strconv.Atoi(v.TonerLevel)
if maxStr := vals["tonerMax"]; maxStr != "" {
maxN, _ := strconv.Atoi(maxStr)
if maxN > 0 {
v.TonerPct = level * 100 / maxN
}
} else {
v.TonerPct = level
}
}
// Format traffic bytes → human readable, also update AllValues for modal
if raw := vals["trafficIn"]; raw != "" {
n, _ := strconv.ParseUint(raw, 10, 64)
v.TrafficIn = formatBytes(n)
vals["trafficIn"] = formatBytes(n)
}
if raw := vals["trafficOut"]; raw != "" {
n, _ := strconv.ParseUint(raw, 10, 64)
v.TrafficOut = formatBytes(n)
vals["trafficOut"] = formatBytes(n)
}
// Memory
if raw := vals["memUsed"]; raw != "" {
n, _ := strconv.ParseUint(raw, 10, 64)
v.MemUsed = formatBytes(n)
vals["memUsed"] = formatBytes(n)
}
if raw := vals["memTotal"]; raw != "" {
n, _ := strconv.ParseUint(raw, 10, 64)
v.MemTotal = formatBytes(n)
vals["memTotal"] = formatBytes(n)
used, _ := strconv.ParseUint(vals["memUsed"], 10, 64)
if n > 0 {
v.MemPct = int(used * 100 / n)
}
}
// Remove nil entries from detail modal
for k, val := range vals {
if val == "<nil>" || strings.Contains(val, "<nil>") {
delete(vals, k)
}
}
}
views = append(views, v)
}
s.renderPage(w, "snmp", map[string]any{
"Page": "snmp",
"Title": "SNMP",
"Targets": views,
"AddErr": r.URL.Query().Get("err"),
})
}
func (s *Server) handleSNMPAddTarget(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
ip := strings.TrimSpace(r.FormValue("ip"))
community := r.FormValue("community")
version := r.FormValue("version")
typ := r.FormValue("type")
if community == "" {
community = "public"
}
if name == "" || ip == "" {
http.Redirect(w, r, "/snmp?err=Name+und+IP+erforderlich", http.StatusSeeOther)
return
}
if _, err := s.store.AddSNMPTarget(name, ip, community, version, typ); err != nil {
slog.Error("add snmp target", "err", err)
}
http.Redirect(w, r, "/snmp", http.StatusSeeOther)
}
func (s *Server) handleSNMPDeleteTarget(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
_ = s.store.DeleteSNMPTarget(id)
http.Redirect(w, r, "/snmp", http.StatusSeeOther)
}
/* ── Monitoring ────────────────────────────────────────────────────── */
// MonitorCheckView combines a check definition with its latest result and history.
type MonitorCheckView struct {
Check db.MonitorCheck
Latest db.MonitorResult
HasResult bool
History []db.MonitorResult
}
func (s *Server) handleMonitoring(w http.ResponseWriter, r *http.Request) {
s.renderMonitoring(w, "")
}
func (s *Server) renderMonitoring(w http.ResponseWriter, addErr string) {
checks, _ := s.store.ListMonitorChecks()
latest, _ := s.store.LatestMonitorResults()
var views []MonitorCheckView
onlineCount, offlineCount := 0, 0
for _, c := range checks {
v := MonitorCheckView{Check: c}
if r, ok := latest[c.ID]; ok {
v.Latest = r
v.HasResult = true
history, _ := s.store.MonitorResultHistory(c.ID, 20)
v.History = history
if r.Status == "online" {
onlineCount++
} else {
offlineCount++
}
}
views = append(views, v)
}
allOnline := len(checks) > 0 && offlineCount == 0 && len(latest) == len(checks)
s.renderPage(w, "monitoring", map[string]any{
"Page": "monitoring",
"Title": "Monitoring",
"Checks": views,
"TotalChecks": len(checks),
"OnlineCount": onlineCount,
"OfflineCount": offlineCount,
"AllOnline": allOnline,
"AddErr": addErr,
})
}
func (s *Server) handleMonitoringAddCheck(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
typ := r.FormValue("type")
target := strings.TrimSpace(r.FormValue("target"))
if name == "" || target == "" {
s.renderMonitoring(w, "Name und Ziel dürfen nicht leer sein.")
return
}
validTypes := map[string]bool{"ping": true, "http": true, "tcp": true}
if !validTypes[typ] {
s.renderMonitoring(w, "Ungültiger Typ.")
return
}
if _, err := s.store.AddMonitorCheck(name, typ, target); err != nil {
slog.Error("add monitor check", "err", err)
s.renderMonitoring(w, "Fehler beim Speichern: "+err.Error())
return
}
slog.Info("monitor check added", "name", name, "type", typ, "target", target)
http.Redirect(w, r, "/monitoring", http.StatusSeeOther)
}
func (s *Server) handleMonitoringDeleteCheck(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := s.store.DeleteMonitorCheck(id); err != nil {
slog.Error("delete monitor check", "id", id, "err", err)
}
http.Redirect(w, r, "/monitoring", http.StatusSeeOther)
}
func (s *Server) handleMonitoringScan(w http.ResponseWriter, r *http.Request) {
s.scanMu.Lock()
if !s.scanning {
s.scanning = true
go func() {
ctx := context.Background()
for _, m := range s.registry.All() {
if m.Name() == "site_monitoring" {
if err := m.Run(ctx); err != nil {
slog.Error("monitoring scan error", "err", err)
}
}
}
s.scanMu.Lock()
s.scanning = false
s.scanMu.Unlock()
}()
}
s.scanMu.Unlock()
http.Redirect(w, r, "/monitoring", http.StatusSeeOther)
}
/* ── Helpers ──────────────────────────────────────────────────────── */
func (s *Server) renderPage(w http.ResponseWriter, page string, data map[string]any) {
// Inject shared layout data.
if _, ok := data["Theme"]; !ok {
data["Theme"] = "light"
}
if _, ok := data["SiteID"]; !ok {
data["SiteID"] = s.cfg.Site
}
if _, ok := data["HostCount"]; !ok {
n, _ := s.store.CountHosts()
data["HostCount"] = n
}
if _, ok := data["Addr"]; !ok {
data["Addr"] = s.cfg.Web.Addr
}
if _, ok := data["Scanning"]; !ok {
s.scanMu.Lock()
data["Scanning"] = s.scanning
s.scanMu.Unlock()
}
// Parse layout + page template together so "content" block is available.
funcMap := template.FuncMap{
"inc": func(i int) int { return i + 1 },
"dec": func(i int) int { return i - 1 },
"mul": func(a, b int) int { return a * b },
"formatTime": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
"not": func(b bool) bool { return !b },
}
t, err := template.New("").Funcs(funcMap).ParseFS(webFS,
"templates/layout.html",
"templates/"+page+".html",
)
if err != nil {
slog.Error("template parse", "page", page, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
slog.Error("template exec", "page", page, "err", err)
}
}
func (s *Server) moduleInfos() []ModuleInfo {
var infos []ModuleInfo
for _, m := range s.registry.All() {
lastRun, _ := s.store.LastScanTime(m.Name())
lastRunStr := ""
if !lastRun.IsZero() {
lastRunStr = formatAgo(lastRun)
}
infos = append(infos, ModuleInfo{
Name: m.Name(),
DisplayName: displayName(m.Name()),
Description: moduleDesc(m.Name()),
Interval: m.Interval().String(),
Enabled: s.store.GetModuleEnabled(m.Name()),
LastRun: lastRunStr,
})
}
return infos
}
func formatAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return fmt.Sprintf("vor %ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("vor %dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("vor %dh", int(d.Hours()))
default:
return fmt.Sprintf("vor %dd", int(d.Hours()/24))
}
}
func reverseLogs(logs []LogEntry) {
sort.Slice(logs, func(i, j int) bool { return i > j })
}
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func getForm(form map[string][]string, key string) string {
if form == nil {
return ""
}
vals := form[key]
if len(vals) == 0 {
return ""
}
return vals[0]
}
func displayName(name string) string {
m := map[string]string{
"arp_discovery": "ARP-Discovery",
"dns_reverse": "DNS-Reverse",
"mac_vendor": "MAC-Vendor",
"ad_sync": "Active Directory",
"unifi_api": "UniFi Controller",
}
if v, ok := m[name]; ok {
return v
}
return name
}
// detectInterfaces returns names of active non-loopback network interfaces.
func detectInterfaces() []string {
ifaces, err := net.Interfaces()
if err != nil {
return []string{"eth0"}
}
var names []string
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, _ := iface.Addrs()
hasIP := false
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
hasIP = true
break
}
}
if hasIP {
names = append(names, iface.Name)
}
}
if len(names) == 0 {
return []string{"eth0"}
}
return names
}
func moduleDesc(name string) string {
m := map[string]string{
"arp_discovery": "Findet alle Geräte per ARP-Broadcast",
"dns_reverse": "Löst IPs in Hostnamen auf",
"mac_vendor": "Bestimmt Hersteller aus MAC-Adresse",
"ad_sync": "Synchronisiert Computer aus winkel.local",
"unifi_api": "Liest Clients aus UniFi Controller",
}
if v, ok := m[name]; ok {
return v
}
return ""
}