Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

49
nexus-scanner/Makefile Normal file
View File

@@ -0,0 +1,49 @@
BINARY := nexus-scanner
CMD := ./cmd/nexus-scanner
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION)"
.PHONY: build run test fmt vet clean install
## build: compile for the local OS/arch
build:
go build $(LDFLAGS) -o $(BINARY) $(CMD)
## build-linux: cross-compile for Linux amd64 + arm64
build-linux:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 $(CMD)
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-linux-arm64 $(CMD)
## run: build and run with the example config (local testing only)
run: build
./$(BINARY) config.example.yaml
## test: run all tests
test:
go test -v -race ./...
## fmt: format all Go source files
fmt:
gofmt -w ./...
## vet: run go vet
vet:
go vet ./...
## tidy: update go.sum and remove unused dependencies
tidy:
go mod tidy
## clean: remove built binaries
clean:
rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
## install: deploy to a scanner VM (set SCANNER_HOST env var)
## Usage: SCANNER_HOST=root@192.168.0.x make install
install: build-linux
scp $(BINARY)-linux-amd64 $(SCANNER_HOST):/usr/local/bin/$(BINARY)
ssh $(SCANNER_HOST) "systemctl restart $(BINARY)"
## help: print this help
help:
@grep -E '^## ' Makefile | sed 's/## / /'

View File

@@ -0,0 +1,156 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"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"
arpmod "github.com/cereda-systems/nexus-scanner/internal/modules/arp"
admod "github.com/cereda-systems/nexus-scanner/internal/modules/adsync"
dnsmod "github.com/cereda-systems/nexus-scanner/internal/modules/dnsreverse"
vendormod "github.com/cereda-systems/nexus-scanner/internal/modules/macvendor"
reportermod "github.com/cereda-systems/nexus-scanner/internal/modules/nexusreporter"
portscanmod "github.com/cereda-systems/nexus-scanner/internal/modules/portscan"
sitemon "github.com/cereda-systems/nexus-scanner/internal/modules/sitemon"
snmpmod "github.com/cereda-systems/nexus-scanner/internal/modules/snmpmod"
sysarpmod "github.com/cereda-systems/nexus-scanner/internal/modules/sysarp"
"github.com/cereda-systems/nexus-scanner/internal/scheduler"
"github.com/cereda-systems/nexus-scanner/internal/web"
)
var version = "dev"
func main() {
cfgPath := "/etc/nexus-scanner/config.yaml"
if len(os.Args) > 1 {
cfgPath = os.Args[1]
}
cfg, err := config.Load(cfgPath)
if err != nil {
slog.Error("failed to load config", "path", cfgPath, "err", err)
os.Exit(1)
}
// Log buffer captures entries for the /logs web page.
logBuf := web.NewLogBuffer(500)
level := slog.LevelInfo
if cfg.LogLevel == "debug" {
level = slog.LevelDebug
}
inner := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
handler := web.NewSlogHandler(inner, logBuf)
slog.SetDefault(slog.New(handler))
slog.Info("nexus-scanner starting", "version", version, "site", cfg.Site)
store, err := db.Open(cfg.DB.Path)
if err != nil {
slog.Error("failed to open database", "path", cfg.DB.Path, "err", err)
os.Exit(1)
}
defer store.Close()
registry := module.NewRegistry()
// sys_arp: reads the OS ARP cache — works on Windows, Linux and macOS
// without raw-socket privileges. Always enabled.
registry.Register(sysarpmod.New(cfg.Site, store))
slog.Info("module registered", "module", "sys_arp")
// dns_reverse: enriches known hosts with reverse-DNS hostnames.
registry.Register(dnsmod.New(cfg.Site, store))
slog.Info("module registered", "module", "dns_reverse")
// mac_vendor: assigns OUI-based vendor names to known hosts (runs once at startup).
registry.Register(vendormod.New(cfg.Site, store))
slog.Info("module registered", "module", "mac_vendor")
// ad_sync: queries Active Directory for all computer objects.
if cfg.Modules.ADSync.Enabled {
registry.Register(admod.New(cfg.Site, cfg.Modules.ADSync, store))
slog.Info("module registered", "module", "ad_sync",
"server", cfg.Modules.ADSync.Server,
"search_base", cfg.Modules.ADSync.SearchBase,
)
}
// site_monitoring: availability checks (ping, http, tcp) — always enabled,
// checks are managed via the web UI.
cfg.Modules.SiteMonitoring.Enabled = true
registry.Register(sitemon.New(cfg.Site, cfg.Modules.SiteMonitoring, cfg.Alert, *cfg, store))
slog.Info("module registered", "module", "site_monitoring",
"interval", cfg.Modules.SiteMonitoring.Interval)
// port_scan: TCP port scanner for all online hosts.
cfg.Modules.PortScan.Enabled = true
registry.Register(portscanmod.New(cfg.Site, cfg.Modules.PortScan, store))
slog.Info("module registered", "module", "port_scan",
"interval", cfg.Modules.PortScan.Interval)
// snmp: polls SNMP targets for device info (managed via web UI).
cfg.Modules.SNMP.Enabled = true
registry.Register(snmpmod.New(cfg.Site, cfg.Modules.SNMP, store))
slog.Info("module registered", "module", "snmp",
"interval", cfg.Modules.SNMP.Interval)
// nexus_reporter: pushes changed assets to IT Nexus API.
if cfg.Nexus.URL != "" && cfg.Nexus.APIKey != "" {
cfg.Modules.NexusReporter.Enabled = true
registry.Register(reportermod.New(cfg.Site, cfg.Modules.NexusReporter, *cfg, store))
slog.Info("module registered", "module", "nexus_reporter")
}
// arp_discovery: full ARP sweep via raw sockets (Linux only).
if cfg.Modules.ARPDiscovery.Enabled {
registry.Register(arpmod.New(cfg.Site, cfg.Modules.ARPDiscovery, store))
slog.Info("module registered", "module", "arp_discovery",
"interval", cfg.Modules.ARPDiscovery.Interval,
"subnets", cfg.Modules.ARPDiscovery.Subnets,
)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Offline-Sweeper: marks hosts as offline when not seen for OfflineAfter duration.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := store.MarkStaleHostsOffline(cfg.OfflineAfter)
if err != nil {
slog.Error("offline sweep", "err", err)
} else if n > 0 {
slog.Info("hosts marked offline", "count", n, "threshold", cfg.OfflineAfter)
}
}
}
}()
sched := scheduler.New(registry)
go sched.Run(ctx)
srv := web.NewServer(cfgPath, cfg, store, registry, logBuf)
go func() {
if err := srv.ListenAndServe(ctx); err != nil {
slog.Error("web server stopped", "err", err)
stop()
}
}()
slog.Info("nexus-scanner ready", "addr", cfg.Web.Addr)
<-ctx.Done()
slog.Info("nexus-scanner shutting down")
}

View File

@@ -0,0 +1,53 @@
site: LUD
log_level: debug
setup_complete: true
db:
path: C:/temp/nexus-scanner/scanner.db
web:
addr: :8090
token: demo1234
nexus:
url: https://it-nexus.cereda-systems.de
api_key: nsx-aa0193d719c2be765441e1dd24bf7480
offline_after: 15m0s
alert:
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
from: ""
to: ""
nexus_enabled: true
modules:
arp_discovery:
enabled: false
interval: 5m0s
subnets:
- 192.168.0.0/24
interface: eth0
ad_sync:
enabled: true
interval: 30m0s
server: 192.168.0.12
port: 389
bind_dn: svc-scanner@winkel.local
bind_password: 6ApvVf6X9hvHBxYUqQ3y
search_base: DC=winkel,DC=local
tls: false
site_monitoring:
enabled: true
interval: 1m0s
port_scan:
enabled: true
interval: 15m0s
ports: []
timeout: 500ms
snmp:
enabled: true
interval: 5m0s
community: public
nexus_reporter:
enabled: true
interval: 10m0s

View File

@@ -0,0 +1,35 @@
# Nexus Scanner — example configuration
# Copy to /etc/nexus-scanner/config.yaml and adjust.
site: "LUD" # LUD = Lüdenscheid | BAR = Barleben
log_level: "info" # debug | info | warn | error
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8080"
token: "change-me-generate-with-openssl-rand-hex-32"
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "" # will be filled once IT Nexus wires up the scanner endpoint
modules:
ad_sync:
enabled: true
interval: 30m
server: "192.168.0.1" # IP des Domain Controllers
port: 389 # 389 = LDAP, 636 = LDAPS
bind_dn: "svc-scanner@winkel.local"
bind_password: "" # Passwort des Service-Accounts
search_base: "DC=winkel,DC=local"
tls: false
arp_discovery:
enabled: true
interval: 5m # how often to scan (Go duration: 30s, 5m, 1h …)
interface: "eth0" # network interface to send ARP requests on
subnets:
- "192.168.0.0/24" # add more subnets as needed
# - "10.0.0.0/24"

View File

@@ -0,0 +1,22 @@
# Minimale Startkonfiguration — wird nach dem Setup-Wizard überschrieben.
site: "LUD"
log_level: "info"
setup_complete: false
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8080"
token: "" # wird im Setup-Wizard gesetzt
nexus:
url: ""
api_key: ""
modules:
arp_discovery:
enabled: false # wird im Setup-Wizard aktiviert
interval: 5m
interface: "eth0"
subnets: []

View File

@@ -0,0 +1,62 @@
site: "BAR"
log_level: "info"
setup_complete: false
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8090"
token: "" # wird beim Setup-Wizard gesetzt
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
offline_after: 15m
alert:
nexus_enabled: true
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
from: ""
to: ""
modules:
arp_discovery:
enabled: true
interval: 5m
subnets:
- "192.168.10.0/24" # Barleben-Netz anpassen!
interface: "eth0"
ad_sync:
enabled: false # DC-Daten für BAR eintragen und auf true setzen
interval: 30m
server: ""
port: 389
bind_dn: "svc-scanner@winkel.local"
bind_password: ""
search_base: "DC=winkel,DC=local"
tls: false
site_monitoring:
enabled: true
interval: 1m
port_scan:
enabled: true
interval: 15m
snmp:
enabled: true
interval: 5m
community: "public"
nexus_reporter:
enabled: true
interval: 10m

View File

@@ -0,0 +1,62 @@
site: "LUD"
log_level: "info"
setup_complete: false
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8090"
token: "" # wird beim Setup-Wizard gesetzt
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
offline_after: 15m
alert:
nexus_enabled: true
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
from: ""
to: ""
modules:
arp_discovery:
enabled: true
interval: 5m
subnets:
- "192.168.0.0/24"
interface: "eth0"
ad_sync:
enabled: true
interval: 30m
server: "192.168.0.12"
port: 389
bind_dn: "svc-scanner@winkel.local"
bind_password: "6ApvVf6X9hvHBxYUqQ3y"
search_base: "DC=winkel,DC=local"
tls: false
site_monitoring:
enabled: true
interval: 1m
port_scan:
enabled: true
interval: 15m
snmp:
enabled: true
interval: 5m
community: "public"
nexus_reporter:
enabled: true
interval: 10m

View File

@@ -0,0 +1,133 @@
#!/bin/bash
# Nexus Scanner — Installations-Skript
# Läuft auf Debian 12 / Ubuntu 22.04+
# Aufruf: sudo bash install.sh [LUD|BAR]
set -e
SITE="${1:-LUD}"
BINARY_URL="https://it-nexus.cereda-systems.de/downloads/nexus-scanner-linux-amd64"
BINARY="/usr/local/bin/nexus-scanner"
CONFIG_DIR="/etc/nexus-scanner"
DATA_DIR="/var/lib/nexus-scanner"
SERVICE_FILE="/etc/systemd/system/nexus-scanner.service"
USER="nexus-scanner"
echo "=============================="
echo " Nexus Scanner Installation"
echo " Standort: $SITE"
echo "=============================="
# Abhängigkeiten
apt-get update -qq
apt-get install -y -qq curl iputils-ping nmap snmp 2>/dev/null || true
# Benutzer anlegen
if ! id "$USER" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
echo "Benutzer $USER angelegt."
fi
# Verzeichnisse
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
chown "$USER:$USER" "$DATA_DIR"
# Binary installieren (aus deploy/-Verzeichnis oder per Download)
if [ -f "./nexus-scanner-linux-amd64" ]; then
cp ./nexus-scanner-linux-amd64 "$BINARY"
echo "Binary aus lokalem Verzeichnis installiert."
else
echo "Binary wird heruntergeladen..."
curl -fsSL "$BINARY_URL" -o "$BINARY"
fi
chmod 755 "$BINARY"
# Config installieren
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
if [ -f "./config.${SITE,,}.yaml" ]; then
cp "./config.${SITE,,}.yaml" "$CONFIG_DIR/config.yaml"
else
# Minimal-Config erzeugen
cat > "$CONFIG_DIR/config.yaml" << EOF
site: "$SITE"
log_level: "info"
setup_complete: false
db:
path: "$DATA_DIR/scanner.db"
web:
addr: ":8090"
token: ""
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
offline_after: 15m
modules:
arp_discovery:
enabled: true
interval: 5m
subnets: ["192.168.0.0/24"]
interface: "eth0"
site_monitoring:
enabled: true
interval: 1m
port_scan:
enabled: true
interval: 15m
snmp:
enabled: true
interval: 5m
community: "public"
nexus_reporter:
enabled: true
interval: 10m
EOF
fi
echo "Config nach $CONFIG_DIR/config.yaml installiert."
fi
chown "$USER":"$USER" "$CONFIG_DIR/config.yaml"
chmod 640 "$CONFIG_DIR/config.yaml"
# systemd Service installieren
cat > "$SERVICE_FILE" << 'EOF'
[Unit]
Description=Nexus Scanner — Netzwerk-Discovery Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nexus-scanner
Group=nexus-scanner
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nexus-scanner
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/nexus-scanner /etc/nexus-scanner
PrivateTmp=yes
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable nexus-scanner
systemctl restart nexus-scanner
IP=$(hostname -I | awk '{print $1}')
echo ""
echo "=============================="
echo " Installation abgeschlossen!"
echo "=============================="
echo ""
echo " Web-UI: http://$IP:8090"
echo " Status: systemctl status nexus-scanner"
echo " Logs: journalctl -u nexus-scanner -f"
echo ""
echo " Öffne http://$IP:8090 im Browser"
echo " um den Setup-Wizard abzuschließen."
echo ""

View File

@@ -0,0 +1,27 @@
[Unit]
Description=Nexus Scanner — Netzwerk-Discovery Agent
Documentation=https://it-nexus.cereda-systems.de
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nexus-scanner
Group=nexus-scanner
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nexus-scanner
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/nexus-scanner
PrivateTmp=yes
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target

36
nexus-scanner/go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/cereda-systems/nexus-scanner
go 1.24.0
require (
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.10
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gosnmp/gosnmp v1.43.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect
github.com/mdlayher/packet v1.0.0 // indirect
github.com/mdlayher/socket v0.2.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

92
nexus-scanner/go.sum Normal file
View File

@@ -0,0 +1,92 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw=
github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 h1:ql8x//rJsHMjS+qqEag8n3i4azw1QneKh5PieH9UEbY=
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc=
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE=
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og=
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,195 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config holds the full agent configuration loaded from config.yaml.
type Config struct {
Site string `yaml:"site"` // LUD or BAR
LogLevel string `yaml:"log_level"` // debug | info | warn | error
SetupComplete bool `yaml:"setup_complete"` // false → show setup wizard on first run
DB struct {
Path string `yaml:"path"`
} `yaml:"db"`
Web struct {
Addr string `yaml:"addr"`
Token string `yaml:"token"`
} `yaml:"web"`
Nexus struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
} `yaml:"nexus"`
OfflineAfter time.Duration `yaml:"offline_after"` // mark hosts offline after this duration
Alert AlertConfig `yaml:"alert"`
Modules struct {
ARPDiscovery ARPConfig `yaml:"arp_discovery"`
ADSync ADConfig `yaml:"ad_sync"`
SiteMonitoring SiteMonConfig `yaml:"site_monitoring"`
PortScan PortScanConfig `yaml:"port_scan"`
SNMP SNMPConfig `yaml:"snmp"`
NexusReporter ReporterConfig `yaml:"nexus_reporter"`
} `yaml:"modules"`
}
// ADConfig holds settings for the ad_sync module.
type ADConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Server string `yaml:"server"` // DC hostname or IP
Port int `yaml:"port"` // default 389
BindDN string `yaml:"bind_dn"` // svc-scanner@winkel.local
BindPassword string `yaml:"bind_password"`
SearchBase string `yaml:"search_base"` // DC=winkel,DC=local
TLS bool `yaml:"tls"` // use LDAPS
}
// SiteMonConfig holds settings for the site_monitoring module.
type SiteMonConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
}
// PortScanConfig holds settings for the port_scan module.
type PortScanConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Ports []int `yaml:"ports"` // empty = use defaults
Timeout time.Duration `yaml:"timeout"` // per-port connect timeout
}
// SNMPConfig holds settings for the snmp module.
type SNMPConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Community string `yaml:"community"` // default community string
}
// ReporterConfig holds settings for the nexus_reporter module.
type ReporterConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
}
// AlertConfig holds notification settings.
type AlertConfig struct {
SMTP struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
From string `yaml:"from"`
To string `yaml:"to"`
} `yaml:"smtp"`
NexusEnabled bool `yaml:"nexus_enabled"`
}
// ARPConfig holds settings for the arp_discovery module.
type ARPConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Subnets []string `yaml:"subnets"`
Interface string `yaml:"interface"`
}
// Load reads and validates the YAML config at path.
func Load(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %q: %w", path, err)
}
defer f.Close()
var cfg Config
if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if err := cfg.applyDefaults(); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return &cfg, nil
}
// Save writes the config back to path in YAML format.
func Save(path string, cfg *Config) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create config %q: %w", path, err)
}
defer f.Close()
if err := yaml.NewEncoder(f).Encode(cfg); err != nil {
return fmt.Errorf("encode config: %w", err)
}
return nil
}
func (c *Config) applyDefaults() error {
if c.Site == "" {
return fmt.Errorf("site is required (LUD or BAR)")
}
if c.Site != "LUD" && c.Site != "BAR" {
return fmt.Errorf("site must be LUD or BAR, got %q", c.Site)
}
if c.LogLevel == "" {
c.LogLevel = "info"
}
if c.DB.Path == "" {
c.DB.Path = "/var/lib/nexus-scanner/scanner.db"
}
if c.Web.Addr == "" {
c.Web.Addr = ":8080"
}
if c.Modules.ARPDiscovery.Interval == 0 {
c.Modules.ARPDiscovery.Interval = 5 * time.Minute
}
if c.Modules.ARPDiscovery.Interface == "" {
c.Modules.ARPDiscovery.Interface = "eth0"
}
if c.Modules.ADSync.Port == 0 {
c.Modules.ADSync.Port = 389
}
if c.Modules.ADSync.Interval == 0 {
c.Modules.ADSync.Interval = 30 * time.Minute
}
if c.Modules.ADSync.SearchBase == "" && c.Modules.ADSync.Server != "" {
c.Modules.ADSync.SearchBase = "DC=winkel,DC=local"
}
if c.Modules.SiteMonitoring.Interval == 0 {
c.Modules.SiteMonitoring.Interval = time.Minute
}
if c.Modules.PortScan.Interval == 0 {
c.Modules.PortScan.Interval = 15 * time.Minute
}
if c.Modules.PortScan.Timeout == 0 {
c.Modules.PortScan.Timeout = 500 * time.Millisecond
}
if c.Modules.SNMP.Interval == 0 {
c.Modules.SNMP.Interval = 5 * time.Minute
}
if c.Modules.SNMP.Community == "" {
c.Modules.SNMP.Community = "public"
}
if c.Modules.NexusReporter.Interval == 0 {
c.Modules.NexusReporter.Interval = 10 * time.Minute
}
if c.OfflineAfter == 0 {
c.OfflineAfter = 15 * time.Minute
}
if c.Alert.SMTP.Port == 0 {
c.Alert.SMTP.Port = 587
}
return nil
}

View File

@@ -0,0 +1,659 @@
package db
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Store wraps the SQLite database.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the SQLite database at path and runs migrations.
func Open(path string) (*Store, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite at %q: %w", path, err)
}
// SQLite works best with a single writer connection.
conn.SetMaxOpenConns(1)
// Recommended pragmas for reliability and performance.
for _, pragma := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA busy_timeout=5000",
"PRAGMA foreign_keys=ON",
} {
if _, err := conn.Exec(pragma); err != nil {
conn.Close()
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
}
}
s := &Store{db: conn}
if err := s.migrate(); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the underlying database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS host_ports (
ip TEXT NOT NULL,
port INTEGER NOT NULL,
scanned_at DATETIME NOT NULL,
PRIMARY KEY (ip, port)
);
CREATE TABLE IF NOT EXISTS snmp_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
ip TEXT NOT NULL,
community TEXT NOT NULL DEFAULT 'public',
version TEXT NOT NULL DEFAULT 'v2c',
type TEXT NOT NULL DEFAULT 'generic',
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snmp_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
oid_name TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_id, oid_name)
);
CREATE TABLE IF NOT EXISTS module_states (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS monitor_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS monitor_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
check_id INTEGER NOT NULL,
status TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_monitor_results_check
ON monitor_results(check_id, checked_at DESC);
CREATE TABLE IF NOT EXISTS ad_computers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cn TEXT NOT NULL UNIQUE,
dn TEXT NOT NULL DEFAULT '',
os TEXT NOT NULL DEFAULT '',
os_version TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '',
last_logon DATETIME,
ad_created DATETIME,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
mac TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
site TEXT NOT NULL,
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'online'
);
CREATE TABLE IF NOT EXISTS scan_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module TEXT NOT NULL,
started_at DATETIME NOT NULL,
ended_at DATETIME,
status TEXT NOT NULL DEFAULT 'running',
error TEXT NOT NULL DEFAULT ''
);
`)
return err
}
// Host represents a discovered network device.
type Host struct {
ID int64
IP string
MAC string
Vendor string
Hostname string
Site string
FirstSeen time.Time
LastSeen time.Time
Status string
}
// UpsertHost inserts a new host or updates MAC, vendor and last_seen if the
// IP already exists. first_seen is never overwritten on update.
func (s *Store) UpsertHost(h Host) error {
_, err := s.db.Exec(`
INSERT INTO hosts (ip, mac, vendor, hostname, site, first_seen, last_seen, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'online')
ON CONFLICT(ip) DO UPDATE SET
mac = excluded.mac,
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END,
last_seen = excluded.last_seen,
status = 'online'
`, h.IP, h.MAC, h.Vendor, h.Hostname, h.Site, h.FirstSeen.UTC(), h.LastSeen.UTC())
if err != nil {
return fmt.Errorf("upsert host %s: %w", h.IP, err)
}
return nil
}
// ListHosts returns all hosts ordered by last_seen desc.
// Pass an empty site to return all sites.
func (s *Store) ListHosts(site string) ([]Host, error) {
query := `
SELECT id, ip, mac, vendor, hostname, site, first_seen, last_seen, status
FROM hosts`
var args []any
if site != "" {
query += ` WHERE site = ?`
args = append(args, site)
}
query += ` ORDER BY last_seen DESC`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("list hosts: %w", err)
}
defer rows.Close()
var hosts []Host
for rows.Next() {
var h Host
if err := rows.Scan(
&h.ID, &h.IP, &h.MAC, &h.Vendor, &h.Hostname,
&h.Site, &h.FirstSeen, &h.LastSeen, &h.Status,
); err != nil {
return nil, fmt.Errorf("scan host row: %w", err)
}
hosts = append(hosts, h)
}
return hosts, rows.Err()
}
// CountHosts returns the total number of hosts in the database.
func (s *Store) CountHosts() (int, error) {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM hosts`).Scan(&n); err != nil {
return 0, fmt.Errorf("count hosts: %w", err)
}
return n, nil
}
// BeginScan records the start of a module scan run and returns the run ID.
func (s *Store) BeginScan(module string) (int64, error) {
res, err := s.db.Exec(`
INSERT INTO scan_runs (module, started_at) VALUES (?, ?)
`, module, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("begin scan: %w", err)
}
return res.LastInsertId()
}
// EndScan marks a scan run as completed (status ok or error).
func (s *Store) EndScan(id int64, scanErr error) error {
status, errMsg := "ok", ""
if scanErr != nil {
status, errMsg = "error", scanErr.Error()
}
_, err := s.db.Exec(`
UPDATE scan_runs SET ended_at = ?, status = ?, error = ? WHERE id = ?
`, time.Now().UTC(), status, errMsg, id)
if err != nil {
return fmt.Errorf("end scan %d: %w", id, err)
}
return nil
}
// ADComputer represents a computer object from Active Directory.
type ADComputer struct {
ID int64
CN string
DN string
OS string
OSVersion string
Owner string
Department string
LastLogon time.Time
ADCreated time.Time
UpdatedAt time.Time
}
// UpsertADComputer inserts or updates an AD computer record by CN.
func (s *Store) UpsertADComputer(c ADComputer) error {
_, err := s.db.Exec(`
INSERT INTO ad_computers (cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(cn) DO UPDATE SET
dn = excluded.dn,
os = excluded.os,
os_version = excluded.os_version,
owner = excluded.owner,
department = excluded.department,
last_logon = excluded.last_logon,
updated_at = excluded.updated_at
`, c.CN, c.DN, c.OS, c.OSVersion, c.Owner, c.Department,
nullTime(c.LastLogon), nullTime(c.ADCreated), time.Now().UTC())
if err != nil {
return fmt.Errorf("upsert ad computer %s: %w", c.CN, err)
}
return nil
}
// ListADComputers returns all known AD computers.
func (s *Store) ListADComputers() ([]ADComputer, error) {
rows, err := s.db.Query(`
SELECT id, cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at
FROM ad_computers ORDER BY cn
`)
if err != nil {
return nil, fmt.Errorf("list ad computers: %w", err)
}
defer rows.Close()
var out []ADComputer
for rows.Next() {
var c ADComputer
var lastLogon, adCreated *time.Time
if err := rows.Scan(&c.ID, &c.CN, &c.DN, &c.OS, &c.OSVersion,
&c.Owner, &c.Department, &lastLogon, &adCreated, &c.UpdatedAt); err != nil {
return nil, err
}
if lastLogon != nil {
c.LastLogon = *lastLogon
}
if adCreated != nil {
c.ADCreated = *adCreated
}
out = append(out, c)
}
return out, rows.Err()
}
// UpdateHostnameFromAD sets the hostname of a host matching the given IP.
func (s *Store) UpdateHostnameFromAD(hostname string) error {
_, err := s.db.Exec(`
UPDATE hosts SET hostname = ? WHERE hostname = '' AND ip IN (
SELECT ip FROM hosts WHERE LOWER(hostname) = LOWER(?)
)
`, hostname, hostname)
return err
}
func nullTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.UTC()
}
// LastScanTime returns when the given module last ran successfully.
func (s *Store) LastScanTime(module string) (time.Time, error) {
var t time.Time
err := s.db.QueryRow(`
SELECT ended_at FROM scan_runs
WHERE module = ? AND status = 'ok'
ORDER BY ended_at DESC LIMIT 1
`, module).Scan(&t)
if err == sql.ErrNoRows {
return time.Time{}, nil
}
return t, err
}
/* ── Site Monitoring ──────────────────────────────────────────────── */
// MonitorCheck is a configured availability check (ping/http/tcp).
type MonitorCheck struct {
ID int64
Name string
Type string // ping | http | tcp
Target string
Enabled bool
CreatedAt time.Time
}
// MonitorResult is a single check execution result.
type MonitorResult struct {
ID int64
CheckID int64
Status string // online | offline
LatencyMS int
Error string
CheckedAt time.Time
}
// ListMonitorChecks returns all configured checks ordered by creation time.
func (s *Store) ListMonitorChecks() ([]MonitorCheck, error) {
rows, err := s.db.Query(`
SELECT id, name, type, target, enabled, created_at
FROM monitor_checks ORDER BY id
`)
if err != nil {
return nil, fmt.Errorf("list monitor checks: %w", err)
}
defer rows.Close()
var out []MonitorCheck
for rows.Next() {
var c MonitorCheck
var ena int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Target, &ena, &c.CreatedAt); err != nil {
return nil, err
}
c.Enabled = ena == 1
out = append(out, c)
}
return out, rows.Err()
}
// AddMonitorCheck inserts a new check definition.
func (s *Store) AddMonitorCheck(name, typ, target string) (int64, error) {
res, err := s.db.Exec(`
INSERT INTO monitor_checks (name, type, target) VALUES (?, ?, ?)
`, name, typ, target)
if err != nil {
return 0, fmt.Errorf("add monitor check: %w", err)
}
return res.LastInsertId()
}
// DeleteMonitorCheck removes a check and all its results.
func (s *Store) DeleteMonitorCheck(id int64) error {
if _, err := s.db.Exec(`DELETE FROM monitor_results WHERE check_id = ?`, id); err != nil {
return fmt.Errorf("delete monitor results: %w", err)
}
if _, err := s.db.Exec(`DELETE FROM monitor_checks WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete monitor check: %w", err)
}
return nil
}
// InsertMonitorResult stores one check execution result.
func (s *Store) InsertMonitorResult(r MonitorResult) error {
_, err := s.db.Exec(`
INSERT INTO monitor_results (check_id, status, latency_ms, error, checked_at)
VALUES (?, ?, ?, ?, ?)
`, r.CheckID, r.Status, r.LatencyMS, r.Error, r.CheckedAt.UTC())
if err != nil {
return fmt.Errorf("insert monitor result: %w", err)
}
return nil
}
// LatestMonitorResults returns the most recent result for each check_id.
func (s *Store) LatestMonitorResults() (map[int64]MonitorResult, error) {
rows, err := s.db.Query(`
SELECT r.id, r.check_id, r.status, r.latency_ms, r.error, r.checked_at
FROM monitor_results r
INNER JOIN (
SELECT check_id, MAX(id) as max_id FROM monitor_results GROUP BY check_id
) latest ON r.id = latest.max_id
`)
if err != nil {
return nil, fmt.Errorf("latest monitor results: %w", err)
}
defer rows.Close()
out := make(map[int64]MonitorResult)
for rows.Next() {
var r MonitorResult
if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil {
return nil, err
}
out[r.CheckID] = r
}
return out, rows.Err()
}
// MonitorResultHistory returns the last limit results for a given check.
func (s *Store) MonitorResultHistory(checkID int64, limit int) ([]MonitorResult, error) {
rows, err := s.db.Query(`
SELECT id, check_id, status, latency_ms, error, checked_at
FROM monitor_results WHERE check_id = ?
ORDER BY checked_at DESC LIMIT ?
`, checkID, limit)
if err != nil {
return nil, fmt.Errorf("monitor history: %w", err)
}
defer rows.Close()
var out []MonitorResult
for rows.Next() {
var r MonitorResult
if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil {
return nil, err
}
out = append(out, r)
}
// Reverse so oldest-first for sparkline rendering.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
/* ── Module States ────────────────────────────────────────────────── */
// SetModuleEnabled stores the enabled state for a module.
func (s *Store) SetModuleEnabled(name string, enabled bool) error {
v := 0
if enabled {
v = 1
}
_, err := s.db.Exec(`
INSERT INTO module_states (name, enabled) VALUES (?,?)
ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled
`, name, v)
return err
}
// GetModuleEnabled returns whether a module is enabled (default true if not set).
func (s *Store) GetModuleEnabled(name string) bool {
var v int
err := s.db.QueryRow(`SELECT enabled FROM module_states WHERE name=?`, name).Scan(&v)
if err != nil {
return true // default: enabled
}
return v == 1
}
/* ── Port Scan ────────────────────────────────────────────────────── */
// UpsertHostPorts replaces all open ports for an IP.
func (s *Store) UpsertHostPorts(ip string, ports []int) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM host_ports WHERE ip = ?`, ip); err != nil {
return err
}
now := time.Now().UTC()
for _, p := range ports {
if _, err := tx.Exec(`INSERT INTO host_ports (ip, port, scanned_at) VALUES (?,?,?)`, ip, p, now); err != nil {
return err
}
}
return tx.Commit()
}
// GetHostPorts returns all open ports for an IP.
func (s *Store) GetHostPorts(ip string) ([]int, error) {
rows, err := s.db.Query(`SELECT port FROM host_ports WHERE ip = ? ORDER BY port`, ip)
if err != nil {
return nil, err
}
defer rows.Close()
var ports []int
for rows.Next() {
var p int
rows.Scan(&p)
ports = append(ports, p)
}
return ports, rows.Err()
}
// ListAllHostPorts returns a map of IP → open ports.
func (s *Store) ListAllHostPorts() (map[string][]int, error) {
rows, err := s.db.Query(`SELECT ip, port FROM host_ports ORDER BY ip, port`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]int)
for rows.Next() {
var ip string
var port int
rows.Scan(&ip, &port)
out[ip] = append(out[ip], port)
}
return out, rows.Err()
}
// MarkStaleHostsOffline marks hosts as offline if last_seen is older than threshold.
func (s *Store) MarkStaleHostsOffline(olderThan time.Duration) (int, error) {
threshold := time.Now().UTC().Add(-olderThan)
res, err := s.db.Exec(`UPDATE hosts SET status='offline' WHERE status='online' AND last_seen < ?`, threshold)
if err != nil {
return 0, fmt.Errorf("mark stale offline: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}
/* ── SNMP ─────────────────────────────────────────────────────────── */
// SNMPTarget is a configured SNMP polling target.
type SNMPTarget struct {
ID int64
Name string
IP string
Community string
Version string
Type string // generic | printer | switch
Enabled bool
CreatedAt time.Time
}
// SNMPResult is the latest value for one OID on one target.
type SNMPResult struct {
ID int64
TargetID int64
OIDName string
Value string
ScannedAt time.Time
}
// ListSNMPTargets returns all SNMP targets.
func (s *Store) ListSNMPTargets() ([]SNMPTarget, error) {
rows, err := s.db.Query(`SELECT id,name,ip,community,version,type,enabled,created_at FROM snmp_targets ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SNMPTarget
for rows.Next() {
var t SNMPTarget
var ena int
rows.Scan(&t.ID, &t.Name, &t.IP, &t.Community, &t.Version, &t.Type, &ena, &t.CreatedAt)
t.Enabled = ena == 1
out = append(out, t)
}
return out, rows.Err()
}
// AddSNMPTarget inserts a new SNMP target.
func (s *Store) AddSNMPTarget(name, ip, community, version, typ string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO snmp_targets (name,ip,community,version,type) VALUES (?,?,?,?,?)`,
name, ip, community, version, typ)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// DeleteSNMPTarget removes a target and its results.
func (s *Store) DeleteSNMPTarget(id int64) error {
s.db.Exec(`DELETE FROM snmp_results WHERE target_id = ?`, id)
_, err := s.db.Exec(`DELETE FROM snmp_targets WHERE id = ?`, id)
return err
}
// UpsertSNMPResult inserts or updates one OID result.
func (s *Store) UpsertSNMPResult(r SNMPResult) error {
_, err := s.db.Exec(`
INSERT INTO snmp_results (target_id, oid_name, value, scanned_at)
VALUES (?,?,?,?)
ON CONFLICT(target_id, oid_name) DO UPDATE SET value=excluded.value, scanned_at=excluded.scanned_at
`, r.TargetID, r.OIDName, r.Value, r.ScannedAt.UTC())
return err
}
// LatestSNMPResults returns all latest OID values keyed by target_id.
func (s *Store) LatestSNMPResults() (map[int64]map[string]string, error) {
rows, err := s.db.Query(`SELECT target_id, oid_name, value FROM snmp_results ORDER BY target_id, oid_name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]map[string]string)
for rows.Next() {
var tid int64
var k, v string
rows.Scan(&tid, &k, &v)
if out[tid] == nil {
out[tid] = make(map[string]string)
}
out[tid][k] = v
}
return out, rows.Err()
}
// CleanupMonitorResults deletes old results, keeping the last keepPerCheck per check.
func (s *Store) CleanupMonitorResults(keepPerCheck int) error {
_, err := s.db.Exec(`
DELETE FROM monitor_results WHERE id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY check_id ORDER BY checked_at DESC
) AS rn FROM monitor_results
) WHERE rn <= ?
)
`, keepPerCheck)
return err
}

View File

@@ -0,0 +1,50 @@
package module
import (
"context"
"fmt"
"time"
)
// Module is the interface every scanner module must implement.
type Module interface {
// Name returns the unique identifier for this module (e.g. "arp_discovery").
Name() string
// Interval returns how often the scheduler should run this module.
// A zero duration means the module runs once on startup only.
Interval() time.Duration
// Run executes one scan cycle. It must respect ctx cancellation.
Run(ctx context.Context) error
}
// Registry holds all registered modules.
type Registry struct {
modules []Module
}
// NewRegistry returns an empty module registry.
func NewRegistry() *Registry {
return &Registry{}
}
// Register adds a module to the registry.
func (r *Registry) Register(m Module) {
r.modules = append(r.modules, m)
}
// All returns all registered modules.
func (r *Registry) All() []Module {
return r.modules
}
// Get returns the module with the given name.
func (r *Registry) Get(name string) (Module, error) {
for _, m := range r.modules {
if m.Name() == name {
return m, nil
}
}
return nil, fmt.Errorf("module %q not registered", name)
}

View File

@@ -0,0 +1,182 @@
// Package adsync queries Active Directory for computer objects and stores
// them in the local database. It enriches existing hosts with their AD
// computer name and department, and records computers that are in AD but
// not yet seen on the network (useful for finding offline/missing devices).
package adsync
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module queries Active Directory for all computer objects.
type Module struct {
site string
cfg config.ADConfig
store *db.Store
log *slog.Logger
}
// New returns a new AD sync module.
func New(site string, cfg config.ADConfig, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
store: store,
log: slog.With("module", "ad_sync"),
}
}
func (m *Module) Name() string { return "ad_sync" }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes one AD sync cycle.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.sync(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) sync(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", m.cfg.Server, m.cfg.Port)
var conn *ldap.Conn
var err error
if m.cfg.TLS {
conn, err = ldap.DialURL("ldaps://" + addr)
} else {
conn, err = ldap.DialURL("ldap://" + addr)
}
if err != nil {
return fmt.Errorf("ldap dial %s: %w", addr, err)
}
defer conn.Close()
if err := conn.Bind(m.cfg.BindDN, m.cfg.BindPassword); err != nil {
return fmt.Errorf("ldap bind as %s: %w", m.cfg.BindDN, err)
}
computers, err := m.searchComputers(conn)
if err != nil {
return fmt.Errorf("ldap search: %w", err)
}
m.log.Info("computers found in AD", "count", len(computers))
saved := 0
for _, c := range computers {
if err := m.store.UpsertADComputer(c); err != nil {
m.log.Error("upsert ad computer", "cn", c.CN, "err", err)
continue
}
saved++
m.log.Debug("ad computer synced", "cn", c.CN, "os", c.OS, "dept", c.Department)
}
m.log.Info("ad sync complete", "synced", saved, "total", len(computers))
return nil
}
func (m *Module) searchComputers(conn *ldap.Conn) ([]db.ADComputer, error) {
req := ldap.NewSearchRequest(
m.cfg.SearchBase,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
"(&(objectClass=computer)(objectCategory=Computer))",
[]string{
"cn",
"distinguishedName",
"operatingSystem",
"operatingSystemVersion",
"lastLogonTimestamp",
"whenCreated",
"description",
"managedBy",
},
nil,
)
result, err := conn.SearchWithPaging(req, 500)
if err != nil {
return nil, fmt.Errorf("ldap search computers: %w", err)
}
var computers []db.ADComputer
for _, entry := range result.Entries {
c := db.ADComputer{
CN: entry.GetAttributeValue("cn"),
DN: entry.GetAttributeValue("distinguishedName"),
OS: entry.GetAttributeValue("operatingSystem"),
OSVersion: entry.GetAttributeValue("operatingSystemVersion"),
Owner: entry.GetAttributeValue("description"),
Department: ouFromDN(entry.GetAttributeValue("distinguishedName")),
}
if raw := entry.GetAttributeValue("lastLogonTimestamp"); raw != "" {
c.LastLogon = windowsTimeToGoTime(raw)
}
if raw := entry.GetAttributeValue("whenCreated"); raw != "" {
c.ADCreated = parseLDAPGeneralizedTime(raw)
}
computers = append(computers, c)
}
return computers, nil
}
// ouFromDN extracts the first OU component from a distinguished name.
// "CN=IT-NB-02,OU=Notebooks,OU=IT,DC=winkel,DC=local" → "Notebooks"
func ouFromDN(dn string) string {
for _, part := range strings.Split(dn, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToUpper(part), "OU=") {
return part[3:]
}
}
return ""
}
// windowsTimeToGoTime converts a Windows LDAP timestamp string (100-ns intervals
// since 1601-01-01) to a Go time.Time.
func windowsTimeToGoTime(raw string) time.Time {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n == 0 {
return time.Time{}
}
// Offset between Windows epoch (1601-01-01) and Unix epoch (1970-01-01)
// in 100-nanosecond intervals.
const windowsToUnix int64 = 116444736000000000
unixNano := (n - windowsToUnix) * 100
return time.Unix(0, unixNano).UTC()
}
// parseLDAPGeneralizedTime parses LDAP generalizedTime format: "20240101120000.0Z"
func parseLDAPGeneralizedTime(raw string) time.Time {
// Trim trailing sub-second and timezone info for simple parsing.
raw = strings.TrimSuffix(raw, "Z")
if idx := strings.Index(raw, "."); idx != -1 {
raw = raw[:idx]
}
t, err := time.Parse("20060102150405", raw)
if err != nil {
return time.Time{}
}
return t.UTC()
}

View File

@@ -0,0 +1,167 @@
// Package arp implements the arp_discovery scanner module.
// It sends ARP requests across configured subnets and records
// responding hosts (IP + MAC) in the shared SQLite store.
package arp
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"time"
"github.com/mdlayher/arp"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module performs ARP discovery on configured subnets.
type Module struct {
site string
cfg config.ARPConfig
store *db.Store
log *slog.Logger
}
// New returns a new ARP discovery module.
func New(site string, cfg config.ARPConfig, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
store: store,
log: slog.With("module", "arp_discovery"),
}
}
func (m *Module) Name() string { return "arp_discovery" }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes one full ARP scan cycle across all configured subnets.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan record: %w", err)
}
scanErr := m.scanAllSubnets(ctx)
if err := m.store.EndScan(scanID, scanErr); err != nil {
m.log.Error("failed to record scan end", "err", err)
}
return scanErr
}
func (m *Module) scanAllSubnets(ctx context.Context) error {
iface, err := net.InterfaceByName(m.cfg.Interface)
if err != nil {
return fmt.Errorf("interface %q: %w", m.cfg.Interface, err)
}
client, err := arp.Dial(iface)
if err != nil {
return fmt.Errorf("arp dial on %s: %w", m.cfg.Interface, err)
}
defer client.Close()
for _, subnet := range m.cfg.Subnets {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := m.scanSubnet(ctx, client, subnet); err != nil {
// Log and continue — one failed subnet should not abort others.
m.log.Error("subnet scan failed", "subnet", subnet, "err", err)
}
}
return nil
}
func (m *Module) scanSubnet(ctx context.Context, client *arp.Client, cidr string) error {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
return fmt.Errorf("parse prefix %q: %w", cidr, err)
}
prefix = prefix.Masked()
targets := hostsInPrefix(prefix)
m.log.Info("scanning subnet", "subnet", cidr, "targets", len(targets))
// Send all ARP requests first, then collect responses.
// Set a deadline so Read() returns after silence.
if err := client.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return fmt.Errorf("set deadline: %w", err)
}
for _, ip := range targets {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := client.Request(ip); err != nil {
m.log.Debug("arp request failed", "ip", ip, "err", err)
}
// Small delay to avoid flooding the network.
time.Sleep(2 * time.Millisecond)
}
// Collect all responses until the deadline fires.
found := 0
for {
pkt, _, err := client.Read()
if err != nil {
// Deadline reached — done reading.
break
}
now := time.Now().UTC()
host := db.Host{
IP: pkt.SenderIP.String(),
MAC: pkt.SenderHardwareAddr.String(),
Site: m.site,
FirstSeen: now,
LastSeen: now,
}
if err := m.store.UpsertHost(host); err != nil {
m.log.Error("upsert host", "ip", host.IP, "err", err)
continue
}
m.log.Info("host discovered", "ip", host.IP, "mac", host.MAC)
found++
}
m.log.Info("subnet scan done", "subnet", cidr, "found", found)
return nil
}
// hostsInPrefix returns all usable host addresses in a prefix,
// excluding the network address and the broadcast address.
//
// Example: 192.168.0.0/24 → 192.168.0.1 … 192.168.0.254
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
var addrs []netip.Addr
// Start at network address + 1 (skip network address itself).
addr := prefix.Masked().Addr().Next()
for prefix.Contains(addr) {
next := addr.Next()
// Stop before the broadcast address (last address in prefix).
if !prefix.Contains(next) {
break
}
addrs = append(addrs, addr)
addr = next
}
return addrs
}

View File

@@ -0,0 +1,230 @@
package arp_test
import (
"fmt"
"net/netip"
"os"
"testing"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// helper creates a temp SQLite store and registers cleanup.
func newTestStore(t *testing.T) *db.Store {
t.Helper()
f, err := os.CreateTemp("", "nexus-test-*.db")
if err != nil {
t.Fatalf("create temp db: %v", err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
store, err := db.Open(f.Name())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func TestUpsertAndListHost(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC().Truncate(time.Second)
original := db.Host{
IP: "192.168.0.10",
MAC: "aa:bb:cc:dd:ee:ff",
Vendor: "Acme Corp",
Site: "LUD",
FirstSeen: now,
LastSeen: now,
}
if err := store.UpsertHost(original); err != nil {
t.Fatalf("UpsertHost (insert): %v", err)
}
hosts, err := store.ListHosts("")
if err != nil {
t.Fatalf("ListHosts: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 host, got %d", len(hosts))
}
h := hosts[0]
if h.IP != original.IP {
t.Errorf("IP: got %q, want %q", h.IP, original.IP)
}
if h.MAC != original.MAC {
t.Errorf("MAC: got %q, want %q", h.MAC, original.MAC)
}
if h.Status != "online" {
t.Errorf("Status: got %q, want %q", h.Status, "online")
}
// Update the same IP — last_seen and MAC should change, first_seen should not.
updated := original
updated.MAC = "11:22:33:44:55:66"
updated.LastSeen = now.Add(time.Minute)
if err := store.UpsertHost(updated); err != nil {
t.Fatalf("UpsertHost (update): %v", err)
}
hosts, err = store.ListHosts("")
if err != nil {
t.Fatalf("ListHosts after update: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 host after upsert, got %d", len(hosts))
}
if hosts[0].MAC != "11:22:33:44:55:66" {
t.Errorf("MAC after update: got %q, want %q", hosts[0].MAC, "11:22:33:44:55:66")
}
}
func TestListHostsFilterBySite(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
hosts := []db.Host{
{IP: "10.0.0.1", MAC: "aa:aa:aa:aa:aa:01", Site: "LUD", FirstSeen: now, LastSeen: now},
{IP: "10.0.0.2", MAC: "aa:aa:aa:aa:aa:02", Site: "LUD", FirstSeen: now, LastSeen: now},
{IP: "10.0.1.1", MAC: "aa:aa:aa:aa:bb:01", Site: "BAR", FirstSeen: now, LastSeen: now},
}
for _, h := range hosts {
if err := store.UpsertHost(h); err != nil {
t.Fatalf("UpsertHost %s: %v", h.IP, err)
}
}
lud, err := store.ListHosts("LUD")
if err != nil {
t.Fatal(err)
}
if len(lud) != 2 {
t.Errorf("LUD: expected 2, got %d", len(lud))
}
bar, err := store.ListHosts("BAR")
if err != nil {
t.Fatal(err)
}
if len(bar) != 1 {
t.Errorf("BAR: expected 1, got %d", len(bar))
}
all, err := store.ListHosts("")
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Errorf("all: expected 3, got %d", len(all))
}
}
func TestCountHosts(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
n, err := store.CountHosts()
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("expected 0 initially, got %d", n)
}
for i := range 5 {
err := store.UpsertHost(db.Host{
IP: fmt.Sprintf("10.0.0.%d", i+1),
MAC: fmt.Sprintf("aa:bb:cc:dd:ee:%02x", i),
Site: "LUD",
FirstSeen: now,
LastSeen: now,
})
if err != nil {
t.Fatalf("UpsertHost %d: %v", i, err)
}
}
n, err = store.CountHosts()
if err != nil {
t.Fatal(err)
}
if n != 5 {
t.Errorf("expected 5, got %d", n)
}
}
func TestScanRunLifecycle(t *testing.T) {
store := newTestStore(t)
id, err := store.BeginScan("arp_discovery")
if err != nil {
t.Fatalf("BeginScan: %v", err)
}
if id == 0 {
t.Error("expected non-zero scan ID")
}
if err := store.EndScan(id, nil); err != nil {
t.Fatalf("EndScan (ok): %v", err)
}
last, err := store.LastScanTime("arp_discovery")
if err != nil {
t.Fatal(err)
}
if last.IsZero() {
t.Error("expected non-zero last scan time after successful run")
}
}
// TestHostsInPrefix validates the subnet host enumeration logic.
// This test lives here because hostsInPrefix is package-internal;
// in a real scenario you would export it for testing or white-box test it.
func TestHostsInPrefixCount(t *testing.T) {
cases := []struct {
cidr string
count int
}{
{"192.168.0.0/24", 254}, // .1 .254
{"10.0.0.0/30", 2}, // .1 and .2 only
{"10.0.0.0/29", 6}, // .1 .6
}
for _, tc := range cases {
t.Run(tc.cidr, func(t *testing.T) {
prefix, err := netip.ParsePrefix(tc.cidr)
if err != nil {
t.Fatal(err)
}
got := hostsInPrefix(prefix.Masked())
if len(got) != tc.count {
t.Errorf("cidr %s: got %d hosts, want %d", tc.cidr, len(got), tc.count)
}
})
}
}
// hostsInPrefix is a copy of the unexported function for white-box testing.
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
var addrs []netip.Addr
addr := prefix.Masked().Addr().Next()
for prefix.Contains(addr) {
next := addr.Next()
if !prefix.Contains(next) {
break
}
addrs = append(addrs, addr)
addr = next
}
return addrs
}

View File

@@ -0,0 +1,94 @@
// Package dnsreverse implements the dns_reverse module.
// It enriches already-known hosts by performing reverse-DNS lookups
// for every host that has no hostname recorded yet.
package dnsreverse
import (
"context"
"fmt"
"log/slog"
"net"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module enriches hosts in the database with reverse-DNS hostnames.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new dns_reverse module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "dns_reverse"),
}
}
func (m *Module) Name() string { return "dns_reverse" }
func (m *Module) Interval() time.Duration { return 10 * time.Minute }
// Run looks up hostnames for all hosts that have none recorded yet.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.scan(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) scan(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
resolved := 0
for _, h := range hosts {
// Skip hosts that already have a hostname.
if h.Hostname != "" {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
names, err := net.DefaultResolver.LookupAddr(ctx, h.IP)
if err != nil {
// A lookup failure is not fatal — continue with the next host.
m.log.Debug("reverse lookup failed", "ip", h.IP, "err", err)
continue
}
if len(names) == 0 {
continue
}
// LookupAddr returns FQDNs with a trailing dot — strip it.
hostname := strings.TrimSuffix(names[0], ".")
h.Hostname = hostname
if err := m.store.UpsertHost(h); err != nil {
m.log.Error("upsert host", "ip", h.IP, "err", err)
continue
}
m.log.Info("hostname resolved", "ip", h.IP, "hostname", hostname)
resolved++
}
m.log.Info("scan complete", "checked", len(hosts), "resolved", resolved)
return nil
}

View File

@@ -0,0 +1,129 @@
// Package macvendor implements the mac_vendor module.
// It maps the OUI (first 3 bytes) of every known host's MAC address to a
// human-readable vendor name and persists the result in the database.
// The mapping is based on a built-in table; no external files are required.
package macvendor
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// ouiTable maps lowercase OUI prefixes (aa:bb:cc) to vendor names.
// Broadcast / multicast entries are intentionally kept with an empty string
// so they are recognised but not written to the database.
var ouiTable = map[string]string{
"00:0c:29": "VMware",
"00:50:56": "VMware",
"bc:24:11": "Proxmox/Ceph",
"90:1b:0e": "Supermicro",
"90:09:d0": "Synology",
"00:17:c8": "Hewlett-Packard",
"00:be:43": "Ubiquiti",
"e4:43:4b": "Ubiquiti",
"24:6a:0e": "Ubiquiti",
"40:86:cb": "Intel",
"68:c6:ac": "Intel",
"1c:af:4a": "Dell",
"50:81:40": "Dell",
"4c:5f:70": "Lenovo",
"dc:58:bc": "Apple",
"c8:4b:d6": "Kyocera",
"38:d5:7a": "Samsung",
"00:04:a5": "Barco",
"7c:5a:1c": "LANCOM",
"00:0a:b3": "Cisco",
"00:e0:67": "Aten",
"ff:ff:ff": "", // broadcast — leave vendor empty
}
// LookupVendor returns the vendor name for a MAC address.
// The MAC must be in the format aa:bb:cc:dd:ee:ff (colon-separated).
// An empty string is returned when no match is found.
func LookupVendor(mac string) string {
if len(mac) < 8 {
return ""
}
oui := strings.ToLower(mac[:8])
return ouiTable[oui]
}
// Module enriches hosts in the database with OUI-based vendor names.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new mac_vendor module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "mac_vendor"),
}
}
func (m *Module) Name() string { return "mac_vendor" }
// Interval returns 0, which causes the scheduler to run this module once at
// startup and never repeat it automatically.
func (m *Module) Interval() time.Duration { return 0 }
// Run assigns vendor names to all hosts that have none recorded yet.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.scan(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) scan(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
enriched := 0
for _, h := range hosts {
// Skip hosts that already have a vendor assigned.
if h.Vendor != "" {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
vendor := LookupVendor(h.MAC)
if vendor == "" {
// No entry in our table — leave vendor empty.
continue
}
h.Vendor = vendor
if err := m.store.UpsertHost(h); err != nil {
m.log.Error("upsert host", "ip", h.IP, "err", err)
continue
}
m.log.Info("vendor resolved", "ip", h.IP, "mac", h.MAC, "vendor", vendor)
enriched++
}
m.log.Info("scan complete", "checked", len(hosts), "enriched", enriched)
return nil
}

View File

@@ -0,0 +1,54 @@
package macvendor
import "testing"
func TestLookupVendor(t *testing.T) {
tests := []struct {
mac string
want string
}{
// Known OUIs from the built-in table.
{"00:0c:29:1a:2b:3c", "VMware"},
{"00:50:56:ab:cd:ef", "VMware"},
{"bc:24:11:00:00:01", "Proxmox/Ceph"},
{"90:1b:0e:ff:ee:dd", "Supermicro"},
{"90:09:d0:11:22:33", "Synology"},
{"00:17:c8:44:55:66", "Hewlett-Packard"},
{"00:be:43:77:88:99", "Ubiquiti"},
{"e4:43:4b:aa:bb:cc", "Ubiquiti"},
{"24:6a:0e:dd:ee:ff", "Ubiquiti"},
{"40:86:cb:00:11:22", "Intel"},
{"68:c6:ac:33:44:55", "Intel"},
{"1c:af:4a:66:77:88", "Dell"},
{"50:81:40:99:aa:bb", "Dell"},
{"4c:5f:70:cc:dd:ee", "Lenovo"},
{"dc:58:bc:ff:00:11", "Apple"},
{"c8:4b:d6:22:33:44", "Kyocera"},
{"38:d5:7a:55:66:77", "Samsung"},
{"00:04:a5:88:99:aa", "Barco"},
{"7c:5a:1c:bb:cc:dd", "LANCOM"},
{"00:0a:b3:ee:ff:00", "Cisco"},
{"00:e0:67:11:22:33", "Aten"},
// Broadcast — vendor must be empty.
{"ff:ff:ff:ff:ff:ff", ""},
// Unknown OUI — no vendor.
{"de:ad:be:ef:00:01", ""},
// MAC shorter than 8 characters — must not panic.
{"aa:bb", ""},
{"", ""},
// Upper-case input — must be normalised.
{"00:0C:29:1A:2B:3C", "VMware"},
{"BC:24:11:FF:EE:DD", "Proxmox/Ceph"},
}
for _, tc := range tests {
got := LookupVendor(tc.mac)
if got != tc.want {
t.Errorf("LookupVendor(%q) = %q, want %q", tc.mac, got, tc.want)
}
}
}

View File

@@ -0,0 +1,130 @@
// Package nexusreporter pushes newly discovered or changed assets to IT Nexus.
package nexusreporter
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "nexus_reporter"
type Module struct {
site string
cfg config.ReporterConfig
nexus config.Config // full config for URL + APIKey
store *db.Store
client *http.Client
// last hash per IP to detect changes
lastHash map[string][32]byte
}
// New creates a new IT Nexus reporter module.
func New(site string, cfg config.ReporterConfig, nexusCfg config.Config, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
nexus: nexusCfg,
store: store,
client: &http.Client{Timeout: 15 * time.Second},
lastHash: make(map[string][32]byte),
}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
type assetPayload struct {
Site string `json:"site"`
Version string `json:"scanner_version"`
ReportedAt time.Time `json:"reported_at"`
Assets []assetItem `json:"assets"`
}
type assetItem struct {
IP string `json:"ip"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
Vendor string `json:"vendor"`
Status string `json:"status"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
// Run reports changed/new hosts to IT Nexus.
func (m *Module) Run(ctx context.Context) error {
if m.nexus.Nexus.URL == "" || m.nexus.Nexus.APIKey == "" {
slog.Debug("nexus reporter skipped — URL or APIKey not configured")
return nil
}
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
var changed []assetItem
for _, h := range hosts {
hash := hostHash(h)
if prev, seen := m.lastHash[h.IP]; seen && prev == hash {
continue // unchanged
}
m.lastHash[h.IP] = hash
changed = append(changed, assetItem{
IP: h.IP,
MAC: h.MAC,
Hostname: h.Hostname,
Vendor: h.Vendor,
Status: h.Status,
FirstSeen: h.FirstSeen,
LastSeen: h.LastSeen,
})
}
if len(changed) == 0 {
slog.Debug("nexus reporter: no changes")
return nil
}
payload := assetPayload{
Site: m.site,
Version: "1.0",
ReportedAt: time.Now(),
Assets: changed,
}
body, _ := json.Marshal(payload)
url := m.nexus.Nexus.URL + "/api/scanner/assets"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Scanner-Key", m.nexus.Nexus.APIKey)
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("post to nexus: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("nexus returned HTTP %d", resp.StatusCode)
}
slog.Info("nexus reporter: assets reported", "count", len(changed), "url", url)
return nil
}
func hostHash(h db.Host) [32]byte {
s := h.IP + "|" + h.MAC + "|" + h.Hostname + "|" + h.Vendor + "|" + h.Status
return sha256.Sum256([]byte(s))
}

View File

@@ -0,0 +1,106 @@
// Package portscan performs concurrent TCP port scanning on all online hosts.
package portscan
import (
"context"
"fmt"
"log/slog"
"net"
"sort"
"sync"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "port_scan"
// Common ports to scan when none are configured.
var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 3389, 5985, 8080, 8443, 9100}
// Module scans TCP ports on all online hosts.
type Module struct {
site string
cfg config.PortScanConfig
store *db.Store
}
// New creates a new port scan module.
func New(site string, cfg config.PortScanConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run scans all online hosts and stores their open ports.
func (m *Module) Run(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
ports := m.cfg.Ports
if len(ports) == 0 {
ports = defaultPorts
}
timeout := m.cfg.Timeout
if timeout == 0 {
timeout = 500 * time.Millisecond
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
found := 0
for _, h := range hosts {
if h.Status != "online" {
continue
}
open := scanHost(ctx, h.IP, ports, timeout)
sort.Ints(open)
if err := m.store.UpsertHostPorts(h.IP, open); err != nil {
slog.Error("save host ports", "ip", h.IP, "err", err)
}
if len(open) > 0 {
slog.Debug("port scan", "ip", h.IP, "open", open)
found++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("port scan complete", "hosts_with_open_ports", found, "total_hosts", len(hosts))
return nil
}
// scanHost tries all ports concurrently (max 30 goroutines) and returns open ones.
func scanHost(ctx context.Context, ip string, ports []int, timeout time.Duration) []int {
var mu sync.Mutex
var open []int
var wg sync.WaitGroup
sem := make(chan struct{}, 30)
for _, port := range ports {
wg.Add(1)
go func(p int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
addr := fmt.Sprintf("%s:%d", ip, p)
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err == nil {
conn.Close()
mu.Lock()
open = append(open, p)
mu.Unlock()
}
}(port)
}
wg.Wait()
return open
}

View File

@@ -0,0 +1,298 @@
// Package sitemon checks the reachability of configured targets (ping, http, tcp).
// Each scanner instance monitors from its own site perspective, giving you
// per-site uptime visibility across all your locations.
package sitemon
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/smtp"
"os/exec"
"runtime"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "site_monitoring"
// Module runs periodic availability checks and stores results in the DB.
type Module struct {
site string
cfg config.SiteMonConfig
alertCfg config.AlertConfig
nexusCfg config.Config
store *db.Store
client *http.Client
alertHTTP *http.Client
// track previous state per check ID to detect transitions
prevState map[int64]string
}
// New creates a new site monitoring module.
func New(site string, cfg config.SiteMonConfig, alertCfg config.AlertConfig, nexusCfg config.Config, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
alertCfg: alertCfg,
nexusCfg: nexusCfg,
store: store,
prevState: make(map[int64]string),
alertHTTP: &http.Client{Timeout: 10 * time.Second},
client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes all enabled checks once and stores results.
func (m *Module) Run(ctx context.Context) error {
checks, err := m.store.ListMonitorChecks()
if err != nil {
return fmt.Errorf("list checks: %w", err)
}
if len(checks) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
for _, check := range checks {
if !check.Enabled {
continue
}
result := m.runCheck(ctx, check)
if err := m.store.InsertMonitorResult(result); err != nil {
slog.Error("insert monitor result", "check", check.Name, "err", err)
}
slog.Debug("monitor check done",
"name", check.Name, "status", result.Status, "latency_ms", result.LatencyMS)
// Detect state transitions and fire alerts.
if prev, seen := m.prevState[check.ID]; seen && prev != result.Status {
go m.sendAlert(check, result)
}
m.prevState[check.ID] = result.Status
}
_ = m.store.EndScan(scanID, nil)
_ = m.store.CleanupMonitorResults(100)
return nil
}
func (m *Module) runCheck(ctx context.Context, check db.MonitorCheck) db.MonitorResult {
result := db.MonitorResult{
CheckID: check.ID,
CheckedAt: time.Now(),
}
start := time.Now()
var checkErr error
switch check.Type {
case "ping":
checkErr = doPing(ctx, check.Target)
case "http", "https":
checkErr = doHTTP(ctx, m.client, check.Target)
case "tcp":
checkErr = doTCP(ctx, check.Target)
default:
checkErr = fmt.Errorf("unknown type: %s", check.Type)
}
result.LatencyMS = int(time.Since(start).Milliseconds())
if checkErr != nil {
result.Status = "offline"
result.Error = checkErr.Error()
} else {
result.Status = "online"
}
return result
}
func doPing(ctx context.Context, target string) error {
var args []string
if runtime.GOOS == "windows" {
args = []string{"-n", "1", "-w", "2000", target}
} else {
args = []string{"-c", "1", "-W", "2", target}
}
cmd := exec.CommandContext(ctx, "ping", args...)
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("ping: %w", err)
}
lower := strings.ToLower(string(out))
if strings.Contains(lower, "unreachable") ||
strings.Contains(lower, "timed out") ||
strings.Contains(lower, "100% loss") ||
strings.Contains(lower, "100% packet loss") {
return fmt.Errorf("host unreachable")
}
return nil
}
func doHTTP(ctx context.Context, client *http.Client, target string) error {
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
target = "https://" + target
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "NexusScanner/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
func doTCP(ctx context.Context, target string) error {
d := net.Dialer{Timeout: 5 * time.Second}
conn, err := d.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
conn.Close()
return nil
}
/* ── Alerts ───────────────────────────────────────────────────────── */
func (m *Module) sendAlert(check db.MonitorCheck, result db.MonitorResult) {
emoji := "🔴"
word := "OFFLINE"
if result.Status == "online" {
emoji = "✅"
word = "WIEDER ONLINE"
}
subject := fmt.Sprintf("[Nexus Scanner %s] %s %s: %s", m.site, emoji, word, check.Name)
body := fmt.Sprintf(
"Standort: %s\nCheck: %s (%s)\nZiel: %s\nStatus: %s\nZeit: %s\n",
m.site, check.Name, check.Type, check.Target,
strings.ToUpper(result.Status),
result.CheckedAt.Format("02.01.2006 15:04:05"),
)
if result.Error != "" {
body += "Fehler: " + result.Error + "\n"
}
if m.alertCfg.SMTP.Enabled {
if err := m.sendMail(subject, body); err != nil {
slog.Error("alert mail failed", "err", err)
} else {
slog.Info("alert mail sent", "check", check.Name, "status", result.Status)
}
}
if m.alertCfg.NexusEnabled && m.nexusCfg.Nexus.URL != "" {
if err := m.sendNexusAlert(check, result, subject, body); err != nil {
slog.Error("nexus alert failed", "err", err)
} else {
slog.Info("nexus alert sent", "check", check.Name, "status", result.Status)
}
}
}
func (m *Module) sendMail(subject, body string) error {
sc := m.alertCfg.SMTP
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
msg := []byte("From: " + sc.From + "\r\n" +
"To: " + sc.To + "\r\n" +
"Subject: " + subject + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n\r\n" +
body)
var auth smtp.Auth
if sc.Username != "" {
auth = smtp.PlainAuth("", sc.Username, sc.Password, sc.Host)
}
// Try STARTTLS first, fall back to plain.
tlsCfg := &tls.Config{ServerName: sc.Host, InsecureSkipVerify: false}
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dial smtp: %w", err)
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(sc.From); err != nil {
return err
}
if err := c.Rcpt(sc.To); err != nil {
return err
}
wc, err := c.Data()
if err != nil {
return err
}
defer wc.Close()
_, err = wc.Write(msg)
return err
}
func (m *Module) sendNexusAlert(check db.MonitorCheck, result db.MonitorResult, subject, body string) error {
payload := map[string]any{
"site": m.site,
"check": check.Name,
"type": check.Type,
"target": check.Target,
"status": result.Status,
"latency": result.LatencyMS,
"error": result.Error,
"timestamp": result.CheckedAt,
"subject": subject,
"body": body,
}
b, _ := json.Marshal(payload)
url := m.nexusCfg.Nexus.URL + "/api/scanner/alert"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Scanner-Key", m.nexusCfg.Nexus.APIKey)
resp, err := m.alertHTTP.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("nexus returned %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,334 @@
// Package snmpmod polls SNMP targets for device information.
// Supports generic devices, printers (toner/pages) and switches.
package snmpmod
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/gosnmp/gosnmp"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "snmp"
// OIDs queried for every device.
var baseOIDs = map[string]string{
"sysDescr": "1.3.6.1.2.1.1.1.0",
"sysUpTime": "1.3.6.1.2.1.1.3.0",
"sysName": "1.3.6.1.2.1.1.5.0",
"sysLocation": "1.3.6.1.2.1.1.6.0",
"sysContact": "1.3.6.1.2.1.1.4.0",
"ifNumber": "1.3.6.1.2.1.2.1.0",
}
// Switch-specific scalar OIDs (queried via Get).
var switchScalarOIDs = map[string]string{
"cpuLoad": "1.3.6.1.2.1.25.3.3.1.2.1", // hrProcessorLoad (first CPU)
}
// Printer-specific OIDs (Kyocera / most RFC 3805 printers).
var printerOIDs = map[string]string{
"tonerLevel": "1.3.6.1.2.1.43.11.1.1.9.1.1",
"tonerMax": "1.3.6.1.2.1.43.11.1.1.8.1.1",
"pageCount": "1.3.6.1.2.1.43.10.2.1.4.1.1",
"printerStatus": "1.3.6.1.2.1.25.3.5.1.1.1",
}
// Walk base OIDs for interfaces and memory.
const (
oidIfOperStatus = "1.3.6.1.2.1.2.2.1.8" // 1=up, 2=down
oidIfHCInOctets = "1.3.6.1.2.1.31.1.1.1.6" // 64-bit in bytes
oidIfHCOutOctets = "1.3.6.1.2.1.31.1.1.1.10" // 64-bit out bytes
oidIfDescr = "1.3.6.1.2.1.2.2.1.2" // interface name
oidHrStorageDescr = "1.3.6.1.2.1.25.2.3.1.2"
oidHrStorageUsed = "1.3.6.1.2.1.25.2.3.1.6"
oidHrStorageSize = "1.3.6.1.2.1.25.2.3.1.5"
oidHrStorageAlloc = "1.3.6.1.2.1.25.2.3.1.4"
)
// Module polls SNMP targets and stores results.
type Module struct {
site string
cfg config.SNMPConfig
store *db.Store
}
// New creates a new SNMP module.
func New(site string, cfg config.SNMPConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run polls all enabled SNMP targets.
func (m *Module) Run(ctx context.Context) error {
targets, err := m.store.ListSNMPTargets()
if err != nil {
return fmt.Errorf("list snmp targets: %w", err)
}
if len(targets) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
ok := 0
for _, t := range targets {
if !t.Enabled {
continue
}
if err := m.pollTarget(ctx, t); err != nil {
slog.Warn("snmp poll failed", "target", t.Name, "ip", t.IP, "err", err)
} else {
ok++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("snmp poll complete", "ok", ok, "total", len(targets))
return nil
}
func (m *Module) pollTarget(ctx context.Context, t db.SNMPTarget) error {
community := t.Community
if community == "" {
community = m.cfg.Community
}
gs := &gosnmp.GoSNMP{
Target: t.IP,
Port: 161,
Community: community,
Version: snmpVersion(t.Version),
Timeout: 10 * time.Second,
Retries: 1,
MaxOids: 60,
}
if err := gs.Connect(); err != nil {
return fmt.Errorf("connect: %w", err)
}
defer gs.Conn.Close()
now := time.Now()
// --- Base + type-specific scalar GET ---
oids := make(map[string]string)
for k, v := range baseOIDs {
oids[k] = v
}
switch t.Type {
case "printer":
for k, v := range printerOIDs {
oids[k] = v
}
case "switch":
for k, v := range switchScalarOIDs {
oids[k] = v
}
}
oidList := make([]string, 0, len(oids))
reverseMap := make(map[string]string)
for name, oid := range oids {
oidList = append(oidList, oid)
reverseMap[oid] = name
}
result, err := gs.Get(oidList)
if err != nil {
return fmt.Errorf("get: %w", err)
}
for _, pdu := range result.Variables {
name := resolveOID(pdu.Name, reverseMap)
if name == "" {
continue
}
value := pduToString(pdu)
if value == "" || value == "<nil>" || strings.HasPrefix(value, "<nil>") {
continue
}
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
// --- Switch: walk interface table + memory ---
if t.Type == "switch" {
m.walkSwitch(gs, t, now)
}
slog.Debug("snmp polled", "target", t.Name, "ip", t.IP, "type", t.Type)
return nil
}
// walkSwitch collects interface stats and memory for switch-type targets.
func (m *Module) walkSwitch(gs *gosnmp.GoSNMP, t db.SNMPTarget, now time.Time) {
// Interface status walk → portsUp / portsTotal
var portsUp, portsTotal int
var trafficIn, trafficOut uint64
ifStatus := walkOID(gs, oidIfOperStatus)
for _, v := range ifStatus {
portsTotal++
if v == "1" {
portsUp++
}
}
// Traffic counters (64-bit)
for _, v := range walkOID(gs, oidIfHCInOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficIn += n
}
for _, v := range walkOID(gs, oidIfHCOutOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficOut += n
}
save := func(name, value string) {
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
if portsTotal > 0 {
save("portsTotal", strconv.Itoa(portsTotal))
save("portsUp", strconv.Itoa(portsUp))
}
if trafficIn > 0 {
save("trafficIn", strconv.FormatUint(trafficIn, 10))
}
if trafficOut > 0 {
save("trafficOut", strconv.FormatUint(trafficOut, 10))
}
// Memory: find "Physical Memory" or "Real Memory" row
descrByIdx := walkOID(gs, oidHrStorageDescr)
usedByIdx := walkOID(gs, oidHrStorageUsed)
sizeByIdx := walkOID(gs, oidHrStorageSize)
allocByIdx := walkOID(gs, oidHrStorageAlloc)
for idx, descr := range descrByIdx {
d := strings.ToLower(descr)
if strings.Contains(d, "physical") || strings.Contains(d, "real") || strings.Contains(d, "ram") {
used, _ := strconv.ParseInt(usedByIdx[idx], 10, 64)
size, _ := strconv.ParseInt(sizeByIdx[idx], 10, 64)
alloc, _ := strconv.ParseInt(allocByIdx[idx], 10, 64)
if alloc <= 0 {
alloc = 1024
}
usedBytes := used * alloc
totalBytes := size * alloc
if totalBytes > 0 {
save("memUsed", strconv.FormatInt(usedBytes, 10))
save("memTotal", strconv.FormatInt(totalBytes, 10))
}
break
}
}
}
// walkOID performs an SNMP walk and returns a map of last-OID-index → value string.
func walkOID(gs *gosnmp.GoSNMP, baseOID string) map[string]string {
out := make(map[string]string)
err := gs.Walk(baseOID, func(pdu gosnmp.SnmpPDU) error {
// Extract the index (last component of OID)
name := strings.TrimPrefix(pdu.Name, ".")
parts := strings.Split(name, ".")
idx := parts[len(parts)-1]
val := pduRawToString(pdu)
if val != "" {
out[idx] = val
}
return nil
})
if err != nil {
slog.Debug("snmp walk failed", "oid", baseOID, "err", err)
}
return out
}
func resolveOID(oidName string, reverseMap map[string]string) string {
key := strings.TrimPrefix(oidName, ".")
if name := reverseMap[key]; name != "" {
return name
}
// Try without trailing .0
if len(key) > 2 && key[len(key)-2:] == ".0" {
return reverseMap[key[:len(key)-2]]
}
return ""
}
func snmpVersion(v string) gosnmp.SnmpVersion {
switch v {
case "v1":
return gosnmp.Version1
case "v3":
return gosnmp.Version3
default:
return gosnmp.Version2c
}
}
// pduToString converts a PDU value to a display string (formatted).
func pduToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.TimeTicks:
ticks, _ := pdu.Value.(uint32)
d := time.Duration(ticks) * 10 * time.Millisecond
h := int(d.Hours())
mn := int(d.Minutes()) % 60
return strconv.Itoa(h) + "h " + strconv.Itoa(mn) + "m"
case gosnmp.ObjectIdentifier:
if s, ok := pdu.Value.(string); ok {
return s
}
default:
return fmt.Sprintf("%v", pdu.Value)
}
return fmt.Sprintf("%v", pdu.Value)
}
// pduRawToString converts a PDU value to a raw numeric/string value (for walks).
func pduRawToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.Integer:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter32, gosnmp.Gauge32, gosnmp.Uinteger32:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter64:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.TimeTicks:
if t, ok := pdu.Value.(uint32); ok {
return fmt.Sprintf("%d", t)
}
}
return fmt.Sprintf("%v", pdu.Value)
}

View File

@@ -0,0 +1,142 @@
// Package sysarp implements a cross-platform ARP-table reader.
// It runs "arp -a" (available on Windows, Linux and macOS) and
// writes every discovered host into the shared SQLite store.
// Unlike the arp_discovery module it does NOT send packets —
// it reads what the OS already knows, so no raw-socket privilege is needed.
package sysarp
import (
"bufio"
"bytes"
"context"
"fmt"
"log/slog"
"os/exec"
"regexp"
"runtime"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module reads the OS ARP cache and persists discovered hosts.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new sys_arp module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "sys_arp"),
}
}
func (m *Module) Name() string { return "sys_arp" }
func (m *Module) Interval() time.Duration { return 2 * time.Minute }
// Run executes one read of the ARP cache.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.scan(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) scan(ctx context.Context) error {
out, err := exec.CommandContext(ctx, "arp", "-a").Output()
if err != nil {
return fmt.Errorf("arp -a failed: %w", err)
}
entries := parseARPOutput(out)
m.log.Info("ARP cache read", "entries", len(entries))
now := time.Now().UTC()
saved := 0
for _, e := range entries {
if err := m.store.UpsertHost(db.Host{
IP: e.ip,
MAC: e.mac,
Site: m.site,
FirstSeen: now,
LastSeen: now,
}); err != nil {
m.log.Error("upsert host", "ip", e.ip, "err", err)
continue
}
m.log.Info("host found", "ip", e.ip, "mac", e.mac)
saved++
}
m.log.Info("scan complete", "found", len(entries), "saved", saved)
return nil
}
type arpEntry struct{ ip, mac string }
// Windows: " 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic"
var reWindows = regexp.MustCompile(`^\s+([\d.]+)\s+([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})\s+\S+`)
// Linux/macOS: "? (192.168.0.1) at aa:bb:cc:dd:ee:ff [ether] on eth0"
var reUnix = regexp.MustCompile(`\(([\d.]+)\) at ([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})`)
// skipPrefixes are IPs that are multicast / broadcast — not real hosts.
var skipPrefixes = []string{"224.", "239.", "255.", "ff02"}
func parseARPOutput(data []byte) []arpEntry {
var entries []arpEntry
seen := make(map[string]struct{})
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
var ip, mac string
if runtime.GOOS == "windows" {
m := reWindows.FindStringSubmatch(line)
if m == nil {
continue
}
ip = m[1]
// Normalise Windows dashes to colons: aa-bb-cc → aa:bb:cc
mac = strings.ReplaceAll(strings.ToLower(m[2]), "-", ":")
} else {
m := reUnix.FindStringSubmatch(line)
if m == nil {
continue
}
ip = m[1]
mac = strings.ToLower(m[2])
}
// Skip multicast, broadcast and already-seen IPs.
skip := false
for _, pfx := range skipPrefixes {
if strings.HasPrefix(ip, pfx) || strings.HasPrefix(mac, pfx) {
skip = true
break
}
}
if skip {
continue
}
if _, dup := seen[ip]; dup {
continue
}
seen[ip] = struct{}{}
entries = append(entries, arpEntry{ip: ip, mac: mac})
}
return entries
}

View File

@@ -0,0 +1,63 @@
package scheduler
import (
"context"
"log/slog"
"time"
"github.com/cereda-systems/nexus-scanner/internal/module"
)
// Scheduler runs registered modules at their configured intervals.
type Scheduler struct {
registry *module.Registry
}
// New returns a new Scheduler backed by the given registry.
func New(registry *module.Registry) *Scheduler {
return &Scheduler{registry: registry}
}
// Run starts all modules in background goroutines and blocks until ctx is cancelled.
func (s *Scheduler) Run(ctx context.Context) {
for _, m := range s.registry.All() {
go s.runModule(ctx, m)
}
<-ctx.Done()
}
func (s *Scheduler) runModule(ctx context.Context, m module.Module) {
log := slog.With("module", m.Name())
// Run immediately on startup.
s.execute(ctx, m, log)
// If interval is zero, run once and stop.
if m.Interval() == 0 {
return
}
ticker := time.NewTicker(m.Interval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.execute(ctx, m, log)
}
}
}
func (s *Scheduler) execute(ctx context.Context, m module.Module, log *slog.Logger) {
log.Info("run started")
start := time.Now()
if err := m.Run(ctx); err != nil {
log.Error("run failed", "err", err, "elapsed", time.Since(start).Round(time.Millisecond))
return
}
log.Info("run completed", "elapsed", time.Since(start).Round(time.Millisecond))
}

View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed static templates
var webFS embed.FS

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,510 @@
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens ── */
:root {
--teal: #008487;
--teal-600: #006e71;
--teal-700: #005a5c;
--teal-50: #e6f3f3;
--bg: #f5f5f7;
--surface: #ffffff;
--surface-2: #fbfbfd;
--surface-3: #f2f2f4;
--text: #1d1d1f;
--text-2: #515154;
--text-3: #86868b;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.14);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04), 0 1px 1px rgba(0,0,0,0.03);
--shadow-md: 0 4px 16px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 24px 64px -16px rgba(0,0,0,0.18), 0 6px 18px rgba(0,0,0,0.08);
--success: #34a853;
--warning: #e8a23a;
--danger: #e5484d;
--info: #2f7df1;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-xl: 20px;
--sidebar-w: 232px;
--topbar-h: 52px;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #000000;
--surface: #1c1c1e;
--surface-2: #161618;
--surface-3: #2c2c2e;
--text: #f5f5f7;
--text-2: #aeaeb2;
--text-3: #636366;
--border: rgba(255,255,255,0.08);
--border-strong: rgba(255,255,255,0.14);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.6);
--shadow-md: 0 6px 20px rgba(0,0,0,0.45);
--teal-50: rgba(0,132,135,0.15);
}
/* ── Base ── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
letter-spacing: -0.005em;
}
a { text-decoration: none; color: inherit; }
button { font-family: inherit; }
input, select { font-family: inherit; font-size: inherit; color: inherit; }
::selection { background: rgba(0,132,135,0.2); }
/* ── Layout ── */
.app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
.main { display: flex; flex-direction: column; min-width: 0; }
/* ── Sidebar ── */
.sidebar {
position: sticky; top: 0; height: 100vh;
background: var(--surface-2);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 14px 10px;
overflow-y: auto;
}
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px 16px;
}
.brand-mark {
width: 30px; height: 30px; border-radius: var(--r-sm);
background: var(--teal); display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.brand-mark svg { color: #fff; width: 17px; height: 17px; }
.brand-name { font-size: 13.5px; font-weight: 700; color: var(--text); }
.brand-sub { font-size: 11px; color: var(--text-3); }
.sidebar-section {
font-size: 10.5px; font-weight: 600; color: var(--text-3);
letter-spacing: 0.05em; text-transform: uppercase;
padding: 12px 8px 5px;
}
.nav-item {
display: flex; align-items: center; gap: 9px;
padding: 7px 8px; border-radius: var(--r-sm);
color: var(--text-2); font-size: 13.5px; font-weight: 500;
transition: background 0.12s, color 0.12s;
cursor: pointer; border: none; background: none; width: 100%; text-align: left;
}
.nav-item:hover { background: var(--surface-3); color: var(--text); }
.nav-item.active { background: var(--teal-50); color: var(--teal); }
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; stroke-width: 1.75; }
.nav-badge {
margin-left: auto; background: var(--surface-3); color: var(--text-2);
border-radius: 999px; padding: 1px 7px; font-size: 11px; font-weight: 600;
}
.nav-item.active .nav-badge { background: rgba(0,132,135,.18); color: var(--teal); }
.nav-dot { margin-left: auto; width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
.sidebar-footer {
margin-top: auto; padding: 10px 8px 6px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.avatar {
width: 27px; height: 27px; border-radius: 50%;
background: linear-gradient(135deg, var(--teal), var(--teal-700));
color: #fff; display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; flex-shrink: 0;
}
.user-name { font-size: 12.5px; font-weight: 600; color: var(--text); }
.user-sub { font-size: 11px; color: var(--text-3); }
/* ── Topbar ── */
.topbar {
height: var(--topbar-h); position: sticky; top: 0;
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 28px; gap: 8px; z-index: 50;
}
.breadcrumb { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-3); }
.breadcrumb-sep { color: var(--text-3); }
.breadcrumb-current { font-weight: 600; color: var(--text); }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
/* ── Content area ── */
.content { flex: 1; padding: 24px 28px; }
/* ── Page header ── */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
.page-header h2, .page-title { font-size: 21px; font-weight: 700; letter-spacing: -0.3px; }
.page-header p, .page-sub { font-size: 13px; color: var(--text-3); margin-top: 2px; }
.page-actions { display: flex; align-items: center; gap: 8px; }
/* ── Scan status pill (in page header) ── */
.scan-pill {
display: inline-flex; align-items: center; gap: 7px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; padding: 5px 12px 5px 10px;
font-size: 12.5px;
}
.scan-pill .label { font-weight: 500; color: var(--text); }
.scan-pill .sep { color: var(--text-3); }
.scan-pill .site { color: var(--text-3); }
/* ── Status pill (topbar) ── */
.status-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500;
background: var(--surface-3); color: var(--text-2);
}
.status-pill.online { background: rgba(52,168,83,.1); color: var(--success); }
.status-pill.scanning { background: rgba(0,132,135,.1); color: var(--teal); }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: var(--r-sm);
font-size: 13px; font-weight: 500;
cursor: pointer; border: 1px solid var(--border);
background: var(--surface); color: var(--text);
transition: background 0.12s, border-color 0.12s; white-space: nowrap;
}
.btn:hover { background: var(--surface-3); }
.btn svg { width: 14px; height: 14px; stroke-width: 1.75; }
.btn-primary { background: var(--teal); color: #fff; border-color: var(--teal); }
.btn-primary:hover { background: var(--teal-600); border-color: var(--teal-600); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-3); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
/* ── Icon button ── */
.icon-btn {
width: 30px; height: 30px; border-radius: var(--r-sm);
border: 1px solid var(--border); background: var(--surface); color: var(--text-2);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: background 0.12s;
}
.icon-btn:hover { background: var(--surface-3); color: var(--text); }
.icon-btn svg { width: 14px; height: 14px; stroke-width: 1.75; }
/* ── Inputs ── */
.input, .select {
width: 100%; padding: 7.5px 12px;
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
background: var(--surface); color: var(--text); font-size: 13.5px;
outline: none; transition: border-color .14s, box-shadow .14s;
}
.input:focus, .select:focus {
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(0,132,135,.14);
}
.input::placeholder { color: var(--text-3); }
.input-label { font-size: 12.5px; font-weight: 500; color: var(--text-2); margin-bottom: 5px; display: block; }
/* ── Toggle ── */
.toggle { position: relative; display: inline-block; width: 36px; height: 22px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0; background: var(--surface-3);
border: 1px solid var(--border); border-radius: 20px;
cursor: pointer; transition: background .18s;
}
.toggle input:checked + .toggle-track { background: var(--teal); border-color: var(--teal); }
.toggle-track::after {
content: ''; position: absolute; left: 2px; top: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2);
transition: transform .18s;
}
.toggle input:checked + .toggle-track::after { transform: translateX(14px); }
/* ── Dot ── */
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.dot.green { background: var(--success); box-shadow: 0 0 0 2px rgba(52,168,83,.18); }
.dot.red { background: var(--danger); }
.dot.yellow { background: var(--warning); }
.dot.gray { background: var(--text-3); }
.dot.teal { background: var(--teal); }
.dot.pulse { animation: dotpulse 1.6s ease-out infinite; }
@keyframes dotpulse {
0% { box-shadow: 0 0 0 0 rgba(52,168,83,0.5); }
70% { box-shadow: 0 0 0 6px rgba(52,168,83,0); }
100% { box-shadow: 0 0 0 0 rgba(52,168,83,0); }
}
/* ── Badge ── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 500;
background: var(--surface-3); color: var(--text-2);
}
.badge-green { background: rgba(52,168,83,.12); color: #2c8a44; }
.badge-red { background: rgba(229,72,77,.12); color: #c4393d; }
.badge-yellow { background: rgba(232,162,58,.14); color: #b07820; }
.badge-teal { background: var(--teal-50); color: var(--teal); }
.badge-gray { background: var(--surface-3); color: var(--text-3); }
[data-theme="dark"] .badge-green { color: #4cc471; }
[data-theme="dark"] .badge-red { color: #ff6b6f; }
[data-theme="dark"] .badge-yellow { color: #ffc46b; }
[data-theme="dark"] .badge-teal { color: #4ec6c9; }
/* ── Card ── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-lg); overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border);
}
.card-title { font-size: 14px; font-weight: 600; }
.card-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.card-action { font-size: 12.5px; color: var(--teal); display: flex; align-items: center; gap: 4px; }
.card-action svg { width: 12px; height: 12px; }
/* ── Stats grid ── */
.stats-grid { display: grid; gap: 16px; margin-bottom: 20px; }
.stats-grid-4 { grid-template-columns: repeat(4, 1fr); }
.stat {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.stat-icon { width: 14px; height: 14px; color: var(--text-3); stroke-width: 1.75; }
.stat-label { font-size: 12.5px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
.stat-value { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; margin-top: 2px; }
.stat-delta { font-size: 12.5px; color: var(--text-3); margin-top: 4px; }
.stat-delta.up { color: var(--success); }
.stat-delta.down { color: var(--danger); }
/* ── Sparkline ── */
.sparkline { display: flex; align-items: flex-end; gap: 2px; height: 24px; margin-top: 8px; }
.sparkline span { flex: 1; background: var(--teal); border-radius: 1.5px; opacity: 0.75; }
/* ── Dashboard 2-col grid ── */
.dash-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
/* ── Table ── */
table { width: 100%; border-collapse: collapse; }
thead th {
padding: 10px 16px; text-align: left; white-space: nowrap;
font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
color: var(--text-3); border-bottom: 1px solid var(--border);
background: var(--surface-2); position: sticky; top: var(--topbar-h);
}
tbody td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-2); }
.mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12.5px; }
/* ── Device icon cell ── */
.device-icon {
width: 28px; height: 28px; border-radius: 7px;
background: var(--surface-3); color: var(--text-2);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.device-icon svg { width: 14px; height: 14px; stroke-width: 1.75; }
.device-row { display: flex; align-items: center; gap: 10px; }
.device-name { font-size: 13px; font-weight: 600; }
.device-vendor { font-size: 11.5px; color: var(--text-3); }
/* ── Filter chips ── */
.filter-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--border); background: var(--surface); }
.filter-chips { display: flex; gap: 5px; }
.chip {
padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 500;
background: transparent; border: 1px solid var(--border-strong); color: var(--text-2);
cursor: pointer; transition: all .12s;
}
.chip:hover { background: var(--surface-3); }
.chip.active { background: var(--teal-50); border-color: transparent; color: var(--teal); }
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 300px; }
.search-wrap svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--text-3); pointer-events: none; }
.search-wrap .input { padding-left: 32px; }
/* ── Module row ── */
.module-row {
display: flex; align-items: center; gap: 14px;
padding: 14px 20px; border-bottom: 1px solid var(--border); transition: background .12s;
}
.module-row:last-child { border-bottom: none; }
.module-row:hover { background: var(--surface-2); }
.module-icon {
width: 34px; height: 34px; border-radius: var(--r-sm);
background: var(--surface-3); color: var(--text-3);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.module-icon.active { background: var(--teal-50); color: var(--teal); }
.module-icon svg { width: 17px; height: 17px; stroke-width: 1.75; }
.module-info { flex: 1; min-width: 0; }
.module-name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.module-desc { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.module-stats { display: flex; gap: 16px; font-size: 12px; color: var(--text-3); margin-top: 3px; }
.module-stats strong { color: var(--text-2); font-weight: 500; }
.module-config {
border-top: 1px solid var(--border); padding: 18px 20px;
background: var(--surface-2);
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
}
/* ── Dashboard module list row ── */
.dash-module-row {
display: flex; align-items: center; gap: 12px;
padding: 11px 20px; border-top: 1px solid var(--border);
}
.dash-module-icon {
width: 30px; height: 30px; border-radius: var(--r-sm);
background: var(--teal-50); color: var(--teal);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.dash-module-icon.off { background: var(--surface-3); color: var(--text-3); }
.dash-module-icon svg { width: 15px; height: 15px; stroke-width: 1.75; }
.dash-module-name { font-size: 13px; font-weight: 600; }
.dash-module-sub { font-size: 11.5px; color: var(--text-3); }
/* ── Logs ── */
.log-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.log-container {
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-lg);
height: 500px; overflow-y: auto;
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px;
}
.log-row {
display: grid; grid-template-columns: 80px 48px 1fr;
gap: 14px; padding: 6px 16px;
border-bottom: 1px solid var(--border); align-items: start; line-height: 1.5;
}
.log-row:last-child { border-bottom: none; }
.log-row:hover { background: var(--surface-3); }
.log-time { color: var(--text-3); font-size: 11px; white-space: nowrap; padding-top: 1px; }
.log-level { font-size: 10.5px; font-weight: 700; padding: 2px 6px; border-radius: 4px; text-align: center; text-transform: uppercase; letter-spacing: 0.04em; }
.log-level.ok { background: rgba(52,168,83,.14); color: #2c8a44; }
.log-level.info { background: rgba(47,125,241,.14); color: #2766c4; }
.log-level.warn { background: rgba(232,162,58,.18); color: #b07820; }
.log-level.err { background: rgba(229,72,77,.14); color: #c4393d; }
[data-theme="dark"] .log-level.ok { color: #4cc471; }
[data-theme="dark"] .log-level.info { color: #71aaff; }
[data-theme="dark"] .log-level.warn { color: #ffc46b; }
[data-theme="dark"] .log-level.err { color: #ff7679; }
.log-msg { color: var(--text); word-break: break-word; }
/* ── Settings ── */
.settings-section { margin-bottom: 24px; }
.settings-title { font-size: 15px; font-weight: 700; margin-bottom: 14px; }
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border); gap: 20px;
}
.setting-row:last-child { border-bottom: none; }
.setting-label { font-size: 13.5px; font-weight: 500; }
.setting-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.setting-body { padding: 18px 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.setting-body.full { grid-template-columns: 1fr; }
.form-group { margin-bottom: 14px; }
/* ── Password wrap ── */
.password-wrap { position: relative; }
.password-wrap .input { padding-right: 40px; }
.eye-btn {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; color: var(--text-3); padding: 2px;
}
.eye-btn:hover { color: var(--text); }
.eye-btn svg { width: 16px; height: 16px; stroke-width: 1.75; }
/* ── Auth / Login ── */
.auth-wrap {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg); padding: 48px 24px;
}
.auth-card {
width: 100%; max-width: 380px; background: var(--surface);
border: 1px solid var(--border); border-radius: 18px; padding: 36px 30px;
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column; align-items: center; gap: 20px;
}
.auth-logo { height: 36px; }
.auth-title { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
.auth-sub { font-size: 13px; color: var(--text-3); text-align: center; margin-top: -10px; }
.auth-card form { width: 100%; display: flex; flex-direction: column; gap: 12px; }
/* ── Setup wizard ── */
.wizard-wrap {
min-height: 100vh; display: flex;
align-items: safe center; justify-content: safe center;
background: var(--bg); padding: 24px;
}
.wizard {
width: 100%; max-width: 540px; background: var(--surface);
border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-lg); overflow: hidden;
}
.wizard-header { padding: 26px 30px 18px; border-bottom: 1px solid var(--border); }
.wizard-steps { display: flex; gap: 6px; margin-top: 16px; }
.wizard-step { flex: 1; height: 3px; border-radius: 999px; background: var(--surface-3); }
.wizard-step.done, .wizard-step.active { background: var(--teal); }
.wizard-body { padding: 26px 30px; min-height: 220px; }
.wizard-footer {
padding: 14px 30px; border-top: 1px solid var(--border);
background: var(--surface-2);
display: flex; align-items: center; justify-content: space-between;
}
.wizard-icon {
width: 48px; height: 48px; border-radius: var(--r-md);
background: var(--teal-50); color: var(--teal);
display: flex; align-items: center; justify-content: center;
margin-bottom: 16px;
}
.wizard-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
.wizard-title { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
.wizard-sub { font-size: 13px; color: var(--text-3); margin-bottom: 20px; }
.site-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.site-card {
border: 2px solid var(--border); border-radius: var(--r-md); padding: 16px;
cursor: pointer; transition: border-color .15s, background .15s;
}
.site-card:hover { border-color: var(--teal-50); background: var(--surface-2); }
.site-card.selected { border-color: var(--teal); background: var(--teal-50); }
.site-card-name { font-weight: 600; font-size: 14px; }
.site-card-sub { font-size: 12px; color: var(--text-3); margin-top: 3px; }
/* ── Pagination ── */
.pagination { display: flex; align-items: center; gap: 3px; }
.page-btn {
min-width: 28px; height: 28px; border-radius: 6px;
border: 1px solid transparent; background: transparent;
color: var(--text-2); font-size: 12.5px; font-weight: 500; cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0 8px;
}
.page-btn:hover { background: var(--surface-3); color: var(--text); }
.page-btn.active { background: var(--teal); color: #fff; }
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ── Error / Success msgs ── */
.error-msg {
background: rgba(229,72,77,.08); border: 1px solid rgba(229,72,77,.2);
border-radius: var(--r-sm); padding: 10px 14px; font-size: 13px; color: var(--danger);
}
.success-msg {
background: rgba(52,168,83,.08); border: 1px solid rgba(52,168,83,.2);
border-radius: var(--r-sm); padding: 10px 14px; font-size: 13px; color: var(--success);
}
/* ── Spinner ── */
.spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── HTMX ── */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-flex; }
/* ── Utilities ── */
.spacer { flex: 1; }
.mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12.5px; }

View File

@@ -0,0 +1,71 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Active Directory</h1>
<p class="page-sub">{{len .Computers}} Computer-Objekte aus {{.Domain}}</p>
</div>
</div>
{{if not .ADEnabled}}
<div style="background:rgba(232,162,58,.08);border:1px solid rgba(232,162,58,.3);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:20px;">
<div style="font-weight:600;color:var(--warning);margin-bottom:6px;">AD Sync nicht konfiguriert</div>
<div style="font-size:13px;color:var(--text-2);">Trage Server, Bind-DN und Passwort in der <a href="/settings" style="color:var(--teal);">config.yaml</a> ein und aktiviere das Modul.</div>
</div>
{{end}}
<!-- Stats -->
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:20px;">
<div class="stat">
<div class="stat-label">Computer in AD</div>
<div class="stat-value">{{len .Computers}}</div>
<div class="stat-delta neutral">{{.Domain}}</div>
</div>
<div class="stat">
<div class="stat-label">Im Netzwerk gesehen</div>
<div class="stat-value" style="color:var(--success);">{{.SeenCount}}</div>
<div class="stat-delta up">↑ aktiv erreichbar</div>
</div>
<div class="stat">
<div class="stat-label">Nicht gesehen</div>
<div class="stat-value" style="{{if gt .MissingCount 0}}color:var(--warning);{{end}}">{{.MissingCount}}</div>
<div class="stat-delta {{if gt .MissingCount 0}}down{{else}}neutral{{end}}">offline oder abwesend</div>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Computer-Name</th>
<th>Abteilung / OU</th>
<th>Betriebssystem</th>
<th>Letzter Login (AD)</th>
<th>Im Netzwerk</th>
</tr>
</thead>
<tbody>
{{range .Computers}}
<tr>
<td style="font-weight:600;">{{.CN}}</td>
<td style="color:var(--text-2);">{{if .Department}}{{.Department}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</td>
<td style="color:var(--text-2);font-size:12px;">{{if .OS}}{{.OS}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</td>
<td style="color:var(--text-3);font-size:12px;">
{{if not .LastLogon.IsZero}}{{formatTime .LastLogon}}{{else}}—{{end}}
</td>
<td>
{{if .SeenInNetwork}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
{{else}}
<span class="badge badge-yellow">Nicht gesehen</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5" style="text-align:center;padding:48px;color:var(--text-3);">
Noch keine AD-Daten — AD Sync ausführen.
</td></tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

View File

@@ -0,0 +1,126 @@
{{define "content"}}
<div class="page-header">
<div>
<h2 class="page-title">Geräte-Inventar</h2>
<p class="page-sub">{{.TotalHosts}} Einträge · zuletzt aktualisiert {{.LastScan}}</p>
</div>
<div style="display:flex;gap:8px;">
<a href="/assets/export.csv" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
CSV Export
</a>
</div>
</div>
<!-- Filter bar -->
<div class="card" style="margin-bottom:16px;overflow:visible;">
<form method="GET" action="/assets">
<div class="filter-bar">
<div class="search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input class="input" type="search" name="q" value="{{.Query}}" placeholder="IP, Hostname oder MAC…">
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:12px;color:var(--text-3);white-space:nowrap;">Standort:</span>
<div class="filter-chips">
<button type="submit" name="site" value="" class="chip{{if eq .SiteFilter ""}} active{{end}}">Alle</button>
<button type="submit" name="site" value="LUD" class="chip{{if eq .SiteFilter "LUD"}} active{{end}}">Lüdenscheid</button>
<button type="submit" name="site" value="BAR" class="chip{{if eq .SiteFilter "BAR"}} active{{end}}">Barleben</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:12px;color:var(--text-3);white-space:nowrap;">Status:</span>
<div class="filter-chips">
<button type="submit" name="status" value="" class="chip{{if eq .StatusFilter ""}} active{{end}}">Alle</button>
<button type="submit" name="status" value="online" class="chip{{if eq .StatusFilter "online"}} active{{end}}">Online</button>
<button type="submit" name="status" value="offline" class="chip{{if eq .StatusFilter "offline"}} active{{end}}">Offline</button>
</div>
</div>
<input type="hidden" name="site" value="{{.SiteFilter}}">
<input type="hidden" name="status" value="{{.StatusFilter}}">
</div>
</form>
</div>
<!-- Table -->
<div class="card">
<table>
<thead>
<tr>
<th style="padding-left:20px;">IP-Adresse</th>
<th>MAC-Adresse</th>
<th>Hersteller</th>
<th>Hostname</th>
<th>Offene Ports</th>
<th>Standort</th>
<th>Zuletzt gesehen</th>
<th style="padding-right:20px;">Status</th>
</tr>
</thead>
<tbody>
{{range .Hosts}}
<tr>
<td style="padding-left:20px;" class="mono" style="font-weight:600;">{{.IP}}</td>
<td class="mono" style="color:var(--text-3);">{{.MAC}}</td>
<td>
<div class="device-row">
<div class="device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>
</svg>
</div>
<span style="color:var(--text-2);">{{if .Vendor}}{{.Vendor}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</span>
</div>
</td>
<td style="color:var(--text-2);font-size:12.5px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{if .Hostname}}{{.Hostname}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</td>
<td>
{{if .OpenPorts}}
<div style="display:flex;flex-wrap:wrap;gap:3px;">
{{range .OpenPorts}}<span style="background:var(--teal-50);color:var(--teal);border-radius:3px;padding:1px 5px;font-size:10px;font-family:monospace;font-weight:600;">{{.}}</span>{{end}}
</div>
{{else}}<span style="color:var(--text-3);"></span>{{end}}
</td>
<td><span class="badge badge-gray">{{.Site}}</span></td>
<td style="color:var(--text-3);font-size:12px;">{{.LastSeenFmt}}</td>
<td style="padding-right:20px;">
{{if eq .Status "online"}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
{{else}}
<span class="badge badge-gray"><span class="dot gray" style="width:5px;height:5px;margin-right:4px;"></span>Offline</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="8" style="text-align:center;padding:48px;color:var(--text-3);">
Keine Geräte gefunden.
</td></tr>
{{end}}
</tbody>
</table>
{{if gt .TotalPages 1}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-top:1px solid var(--border);background:var(--surface-2);">
<span style="font-size:12.5px;color:var(--text-3);">
Zeige {{if gt .TotalHosts 0}}{{inc (mul (dec .PageNum) 20)}}{{end}}{{.TotalHosts}} Einträge
</span>
<div class="pagination">
{{if gt .PageNum 1}}
<a href="/assets?{{.QueryString}}&page={{dec .PageNum}}" class="page-btn"></a>
{{else}}
<button class="page-btn" disabled></button>
{{end}}
{{range .Pages}}
<a href="/assets?{{$.QueryString}}&page={{.}}" class="page-btn{{if eq . $.PageNum}} active{{end}}">{{.}}</a>
{{end}}
{{if lt .PageNum .TotalPages}}
<a href="/assets?{{.QueryString}}&page={{inc .PageNum}}" class="page-btn"></a>
{{else}}
<button class="page-btn" disabled></button>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,166 @@
{{define "content"}}
<div class="page-header">
<div>
<h2 class="page-title">Übersicht</h2>
<p class="page-sub">Status deines lokalen Scanners und der gefundenen Assets.</p>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div class="scan-pill">
{{if .Scanning}}<span class="dot green" style="animation:dotpulse 1.6s ease-out infinite;"></span>
<span class="label">Scanner läuft</span>
{{else}}<span class="dot yellow"></span>
<span class="label">Bereit</span>{{end}}
<span class="sep">·</span>
<span class="site">Standort {{.SiteID}}</span>
</div>
<form method="POST" action="/scan/start" style="margin:0;">
<button type="submit" class="btn btn-primary" {{if .Scanning}}disabled{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;">
{{if .Scanning}}<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
{{else}}<polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/>{{end}}
</svg>
{{if .Scanning}}Scan läuft…{{else}}Jetzt scannen{{end}}
</button>
</form>
</div>
</div>
<!-- Stats -->
<div class="stats-grid stats-grid-4">
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/></svg>
Gefundene Geräte
</div>
<div class="stat-value">{{.HostCount}}</div>
<div class="stat-delta up">+{{.RecentCount}} seit letztem Scan</div>
<div class="sparkline">
<span style="height:35%"></span><span style="height:42%"></span><span style="height:50%"></span><span style="height:58%"></span><span style="height:62%"></span><span style="height:70%"></span><span style="height:78%"></span><span style="height:82%"></span><span style="height:88%"></span><span style="height:92%"></span><span style="height:95%"></span><span style="height:100%"></span>
</div>
</div>
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
Neu seit letztem Scan
</div>
<div class="stat-value">{{.RecentCount}}</div>
<div class="stat-delta">In den letzten 60 Min</div>
<div class="sparkline">
<span style="height:20%"></span><span style="height:30%"></span><span style="height:25%"></span><span style="height:45%"></span><span style="height:55%"></span><span style="height:60%"></span><span style="height:70%"></span><span style="height:65%"></span><span style="height:80%"></span><span style="height:75%"></span><span style="height:85%"></span><span style="height:90%"></span>
</div>
</div>
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/></svg>
Offline-Geräte
</div>
<div class="stat-value" style="{{if gt .OfflineCount 5}}color:var(--danger);{{end}}">{{.OfflineCount}}</div>
<div class="stat-delta {{if gt .OfflineCount 5}}down{{end}}">{{if gt .OfflineCount 5}}Erhöht ggü. Vorwoche{{else}}Im Normalbereich{{end}}</div>
<div class="sparkline">
<span style="height:60%"></span><span style="height:70%"></span><span style="height:55%"></span><span style="height:65%"></span><span style="height:80%"></span><span style="height:60%"></span><span style="height:75%"></span><span style="height:65%"></span><span style="height:70%"></span><span style="height:85%"></span><span style="height:90%"></span><span style="height:70%"></span>
</div>
</div>
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Letzter Scan
</div>
<div class="stat-value" style="font-size:26px;margin-top:4px;">{{.LastScan}}</div>
<div class="stat-delta">Intervall: {{.ARPInterval}}</div>
</div>
</div>
<!-- 2-col content -->
<div class="dash-grid">
<!-- Zuletzt gefundene Geräte -->
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Zuletzt gefundene Geräte</div>
<div class="card-sub">Die 5 jüngsten Einträge im Inventar</div>
</div>
<a href="/assets" class="card-action">
Alle anzeigen
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</a>
</div>
<table>
<thead>
<tr>
<th style="padding-left:20px;">Gerät</th>
<th>IP-Adresse</th>
<th>Standort</th>
<th>Status</th>
<th style="padding-right:20px;text-align:right;">Gesehen</th>
</tr>
</thead>
<tbody>
{{range .RecentHosts}}
<tr>
<td style="padding-left:20px;">
<div class="device-row">
<div class="device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/></svg>
</div>
<div>
<div class="device-name">{{if .Hostname}}{{.Hostname}}{{else}}{{.IP}}{{end}}</div>
<div class="device-vendor">{{if .Vendor}}{{.Vendor}}{{else}}Unbekannt{{end}}</div>
</div>
</div>
</td>
<td class="mono">{{.IP}}</td>
<td><span class="badge badge-gray">{{.Site}}</span></td>
<td>
{{if eq .Status "online"}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
{{else}}
<span class="badge badge-red"><span class="dot red" style="width:5px;height:5px;margin-right:4px;"></span>Offline</span>
{{end}}
</td>
<td style="padding-right:20px;text-align:right;color:var(--text-3);font-size:12px;">{{.LastSeenFmt}}</td>
</tr>
{{else}}
<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-3);">
Noch keine Geräte — Scan starten.
</td></tr>
{{end}}
</tbody>
</table>
</div>
<!-- Aktive Module -->
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Aktive Module</div>
<div class="card-sub">Status der Scan-Quellen</div>
</div>
<a href="/modules" class="card-action">
Verwalten
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</a>
</div>
{{range $i, $m := .Modules}}
<div class="dash-module-row" style="{{if eq $i 0}}border-top:none;{{end}}">
<div class="dash-module-icon{{if not $m.Enabled}} off{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px;">
{{if eq $m.Name "arp_discovery"}}<path d="M5 12.55a11 11 0 0 1 14 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/>
{{else if eq $m.Name "dns_reverse"}}<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
{{else if eq $m.Name "ad_sync"}}<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
{{else if eq $m.Name "site_monitoring"}}<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
{{else if eq $m.Name "port_scan"}}<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
{{else if eq $m.Name "snmp"}}<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
{{else}}<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/>{{end}}
</svg>
</div>
<div style="flex:1;min-width:0;">
<div class="dash-module-name">{{$m.DisplayName}}</div>
<div class="dash-module-sub">{{if $m.LastRun}}Letzter Lauf {{$m.LastRun}}{{else}}Noch nicht gelaufen{{end}}</div>
</div>
{{if $m.Enabled}}<span class="badge badge-green" style="font-size:11px;flex-shrink:0;">OK</span>
{{else}}<span class="badge badge-gray" style="font-size:11px;flex-shrink:0;">Aus</span>{{end}}
</div>
{{end}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,145 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — Nexus Scanner</title>
<link rel="stylesheet" href="/static/style.css">
<script>
(function(){
var t = localStorage.getItem('nx-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div>
<div class="brand-name">Nexus Scanner</div>
<div class="brand-sub">{{.SiteID}} · {{.Addr}}</div>
</div>
</div>
<div class="sidebar-section">Scanner</div>
<nav>
<a href="/dashboard" class="nav-item{{if eq .Page "dashboard"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
<span>Übersicht</span>
</a>
<a href="/assets" class="nav-item{{if eq .Page "assets"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 9v11"/></svg>
<span>Geräte</span>
<span class="nav-badge">{{.HostCount}}</span>
</a>
<a href="/modules" class="nav-item{{if eq .Page "modules"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
<span>Module</span>
</a>
<a href="/snmp" class="nav-item{{if eq .Page "snmp"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span>SNMP</span>
</a>
<a href="/monitoring" class="nav-item{{if eq .Page "monitoring"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<span>Monitoring</span>
{{if .Scanning}}<span class="nav-dot"></span>{{end}}
</a>
<a href="/ad" class="nav-item{{if eq .Page "ad"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Active Directory</span>
</a>
<a href="/logs" class="nav-item{{if eq .Page "logs"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span>Logs</span>
<span class="nav-dot"></span>
</a>
</nav>
<div class="sidebar-section">System</div>
<nav>
<a href="/settings" class="nav-item{{if eq .Page "settings"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
<span>Einstellungen</span>
</a>
<button class="nav-item" onclick="toggleTheme()">
<svg id="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span id="theme-label">Dunkler Modus</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="avatar">A</div>
<div style="flex:1;min-width:0;">
<div class="user-name">Administrator</div>
<div class="user-sub">Eingeloggt</div>
</div>
<a href="/logout" class="icon-btn" title="Abmelden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
</a>
</div>
</aside>
<!-- Main -->
<div class="main">
<header class="topbar">
<div class="breadcrumb">
<span>Nexus Scanner</span>
<span class="breadcrumb-sep">/</span>
<span class="breadcrumb-current">{{.Title}}</span>
</div>
<div class="topbar-actions">
{{if .Scanning}}
<div class="status-pill scanning"><span class="dot teal" style="animation:dotpulse 1.6s ease-out infinite;"></span> Scan läuft…</div>
{{else}}
<div class="status-pill online"><span class="dot green"></span> Online · {{.SiteID}}</div>
{{end}}
<a href="/logout" class="icon-btn" title="Abmelden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
</a>
</div>
</header>
<main class="content">
{{template "content" .}}
</main>
</div>
</div>
<script>
function toggleTheme() {
var cur = document.documentElement.getAttribute('data-theme') || 'light';
var next = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('nx-theme', next);
updateThemeUI(next);
}
function updateThemeUI(t) {
var icon = document.getElementById('theme-icon');
var label = document.getElementById('theme-label');
if (!icon || !label) return;
if (t === 'dark') {
icon.innerHTML = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>';
label.textContent = 'Heller Modus';
} else {
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
label.textContent = 'Dunkler Modus';
}
}
(function(){
updateThemeUI(document.documentElement.getAttribute('data-theme') || 'light');
})();
</script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,71 @@
{{define "login"}}
<!DOCTYPE html>
<html data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden — Nexus Scanner</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-logo">
<div class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div>
<div class="brand-name">Nexus Scanner</div>
<div class="brand-sub">Netzwerk-Inventar Agent</div>
</div>
</div>
<h1 class="auth-title">Willkommen zurück.</h1>
<p class="auth-sub">Passwort eingeben um fortzufahren.</p>
{{if .Error}}
<div class="error-msg">{{.Error}}</div>
{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label class="input-label" for="password">Passwort</label>
<div class="password-wrap">
<input class="input" type="password" id="password" name="password"
placeholder="••••••••" autocomplete="current-password" autofocus required>
<button type="button" class="eye-btn" onclick="togglePw()" title="Anzeigen">
<svg id="eye-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-full" style="justify-content:center;">
Anmelden
</button>
</form>
<p style="font-size:12px;color:var(--text-3);text-align:center;margin-top:20px;">
Nexus Scanner · Cereda Systems GmbH
</p>
</div>
</div>
<script>
function togglePw() {
const f = document.getElementById('password');
const i = document.getElementById('eye-icon');
if (f.type === 'password') {
f.type = 'text';
i.innerHTML = '<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-6.5 0-10-7-10-7a18.4 18.4 0 0 1 4.06-4.94"/><path d="M9.9 4.24A10 10 0 0 1 12 4c6.5 0 10 7 10 7a18.4 18.4 0 0 1-3.16 4.19"/><path d="m2 2 20 20"/>';
} else {
f.type = 'password';
i.innerHTML = '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
}
}
</script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,57 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Logs</h1>
<p class="page-sub">Strukturierte Ausgabe des Scanners in Echtzeit.</p>
</div>
<div class="page-actions">
<form method="GET" action="/logs" style="display:flex;gap:8px;align-items:center;">
<select class="select" name="level" style="width:auto;" onchange="this.form.submit()">
<option value="">Alle Level</option>
<option value="INFO"{{if eq .LevelFilter "INFO"}} selected{{end}}>INFO</option>
<option value="WARN"{{if eq .LevelFilter "WARN"}} selected{{end}}>WARN</option>
<option value="ERROR"{{if eq .LevelFilter "ERROR"}} selected{{end}}>ERROR</option>
</select>
</form>
<button class="btn" onclick="clearLogs()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Leeren
</button>
</div>
</div>
<div class="log-container" id="log-container"
hx-get="/logs/rows?level={{.LevelFilter}}"
hx-trigger="every 2s"
hx-swap="innerHTML"
hx-target="this">
{{template "log-rows" .}}
</div>
<div style="margin-top:10px;font-size:12px;color:var(--text-3);text-align:right;">
Aktualisiert automatisch alle 2 Sekunden
<span id="log-count"> · {{len .Logs}} Einträge</span>
</div>
<script>
function clearLogs() {
fetch('/logs/clear', {method:'POST'}).then(() => {
document.getElementById('log-container').innerHTML = '';
});
}
</script>
{{end}}
{{define "log-rows"}}
{{range .Logs}}
<div class="log-row">
<span class="log-time">{{.Time}}</span>
<span class="log-level {{.LevelClass}}">{{.Level}}</span>
<span class="log-msg">{{.Message}}</span>
</div>
{{else}}
<div style="padding:48px;text-align:center;color:var(--text-3);font-size:13px;">
Noch keine Log-Einträge.
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,69 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Module</h1>
<p class="page-sub">Scanner-Module aktivieren, deaktivieren und konfigurieren.</p>
</div>
</div>
<div class="card">
{{range .Modules}}
<div class="module-row">
<div class="module-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="module-info">
<div class="module-name">{{.DisplayName}}</div>
<div class="module-desc">{{.Description}} · alle {{.Interval}}</div>
</div>
<div class="module-status">
{{if .LastRun}}
<span style="font-size:12px;color:var(--text-3);">Letzter Lauf: {{.LastRun}}</span>
{{end}}
{{if .Enabled}}
<span class="badge badge-green">Aktiv</span>
{{else}}
<span class="badge badge-gray">Inaktiv</span>
{{end}}
<form method="POST" action="/modules/{{.Name}}/toggle" style="display:inline;">
<label class="toggle" title="{{if .Enabled}}Deaktivieren{{else}}Aktivieren{{end}}">
<input type="checkbox"{{if .Enabled}} checked{{end}} onchange="this.form.submit()">
<span class="toggle-track"></span>
</label>
</form>
</div>
</div>
{{else}}
<div style="padding:48px;text-align:center;color:var(--text-3);">
Keine Module registriert. Konfiguriere Module in config.yaml.
</div>
{{end}}
</div>
<div class="card" style="margin-top:16px;">
<div class="card-header">
<span class="card-title">ARP-Discovery Konfiguration</span>
</div>
<form method="POST" action="/modules/arp_discovery/config" class="setting-body full">
<div class="form-group">
<label class="input-label">Subnetz (CIDR)</label>
{{range .ARPSubnets}}
<input class="input mb-3" type="text" name="subnet" value="{{.}}" placeholder="192.168.0.0/24">
{{else}}
<input class="input mb-3" type="text" name="subnet" value="" placeholder="192.168.0.0/24">
{{end}}
</div>
<div class="form-group">
<label class="input-label">Netzwerk-Interface</label>
<input class="input" type="text" name="interface" value="{{.ARPInterface}}" placeholder="eth0">
</div>
<div>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,158 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Standort-Monitoring</h1>
<p class="page-sub">Erreichbarkeit von Internet, Diensten und Netzwerkgeräten.</p>
</div>
<div style="display:flex;gap:8px;align-items:center;">
{{if .AllOnline}}
<div class="status-pill online"><span class="dot green"></span> Alle Systeme Online</div>
{{else if eq .TotalChecks 0}}
<div class="status-pill"><span class="dot" style="background:var(--text-3);"></span> Noch keine Checks</div>
{{else}}
<div class="status-pill" style="background:rgba(255,59,48,.1);border-color:rgba(255,59,48,.3);color:#ff3b30;">
<span class="dot" style="background:#ff3b30;"></span> Probleme erkannt
</div>
{{end}}
<form method="POST" action="/monitoring/scan" style="margin:0;">
<button type="submit" class="btn btn-primary" style="height:32px;font-size:13px;">Jetzt prüfen</button>
</form>
</div>
</div>
<!-- Stats -->
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:20px;">
<div class="stat">
<div class="stat-label">Checks gesamt</div>
<div class="stat-value">{{.TotalChecks}}</div>
<div class="stat-delta neutral">konfiguriert</div>
</div>
<div class="stat">
<div class="stat-label">Online</div>
<div class="stat-value" style="{{if gt .OnlineCount 0}}color:var(--success);{{end}}">{{.OnlineCount}}</div>
<div class="stat-delta up">erreichbar</div>
</div>
<div class="stat">
<div class="stat-label">Offline</div>
<div class="stat-value" style="{{if gt .OfflineCount 0}}color:#ff3b30;{{end}}">{{.OfflineCount}}</div>
<div class="stat-delta {{if gt .OfflineCount 0}}down{{else}}neutral{{end}}">nicht erreichbar</div>
</div>
</div>
<!-- Checks Table -->
<div class="card" style="margin-bottom:20px;">
{{if .Checks}}
<table>
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Ziel</th>
<th>Status</th>
<th>Latenz</th>
<th style="width:180px;">Verlauf (letzte 20)</th>
<th>Geprüft</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Checks}}
<tr>
<td style="font-weight:600;">{{.Check.Name}}</td>
<td>
<span style="background:var(--bg-2);border:1px solid var(--border);border-radius:4px;padding:1px 7px;font-size:11px;font-family:monospace;text-transform:uppercase;color:var(--text-2);">{{.Check.Type}}</span>
</td>
<td style="color:var(--text-2);font-size:12px;font-family:monospace;">{{.Check.Target}}</td>
<td>
{{if not .HasResult}}
<span class="badge" style="color:var(--text-3);">Ausstehend</span>
{{else if eq .Latest.Status "online"}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;display:inline-block;"></span>Online</span>
{{else}}
<span class="badge" style="background:rgba(255,59,48,.1);border-color:rgba(255,59,48,.3);color:#ff3b30;"><span class="dot" style="background:#ff3b30;width:5px;height:5px;margin-right:4px;display:inline-block;border-radius:50%;"></span>Offline</span>
{{end}}
</td>
<td style="color:var(--text-2);font-size:12px;">
{{if and .HasResult (gt .Latest.LatencyMS 0)}}{{.Latest.LatencyMS}} ms{{else}}—{{end}}
</td>
<td>
<div style="display:flex;gap:2px;align-items:center;">
{{range .History}}
<div style="width:8px;height:20px;border-radius:2px;flex-shrink:0;
background:{{if eq .Status "online"}}var(--success){{else}}#ff3b30{{end}};
opacity:0.8;"
title="{{formatTime .CheckedAt}} · {{.Status}}{{if gt .LatencyMS 0}} · {{.LatencyMS}}ms{{end}}{{if .Error}} · {{.Error}}{{end}}">
</div>
{{end}}
{{if eq (len .History) 0}}
<span style="color:var(--text-3);font-size:11px;">noch keine Daten</span>
{{end}}
</div>
</td>
<td style="color:var(--text-3);font-size:12px;">
{{if .HasResult}}{{formatTime .Latest.CheckedAt}}{{else}}—{{end}}
</td>
<td>
<form method="POST" action="/monitoring/checks/{{.Check.ID}}/delete" style="margin:0;">
<button type="submit" class="icon-btn" title="Löschen"
onclick="return confirm('Check \'{{.Check.Name}}\' löschen?')"
style="color:var(--text-3);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div style="text-align:center;padding:48px;color:var(--text-3);">
<div style="font-size:32px;margin-bottom:8px;">📡</div>
<div style="font-weight:600;margin-bottom:4px;">Noch keine Checks konfiguriert</div>
<div style="font-size:13px;">Füge unten deinen ersten Check hinzu.</div>
</div>
{{end}}
</div>
<!-- Add Check Form -->
<div class="card" style="margin-top:4px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;font-size:14px;color:var(--text-1);">Check hinzufügen</div>
<div style="font-size:12px;color:var(--text-3);margin-top:2px;">Jeder Standort prüft unabhängig — Ping, HTTP oder TCP Port.</div>
</div>
</div>
{{if .AddErr}}<div class="error-msg" style="margin-bottom:14px;">{{.AddErr}}</div>{{end}}
<form method="POST" action="/monitoring/checks/add">
<div style="display:grid;grid-template-columns:1fr 110px 1fr;gap:12px;margin-bottom:14px;">
<div>
<label class="input-label">Name</label>
<input class="input" type="text" name="name" placeholder="z.B. Internet (Google)" required>
</div>
<div>
<label class="input-label">Typ</label>
<select class="select" name="type" style="width:100%;">
<option value="ping">Ping</option>
<option value="http">HTTP/S</option>
<option value="tcp">TCP Port</option>
</select>
</div>
<div>
<label class="input-label">Ziel</label>
<input class="input" type="text" name="target"
placeholder="8.8.8.8 · https://… · host:443" required>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;color:var(--text-3);">
<span style="color:var(--text-2);font-weight:600;">Ping:</span> IP-Adresse &nbsp;&nbsp;
<span style="color:var(--text-2);font-weight:600;">HTTP/S:</span> https://hostname &nbsp;&nbsp;
<span style="color:var(--text-2);font-weight:600;">TCP:</span> hostname:port
</span>
<button type="submit" class="btn btn-primary">+ Hinzufügen</button>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,252 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Einstellungen</h1>
<p class="page-sub">Standort, IT Nexus API und Scanner-Optionen.</p>
</div>
</div>
{{if .Saved}}<div class="success-msg" style="margin-bottom:16px;">Einstellungen gespeichert.</div>{{end}}
{{if .SaveErr}}<div class="error-msg" style="margin-bottom:16px;">{{.SaveErr}}</div>{{end}}
<form method="POST" action="/settings">
<!-- Standort -->
<div class="settings-section">
<div class="settings-title">Standort</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">Standort-ID</div>
<div class="setting-sub">Wird in allen gemeldeten Assets verwendet.</div>
</div>
<div class="setting-control">
<select class="select" name="site" style="width:200px;">
<option value="LUD"{{if eq .Site "LUD"}} selected{{end}}>Lüdenscheid (LUD)</option>
<option value="BAR"{{if eq .Site "BAR"}} selected{{end}}>Barleben (BAR)</option>
</select>
</div>
</div>
</div>
</div>
<!-- IT Nexus API -->
<div class="settings-section">
<div class="settings-title">IT Nexus Integration</div>
<div class="card">
<div class="setting-body full">
<div class="form-group">
<label class="input-label">API URL</label>
<input class="input" type="url" name="nexus_url" value="{{.NexusURL}}"
placeholder="https://it-nexus.cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">API Key</label>
<div class="password-wrap">
<input class="input" type="password" id="api-key" name="nexus_api_key" value="{{.NexusAPIKey}}"
placeholder="itx-…">
<button type="button" class="eye-btn" onclick="toggleApiKey()" title="Anzeigen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scanner -->
<div class="settings-section">
<div class="settings-title">Scanner-Optionen</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">Scan-Intervall</div>
<div class="setting-sub">Wie oft der ARP-Scan automatisch ausgeführt wird.</div>
</div>
<div class="setting-control">
<select class="select" name="arp_interval" style="width:160px;">
<option value="1m"{{if eq .ARPInterval "1m"}} selected{{end}}>1 Minute</option>
<option value="5m"{{if eq .ARPInterval "5m"}} selected{{end}}>5 Minuten</option>
<option value="15m"{{if eq .ARPInterval "15m"}} selected{{end}}>15 Minuten</option>
<option value="1h"{{if eq .ARPInterval "1h"}} selected{{end}}>1 Stunde</option>
</select>
</div>
</div>
</div>
</div>
<!-- Scanner-Verhalten -->
<div class="settings-section">
<div class="settings-title">Scanner-Verhalten</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">Offline-Schwellwert</div>
<div class="setting-sub">Host gilt als Offline wenn er seit dieser Zeit nicht mehr gesehen wurde.</div>
</div>
<div class="setting-control">
<select class="select" name="offline_after" style="width:140px;">
<option value="5m"{{if eq .OfflineAfter "5m0s"}} selected{{end}}>5 Minuten</option>
<option value="10m"{{if eq .OfflineAfter "10m0s"}} selected{{end}}>10 Minuten</option>
<option value="15m"{{if eq .OfflineAfter "15m0s"}} selected{{end}}>15 Minuten</option>
<option value="30m"{{if eq .OfflineAfter "30m0s"}} selected{{end}}>30 Minuten</option>
<option value="1h"{{if eq .OfflineAfter "1h0m0s"}} selected{{end}}>1 Stunde</option>
</select>
</div>
</div>
</div>
</div>
<!-- Active Directory -->
<div class="settings-section">
<div class="settings-title">Active Directory</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">AD Sync aktivieren</div>
<div class="setting-sub">Computer-Objekte aus winkel.local importieren.</div>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" name="ad_enabled"{{if .ADEnabled}} checked{{end}}>
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="setting-body">
<div class="form-group">
<label class="input-label">Domain Controller (IP oder Hostname)</label>
<input class="input" type="text" name="ad_server" value="{{.ADServer}}" placeholder="192.168.0.12">
</div>
<div class="form-group">
<label class="input-label">Port</label>
<input class="input" type="number" name="ad_port" value="{{.ADPort}}" placeholder="389">
</div>
<div class="form-group">
<label class="input-label">Bind-DN (Service Account)</label>
<input class="input" type="text" name="ad_bind_dn" value="{{.ADBindDN}}" placeholder="svc-scanner@winkel.local">
</div>
<div class="form-group">
<label class="input-label">Passwort</label>
<div class="password-wrap">
<input class="input" type="password" id="ad-pw" name="ad_bind_password" value="{{.ADBindPW}}" placeholder="Service-Account Passwort">
<button type="button" class="eye-btn" onclick="toggleAdPw()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<div class="form-group">
<label class="input-label">Search Base</label>
<input class="input" type="text" name="ad_search_base" value="{{.ADSearchBase}}" placeholder="DC=winkel,DC=local">
</div>
<div class="form-group">
<label class="input-label">Sync-Intervall</label>
<select class="select" name="ad_interval">
<option value="15m"{{if eq .ADInterval "15m0s"}} selected{{end}}>15 Minuten</option>
<option value="30m"{{if eq .ADInterval "30m0s"}} selected{{end}}>30 Minuten</option>
<option value="1h"{{if eq .ADInterval "1h0m0s"}} selected{{end}}>1 Stunde</option>
<option value="6h"{{if eq .ADInterval "6h0m0s"}} selected{{end}}>6 Stunden</option>
</select>
</div>
</div>
</div>
</div>
<!-- Alerts -->
<div class="settings-section">
<div class="settings-title">Benachrichtigungen</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">IT Nexus Alert</div>
<div class="setting-sub">Bei Ausfall an IT Nexus API melden (POST /api/scanner/alert).</div>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" name="alert_nexus"{{if .AlertNexus}} checked{{end}}>
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">E-Mail (SMTP)</div>
<div class="setting-sub">E-Mail bei Status-Wechsel senden.</div>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" name="smtp_enabled"{{if .SMTPEnabled}} checked{{end}}>
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="setting-body" style="grid-template-columns:1fr 1fr;">
<div class="form-group">
<label class="input-label">SMTP-Host</label>
<input class="input" type="text" name="smtp_host" value="{{.SMTPHost}}" placeholder="smtp.office365.com">
</div>
<div class="form-group">
<label class="input-label">Port</label>
<input class="input" type="number" name="smtp_port" value="{{.SMTPPort}}" placeholder="587">
</div>
<div class="form-group">
<label class="input-label">Benutzername</label>
<input class="input" type="text" name="smtp_user" value="{{.SMTPUser}}" placeholder="scanner@cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">Passwort</label>
<div class="password-wrap">
<input class="input" type="password" name="smtp_pass" value="{{.SMTPPass}}" placeholder="SMTP-Passwort">
<button type="button" class="eye-btn" onclick="this.previousElementSibling.type=this.previousElementSibling.type==='password'?'text':'password'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="form-group">
<label class="input-label">Absender (From)</label>
<input class="input" type="email" name="smtp_from" value="{{.SMTPFrom}}" placeholder="nexus-scanner@cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">Empfänger (To)</label>
<input class="input" type="email" name="smtp_to" value="{{.SMTPTo}}" placeholder="it@cereda-systems.de">
</div>
</div>
</div>
</div>
<!-- Passwort -->
<div class="settings-section">
<div class="settings-title">Passwort ändern</div>
<div class="card">
<div class="setting-body">
<div class="form-group">
<label class="input-label">Neues Passwort</label>
<input class="input" type="password" name="new_password" placeholder="Mindestens 6 Zeichen" autocomplete="new-password">
</div>
<div class="form-group">
<label class="input-label">Bestätigung</label>
<input class="input" type="password" name="confirm_password" placeholder="Passwort wiederholen" autocomplete="new-password">
</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;">
<button type="reset" class="btn">Zurücksetzen</button>
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
</div>
</form>
<script>
function togglePw(id) {
const f = document.getElementById(id);
f.type = f.type === 'password' ? 'text' : 'password';
}
function toggleApiKey() { togglePw('api-key'); }
function toggleAdPw() { togglePw('ad-pw'); }
</script>
{{end}}

View File

@@ -0,0 +1,219 @@
{{define "setup"}}
<!DOCTYPE html>
<html data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichtung — Nexus Scanner</title>
<link rel="stylesheet" href="/static/style.css">
<script>(function(){ var t=localStorage.getItem('nx-theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
</head>
<body>
<div class="wizard-wrap">
<div class="wizard">
<!-- Header -->
<div class="wizard-header">
<div style="display:flex;align-items:center;gap:10px;">
<div class="brand-mark" style="width:28px;height:28px;border-radius:7px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px;color:#fff;"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Nexus Scanner</span>
</div>
<div class="wizard-steps">
{{range $i, $_ := .Steps}}
<div class="wizard-step{{if lt $i $.Step}} done{{else if eq $i $.Step}} active{{end}}"></div>
{{end}}
</div>
</div>
{{if eq .Step 0}}
<!-- Schritt 1: Standort -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<h2 class="wizard-title">Willkommen beim Nexus Scanner.</h2>
<p class="wizard-sub">Wähle zuerst deinen Standort.</p>
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="0">
<div class="site-cards">
<label class="site-card{{if eq .Site "LUD"}} selected{{end}}" onclick="sel(this,'LUD')">
<input type="radio" name="site" value="LUD" style="display:none;"{{if eq .Site "LUD"}} checked{{end}}>
<div class="site-card-name">Lüdenscheid</div>
<div class="site-card-sub">LUD · 192.168.0.0/24</div>
</label>
<label class="site-card{{if eq .Site "BAR"}} selected{{end}}" onclick="sel(this,'BAR')">
<input type="radio" name="site" value="BAR" style="display:none;"{{if eq .Site "BAR"}} checked{{end}}>
<div class="site-card-name">Barleben</div>
<div class="site-card-sub">BAR · 192.168.10.0/24</div>
</label>
</div>
</form>
</div>
<div class="wizard-footer">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 1 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="1" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
{{else if eq .Step 1}}
<!-- Schritt 2: Netzwerk -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<h2 class="wizard-title">Netzwerk konfigurieren.</h2>
<p class="wizard-sub">Welches Subnetz soll gescannt werden?</p>
{{if .Error}}<div class="error-msg" style="margin-bottom:14px;">{{.Error}}</div>{{end}}
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="1">
<input type="hidden" name="site" value="{{.Site}}">
<div class="form-group">
<label class="input-label">Subnetz (CIDR)</label>
<input class="input" type="text" name="subnet" value="{{.Subnet}}" placeholder="192.168.0.0/24" required autofocus>
<p style="font-size:12px;color:var(--text-3);margin-top:5px;">Beispiel: 192.168.0.0/24 scannt 254 Adressen.</p>
</div>
<div class="form-group">
<label class="input-label">Netzwerk-Interface</label>
<select class="select" name="interface">
{{range .Interfaces}}
<option value="{{.}}"{{if eq . $.Interface}} selected{{end}}>{{.}}</option>
{{end}}
{{if not .Interfaces}}<option value="eth0">eth0</option>{{end}}
</select>
<p style="font-size:12px;color:var(--text-3);margin-top:5px;">Automatisch erkannt — wähle das Interface zum LAN.</p>
</div>
</form>
</div>
<div class="wizard-footer">
<a href="/setup?step=0" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Zurück
</a>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 2 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="2" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
{{else if eq .Step 2}}
<!-- Schritt 3: IT Nexus API -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<h2 class="wizard-title">IT Nexus verbinden.</h2>
<p class="wizard-sub">Optional — kann auch später konfiguriert werden.</p>
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="2">
<input type="hidden" name="site" value="{{.Site}}">
<input type="hidden" name="subnet" value="{{.Subnet}}">
<input type="hidden" name="interface" value="{{.Interface}}">
<div class="form-group">
<label class="input-label">IT Nexus API URL</label>
<input class="input" type="url" name="nexus_url" value="{{.NexusURL}}" placeholder="https://it-nexus.cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">API Key</label>
<input class="input" type="password" name="nexus_api_key" value="{{.NexusAPIKey}}" placeholder="nsx-…">
</div>
</form>
</div>
<div class="wizard-footer">
<a href="/setup?step=1" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Zurück
</a>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 3 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="3" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
{{else if eq .Step 3}}
<!-- Schritt 4: Passwort -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h2 class="wizard-title">Passwort festlegen.</h2>
<p class="wizard-sub">Mindestens 6 Zeichen für den Web-Login.</p>
{{if .Error}}<div class="error-msg" style="margin-bottom:14px;">{{.Error}}</div>{{end}}
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="3">
<input type="hidden" name="site" value="{{.Site}}">
<input type="hidden" name="subnet" value="{{.Subnet}}">
<input type="hidden" name="interface" value="{{.Interface}}">
<input type="hidden" name="nexus_url" value="{{.NexusURL}}">
<input type="hidden" name="nexus_api_key" value="{{.NexusAPIKey}}">
<div class="form-group">
<label class="input-label">Passwort</label>
<input class="input" type="password" name="password" placeholder="Mindestens 6 Zeichen" minlength="6" required autofocus>
</div>
<div class="form-group">
<label class="input-label">Bestätigung</label>
<input class="input" type="password" name="confirm" placeholder="Passwort wiederholen" required>
</div>
</form>
</div>
<div class="wizard-footer">
<a href="/setup?step=2" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Zurück
</a>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 4 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="4" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
{{else}}
<!-- Schritt 5: Fertig -->
<div class="wizard-body">
<div class="wizard-icon" style="background:rgba(52,168,83,.12);color:var(--success);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h2 class="wizard-title">Alles bereit.</h2>
<p class="wizard-sub">Der Scanner ist konfiguriert und startet jetzt.</p>
<div style="display:flex;flex-direction:column;gap:10px;font-size:13px;margin-top:16px;">
<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-teal">{{.Site}}</span><span style="color:var(--text-2);">Standort</span></div>
<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-green">{{.Subnet}}</span><span style="color:var(--text-2);">Subnetz</span></div>
{{if .NexusURL}}<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-green">Verbunden</span><span style="color:var(--text-2);">IT Nexus API</span></div>{{end}}
<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-green"></span><span style="color:var(--text-2);">Passwort gesetzt</span></div>
</div>
<form method="POST" action="/setup/complete" id="f">
<input type="hidden" name="step" value="4">
<input type="hidden" name="site" value="{{.Site}}">
<input type="hidden" name="subnet" value="{{.Subnet}}">
<input type="hidden" name="interface" value="{{.Interface}}">
<input type="hidden" name="nexus_url" value="{{.NexusURL}}">
<input type="hidden" name="nexus_api_key" value="{{.NexusAPIKey}}">
<input type="hidden" name="password" value="{{.Password}}">
</form>
</div>
<div class="wizard-footer" style="justify-content:center;">
<button form="f" type="submit" class="btn btn-primary" style="width:100%;justify-content:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
Scanner starten
</button>
</div>
{{end}}
</div><!-- .wizard -->
</div><!-- .wizard-wrap -->
<script>
function sel(card, site) {
document.querySelectorAll('.site-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
card.querySelector('input[type=radio]').checked = true;
}
</script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,232 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">SNMP</h1>
<p class="page-sub">Geräte-Informationen via SNMP — Drucker, Switches, USVs.</p>
</div>
</div>
{{if .AddErr}}<div class="error-msg" style="margin-bottom:16px;">{{.AddErr}}</div>{{end}}
{{if .Targets}}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;margin-bottom:24px;">
{{range .Targets}}
<div class="card" style="overflow:visible;">
<!-- Card Header -->
<div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:32px;height:32px;border-radius:var(--r-sm);background:var(--teal-50);color:var(--teal);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
{{if eq .Target.Type "printer"}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
{{else if eq .Target.Type "switch"}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 12h.01M10 12h.01M14 12h.01M18 12h.01"/></svg>
{{else}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6M9 12h6M9 15h4"/></svg>
{{end}}
</div>
<div>
<div style="font-weight:600;font-size:14px;">{{.Target.Name}}</div>
<div style="font-size:12px;color:var(--text-3);font-family:monospace;">{{.Target.IP}}</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
{{if .HasData}}
<span class="badge badge-green" style="font-size:11px;">Online</span>
{{else}}
<span class="badge badge-gray" style="font-size:11px;">Kein Datum</span>
{{end}}
<form method="POST" action="/snmp/targets/{{.Target.ID}}/delete" style="margin:0;">
<button type="submit" class="icon-btn" onclick="return confirm('{{.Target.Name}} löschen?')" style="color:var(--text-3);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</form>
</div>
</div>
<!-- Card Body -->
<div style="padding:16px 20px;">
{{if .HasData}}
{{if .SysName}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Name</span><div style="font-size:13px;color:var(--text-1);margin-top:2px;">{{.SysName}}</div></div>{{end}}
{{if .SysDescr}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Beschreibung</span><div style="font-size:12px;color:var(--text-2);margin-top:2px;">{{.SysDescr}}</div></div>{{end}}
{{if .SysUpTime}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Uptime</span><div style="font-size:13px;color:var(--text-2);margin-top:2px;">{{.SysUpTime}}</div></div>{{end}}
{{if .SysLocation}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Standort</span><div style="font-size:13px;color:var(--text-2);margin-top:2px;">{{.SysLocation}}</div></div>{{end}}
{{if eq .Target.Type "switch"}}
<!-- Switch Stats -->
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
{{if .PortsTotal}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">Ports</div>
<div style="font-size:20px;font-weight:600;color:var(--text-1);">{{.PortsUp}}<span style="font-size:13px;color:var(--text-3);font-weight:400;"> / {{.PortsTotal}}</span></div>
<div style="font-size:11px;color:var(--success);margin-top:2px;">● aktiv</div>
</div>
{{end}}
{{if .TrafficIn}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">Traffic</div>
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">↓ {{.TrafficIn}}</div>
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">↑ {{.TrafficOut}}</div>
</div>
{{end}}
{{if .MemTotal}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">RAM</div>
<div style="font-size:13px;color:var(--text-1);">{{.MemUsed}} / {{.MemTotal}}</div>
<div style="background:var(--surface-3);border-radius:3px;height:5px;margin-top:5px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{.MemPct}}%;background:{{if gt .MemPct 85}}var(--danger){{else if gt .MemPct 70}}var(--warning){{else}}var(--teal){{end}};"></div>
</div>
</div>
{{end}}
{{if .CpuLoad}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">CPU</div>
<div style="font-size:20px;font-weight:600;color:var(--text-1);">{{.CpuLoad}}<span style="font-size:13px;font-weight:400;color:var(--text-3);">%</span></div>
<div style="background:var(--surface-3);border-radius:3px;height:5px;margin-top:5px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{.CpuLoad}}%;background:var(--teal);"></div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if eq .Target.Type "printer"}}
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">
{{if .PrinterStatus}}
<div style="display:inline-flex;align-items:center;gap:5px;margin-bottom:12px;background:{{if eq .PrinterStatus "Druckt"}}rgba(0,132,135,.1){{else}}var(--surface-2){{end}};padding:4px 10px;border-radius:20px;">
<span style="width:6px;height:6px;border-radius:50%;background:{{if eq .PrinterStatus "Druckt"}}var(--teal){{else}}var(--success){{end}};display:inline-block;"></span>
<span style="font-size:12px;font-weight:500;color:var(--text-2);">{{.PrinterStatus}}</span>
</div>
{{end}}
{{if .TonerLevel}}
<div style="margin-bottom:4px;display:flex;justify-content:space-between;align-items:baseline;">
<span style="font-size:12px;font-weight:600;color:var(--text-2);">Toner</span>
<span style="font-size:13px;font-weight:600;color:{{if lt .TonerPct 20}}#ff3b30{{else if lt .TonerPct 40}}var(--warning){{else}}var(--text-1){{end}};">{{.TonerPct}}%</span>
</div>
<div style="background:var(--surface-3);border-radius:6px;height:10px;overflow:hidden;">
<div style="height:100%;border-radius:6px;width:{{.TonerPct}}%;background:{{if lt .TonerPct 20}}#ff3b30{{else if lt .TonerPct 40}}#ff9500{{else}}var(--success){{end}};transition:width .4s;"></div>
</div>
{{if lt .TonerPct 20}}<div style="font-size:11px;color:#ff3b30;margin-top:5px;font-weight:500;">⚠ Toner bald leer — bitte nachbestellen</div>{{end}}
{{end}}
{{if .PageCount}}
<div style="display:flex;align-items:center;gap:6px;margin-top:12px;padding-top:10px;border-top:1px solid var(--border);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;color:var(--text-3);flex-shrink:0;"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
<span style="font-size:12px;color:var(--text-3);">Gesamtseiten: <strong style="color:var(--text-2);">{{.PageCount}}</strong></span>
</div>
{{end}}
</div>
{{end}}
{{else}}
<div style="color:var(--text-3);font-size:13px;text-align:center;padding:16px 0;">Noch kein Scan — läuft alle {{.Interval}}.</div>
{{end}}
</div>
<!-- Card Footer -->
<div style="padding:8px 20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:11px;color:var(--text-3);">Community: <code style="background:var(--surface-3);padding:1px 5px;border-radius:3px;">{{.Target.Community}}</code> · {{.Target.Version}}</span>
<div style="display:flex;gap:8px;align-items:center;">
{{if .HasData}}
<button onclick="openDetail('modal-{{.Target.ID}}')" class="btn" style="font-size:11px;padding:4px 10px;height:auto;">
Details
</button>
{{end}}
<span class="badge badge-gray" style="font-size:11px;text-transform:capitalize;">{{.Target.Type}}</span>
</div>
</div>
</div>
<!-- Detail Modal -->
{{if .HasData}}
<div id="modal-{{.Target.ID}}" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.45);backdrop-filter:blur(2px);" onclick="if(event.target===this)closeDetail('modal-{{.Target.ID}}')">
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:min(560px,95vw);max-height:85vh;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);box-shadow:0 8px 40px rgba(0,0,0,.25);">
<div style="padding:18px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:var(--surface);z-index:1;">
<div>
<div style="font-weight:600;font-size:15px;">{{.Target.Name}}</div>
<div style="font-size:12px;color:var(--text-3);font-family:monospace;margin-top:2px;">{{.Target.IP}} · {{.Target.Type}}</div>
</div>
<button onclick="closeDetail('modal-{{.Target.ID}}')" class="icon-btn" style="color:var(--text-3);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px;"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div style="padding:20px 22px;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr>
<th style="text-align:left;font-size:11px;font-weight:600;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;padding:0 0 10px;border-bottom:1px solid var(--border);width:40%;">Feld</th>
<th style="text-align:left;font-size:11px;font-weight:600;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;padding:0 0 10px 16px;border-bottom:1px solid var(--border);">Wert</th>
</tr>
</thead>
<tbody>
{{range $k, $v := .AllValues}}
<tr>
<td style="padding:9px 0;border-bottom:1px solid var(--border);color:var(--text-3);font-size:12px;vertical-align:top;font-weight:500;">{{$k}}</td>
<td style="padding:9px 0 9px 16px;border-bottom:1px solid var(--border);color:var(--text-1);font-family:{{if or (eq $k "sysDescr") (eq $k "sysName") (eq $k "sysLocation") (eq $k "sysContact")}}inherit{{else}}monospace{{end}};font-size:13px;word-break:break-all;">{{$v}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="card" style="margin-bottom:24px;">
<div style="text-align:center;padding:48px;color:var(--text-3);">
<div style="font-size:32px;margin-bottom:8px;">📡</div>
<div style="font-weight:600;margin-bottom:4px;">Noch keine SNMP-Geräte</div>
<div style="font-size:13px;">Füge unten dein erstes Gerät hinzu.</div>
</div>
</div>
{{end}}
<!-- Add Target Form -->
<div class="card">
<div style="padding:16px 20px;border-bottom:1px solid var(--border);">
<div style="font-weight:600;font-size:14px;">SNMP-Gerät hinzufügen</div>
<div style="font-size:12px;color:var(--text-3);margin-top:2px;">Drucker (Kyocera), Switches, USVs — Community string "public" ist Standard.</div>
</div>
<form method="POST" action="/snmp/targets/add" style="padding:16px 20px;">
<div style="display:grid;grid-template-columns:1fr 160px 110px 110px 110px;gap:12px;margin-bottom:14px;">
<div>
<label class="input-label">Name</label>
<input class="input" type="text" name="name" placeholder="z.B. Kyocera EcoSys" required>
</div>
<div>
<label class="input-label">IP-Adresse</label>
<input class="input" type="text" name="ip" placeholder="192.168.0.50" required>
</div>
<div>
<label class="input-label">Community</label>
<input class="input" type="text" name="community" placeholder="public" value="public">
</div>
<div>
<label class="input-label">Version</label>
<select class="select" name="version" style="width:100%;">
<option value="v2c">SNMPv2c</option>
<option value="v1">SNMPv1</option>
</select>
</div>
<div>
<label class="input-label">Typ</label>
<select class="select" name="type" style="width:100%;">
<option value="generic">Allgemein</option>
<option value="printer">Drucker</option>
<option value="switch">Switch</option>
</select>
</div>
</div>
<div style="display:flex;justify-content:flex-end;">
<button type="submit" class="btn btn-primary">+ Gerät hinzufügen</button>
</div>
</form>
</div>
<script>
function openDetail(id) { document.getElementById(id).style.display = 'block'; document.body.style.overflow = 'hidden'; }
function closeDetail(id) { document.getElementById(id).style.display = 'none'; document.body.style.overflow = ''; }
document.addEventListener('keydown', function(e) { if(e.key === 'Escape') { document.querySelectorAll('[id^="modal-"]').forEach(m => { m.style.display='none'; }); document.body.style.overflow=''; } });
</script>
{{end}}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,32 @@
[Unit]
Description=Nexus Network Scanner Agent
Documentation=https://it-nexus.cereda-systems.de
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
Restart=on-failure
RestartSec=10s
User=nexus-scanner
Group=nexus-scanner
# Allow raw socket access for ARP (runs as non-root).
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/nexus-scanner
# Logging — captured by journald.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nexus-scanner
[Install]
WantedBy=multi-user.target