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 != "" && !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 == "" || strings.Contains(val, "") { 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 "" }