1295 lines
36 KiB
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 ""
|
|
}
|