diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..6154133
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+# Copy to .env and adjust
+NTFYWUI_SECRET=REPLACE_ME_WITH_BASE64_SECRET
+NTFYWUI_BOOTSTRAP_USER=admin
+NTFYWUI_BOOTSTRAP_PASS=change-me-now
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d81031a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+# syntax=docker/dockerfile:1
+
+# Build Go binary
+FROM golang:1.22-alpine AS builder
+WORKDIR /src
+RUN apk add --no-cache ca-certificates tzdata
+COPY go.mod ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ntfywui ./cmd/ntfywui
+
+# Grab ntfy CLI binary from official image
+FROM binwiederhier/ntfy:latest AS ntfy
+
+# Runtime
+FROM alpine:3.20
+RUN apk add --no-cache ca-certificates tzdata \
+ && addgroup -S ntfywui && adduser -S -G ntfywui -h /home/ntfywui ntfywui
+WORKDIR /app
+COPY --from=builder /out/ntfywui /usr/local/bin/ntfywui
+COPY --from=ntfy /usr/bin/ntfy /usr/bin/ntfy
+USER ntfywui
+EXPOSE 8080
+ENV NTFYWUI_LISTEN=:8080 \
+ NTFYWUI_DATA_DIR=/data \
+ NTFYWUI_NTFY_BIN=/usr/bin/ntfy \
+ NTFYWUI_NTFY_CONFIG=/etc/ntfy/server.yml \
+ NTFYWUI_COOKIE_SECURE=true
+VOLUME ["/data"]
+ENTRYPOINT ["/usr/local/bin/ntfywui"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..14fac91
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index fa69ef6..add4c1b 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,69 @@
-# ntfywui
+# ntfywui (Go) – Webinterface für ntfy User/ACL/Token Verwaltung
+Dieses Projekt stellt ein **sicheres Webinterface** bereit, um die ntfy **CLI-Administration** (Users, ACL/Access, Tokens) bequem im Browser zu machen.
+
+- **Golang, Standardbibliothek-only** (keine externen deps)
+- Arbeitet **gegen die lokale ntfy Auth-DB** über die ntfy CLI (im Container enthalten)
+- **CSRF-Schutz**, **Secure Headers (CSP)**, **verschlüsselte Sessions (AES-GCM)**, **Rate-Limit**
+- **WebUI-Admins** separat von ntfy-Users (inkl. optionalem TOTP-2FA)
+- **Audit Log** (JSONL)
+
+## Quickstart (Docker Compose)
+
+1) `.env` anlegen:
+
+```bash
+cp .env.example .env
+# SECRET erzeugen (Beispiel): openssl rand -base64 48
+```
+
+2) Stelle sicher, dass `/etc/ntfy/server.yml` Authentication aktiv hat, z.B.:
+
+```yaml
+auth-file: "/var/lib/ntfy/user.db"
+auth-default-access: "deny-all"
+```
+
+3) Start:
+
+```bash
+docker compose up -d --build
+```
+
+- ntfy: http://localhost:8080
+- ntfywui: http://localhost:8090
+
+## Wichtige Hinweise (Sicherheit)
+
+- **NTFYWUI_SECRET ist Pflicht** (>=32 Bytes; base64 empfohlen).
+- Setze `NTFYWUI_COOKIE_SECURE=true` (Default). Für reines HTTP-Lab ggf. `false`.
+- Wenn du einen Reverse Proxy davor hast, setze `NTFYWUI_TRUST_PROXY` nur auf **deine Proxy-CIDRs**,
+ damit `X-Forwarded-For` nicht spoofbar ist.
+- Vergib WebUI-Admin-Rechte sparsam:
+ - `viewer`: read-only (Users anzeigen)
+ - `operator`: Users/ACL/Tokens verwalten
+ - `admin`: zusätzlich Admins/Audit verwalten
+
+## Konfiguration (Env/Flags)
+
+- `NTFYWUI_LISTEN` (Default `:8080`)
+- `NTFYWUI_BASE_PATH` (z.B. `/ntfywui`)
+- `NTFYWUI_DATA_DIR` (Default `/data`) – `admins.json`, `audit.jsonl`
+- `NTFYWUI_SECRET` (required)
+- `NTFYWUI_COOKIE_SECURE` (Default `true`)
+- `NTFYWUI_TRUST_PROXY` (CIDRs, z.B. `10.0.0.0/8,172.16.0.0/12`)
+- `NTFYWUI_NTFY_BIN` (Default `/usr/bin/ntfy`)
+- `NTFYWUI_NTFY_CONFIG` (Default `/etc/ntfy/server.yml`)
+- `NTFYWUI_NTFY_TIMEOUT` (Default `10s`)
+- Bootstrap:
+ - `NTFYWUI_BOOTSTRAP_USER`
+ - `NTFYWUI_BOOTSTRAP_PASS`
+
+## Limitierungen
+
+- Das UI parst die Textausgabe von `ntfy user list` / `ntfy token list` best-effort.
+- Für sehr große Auth-DBs kann `Audit Tail` (einfaches File-Read) angepasst werden.
+
+## Lizenz
+
+MIT – mach damit was du willst.
diff --git a/cmd/ntfywui/main.go b/cmd/ntfywui/main.go
new file mode 100644
index 0000000..5f7b837
--- /dev/null
+++ b/cmd/ntfywui/main.go
@@ -0,0 +1,182 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/yourorg/ntfywui/internal/app"
+ "github.com/yourorg/ntfywui/internal/security"
+)
+
+func main() {
+ var (
+ listenAddr = flag.String("listen", envOr("NTFYWUI_LISTEN", ":8080"), "HTTP listen address")
+ basePath = flag.String("base-path", envOr("NTFYWUI_BASE_PATH", ""), "Base path prefix, e.g. /ntfywui (no trailing slash)")
+ dataDir = flag.String("data-dir", envOr("NTFYWUI_DATA_DIR", "/data"), "Data dir for admin store and audit log")
+ secret = flag.String("secret", envOr("NTFYWUI_SECRET", ""), "Secret key (base64 or raw) for sessions/CSRF/HMAC (required)")
+ cookieSecure = flag.Bool("cookie-secure", envOrBool("NTFYWUI_COOKIE_SECURE", true), "Set Secure cookies (recommended behind HTTPS)")
+ trustProxy = flag.String("trust-proxy", envOr("NTFYWUI_TRUST_PROXY", ""), "Comma-separated CIDRs of trusted reverse proxies for X-Forwarded-For")
+ ntfyBin = flag.String("ntfy-bin", envOr("NTFYWUI_NTFY_BIN", "/usr/bin/ntfy"), "Path to ntfy binary")
+ ntfyConfig = flag.String("ntfy-config", envOr("NTFYWUI_NTFY_CONFIG", "/etc/ntfy/server.yml"), "Path to ntfy server.yml (mounted read-only)")
+ reqTimeout = flag.Duration("ntfy-timeout", envOrDur("NTFYWUI_NTFY_TIMEOUT", 10*time.Second), "Timeout for ntfy CLI calls")
+ )
+ flag.Parse()
+
+ if *secret == "" {
+ log.Fatal("NTFYWUI_SECRET is required (>=32 random bytes; base64 recommended)")
+ }
+ secKey, err := decodeSecret(*secret)
+ if err != nil {
+ log.Fatalf("invalid secret: %v", err)
+ }
+ if len(secKey) < 32 {
+ log.Fatalf("secret too short: need at least 32 bytes, got %d", len(secKey))
+ }
+
+ var trusted []*net.IPNet
+ if strings.TrimSpace(*trustProxy) != "" {
+ for _, cidr := range strings.Split(*trustProxy, ",") {
+ cidr = strings.TrimSpace(cidr)
+ if cidr == "" {
+ continue
+ }
+ _, n, err := net.ParseCIDR(cidr)
+ if err != nil {
+ log.Fatalf("invalid trust-proxy CIDR %q: %v", cidr, err)
+ }
+ trusted = append(trusted, n)
+ }
+ }
+
+ logger := log.New(os.Stdout, "", log.LstdFlags)
+
+ s := app.NewServer(app.Config{
+ BasePath: strings.TrimRight(*basePath, "/"),
+ DataDir: *dataDir,
+ Secret: secKey,
+ CookieSecure: *cookieSecure,
+ TrustedProxies: trusted,
+ NtfyBin: *ntfyBin,
+ NtfyConfig: *ntfyConfig,
+ NtfyTimeout: *reqTimeout,
+ Logger: logger,
+ })
+
+ httpSrv := &http.Server{
+ Addr: *listenAddr,
+ Handler: s.Handler(),
+ ReadTimeout: 15 * time.Second,
+ ReadHeaderTimeout: 10 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ MaxHeaderBytes: 1 << 20,
+ }
+
+ go func() {
+ logger.Printf("ntfywui listening on %s%s", *listenAddr, s.BasePath())
+ if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.Fatalf("listen: %v", err)
+ }
+ }()
+
+ // Optional bootstrap admin
+ if u := os.Getenv("NTFYWUI_BOOTSTRAP_USER"); u != "" {
+ p := os.Getenv("NTFYWUI_BOOTSTRAP_PASS")
+ if p == "" {
+ logger.Printf("bootstrap user provided but NTFYWUI_BOOTSTRAP_PASS empty; skipping bootstrap")
+ } else if err := s.BootstrapAdmin(u, p); err != nil {
+ logger.Printf("bootstrap admin: %v", err)
+ } else {
+ logger.Printf("bootstrap admin ensured for user %q", u)
+ }
+ }
+
+ // Graceful shutdown
+ stop := make(chan os.Signal, 2)
+ signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
+ <-stop
+ logger.Println("shutdown requested")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _ = s.Close()
+
+ if err := httpSrv.Shutdown(ctx); err != nil {
+ logger.Printf("shutdown error: %v", err)
+ }
+}
+
+func envOr(k, def string) string {
+ if v := strings.TrimSpace(os.Getenv(k)); v != "" {
+ return v
+ }
+ return def
+}
+
+func envOrBool(k string, def bool) bool {
+ v := strings.TrimSpace(os.Getenv(k))
+ if v == "" {
+ return def
+ }
+ switch strings.ToLower(v) {
+ case "1", "true", "yes", "y", "on":
+ return true
+ case "0", "false", "no", "n", "off":
+ return false
+ default:
+ return def
+ }
+}
+
+func envOrDur(k string, def time.Duration) time.Duration {
+ v := strings.TrimSpace(os.Getenv(k))
+ if v == "" {
+ return def
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ return def
+ }
+ return d
+}
+
+func decodeSecret(s string) ([]byte, error) {
+ // Accept raw or base64 (std or URL)
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil, fmt.Errorf("empty")
+ }
+ // Heuristic: if it looks like base64, decode.
+ if strings.ContainsAny(s, "+/=") || strings.Contains(s, "-") || strings.Contains(s, "_") {
+ if b, err := base64.StdEncoding.DecodeString(s); err == nil {
+ return b, nil
+ }
+ if b, err := base64.RawStdEncoding.DecodeString(s); err == nil {
+ return b, nil
+ }
+ if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
+ return b, nil
+ }
+ }
+ return []byte(s), nil
+}
+
+// GenerateSecret prints a base64 secret to stdout (optional helper).
+func GenerateSecret() string {
+ b := make([]byte, 48)
+ _, _ = rand.Read(b)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+var _ = security.Version
diff --git a/data/admins.json b/data/admins.json
new file mode 100644
index 0000000..4bcbe76
--- /dev/null
+++ b/data/admins.json
@@ -0,0 +1,9 @@
+{
+ "admin": {
+ "username": "admin",
+ "role": "admin",
+ "pass_hash": "pbkdf2_sha256$120000$zBmvdO4DjRapCKHSLCZAEQ$mo3uzViHoac1_OlhDdNiZIOMLg3eMbiMbuIQkJ6BrAA",
+ "disabled": false,
+ "created_at": 1768204361
+ }
+}
\ No newline at end of file
diff --git a/data/audit.jsonl b/data/audit.jsonl
new file mode 100644
index 0000000..124422c
--- /dev/null
+++ b/data/audit.jsonl
@@ -0,0 +1,3 @@
+{"time":"2026-01-12T07:52:52.0973915Z","actor":"admin","ip":"127.0.0.1","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","action":"login_failed"}
+{"time":"2026-01-12T07:53:20.6203935Z","actor":"admin","ip":"127.0.0.1","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","action":"login_failed"}
+{"time":"2026-01-12T07:55:12.4452336Z","actor":"admin","ip":"127.0.0.1","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","action":"login_failed"}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..361444d
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,44 @@
+version: "3.8"
+
+services:
+ ntfy:
+ image: binwiederhier/ntfy:latest
+ command: ["serve"]
+ restart: unless-stopped
+ ports:
+ - "8080:80" # ntfy web/app (optional)
+ volumes:
+ - ntfy_etc:/etc/ntfy
+ - ntfy_var_lib:/var/lib/ntfy
+ environment:
+ # Make sure auth-file is configured in /etc/ntfy/server.yml
+ # Example:
+ # auth-file: "/var/lib/ntfy/user.db"
+ # auth-default-access: "deny-all"
+ - TZ=Europe/Berlin
+
+ ntfywui:
+ build: .
+ restart: unless-stopped
+ depends_on:
+ - ntfy
+ ports:
+ - "8090:8080" # WebUI
+ volumes:
+ - ntfy_etc:/etc/ntfy:ro
+ - ntfy_var_lib:/var/lib/ntfy
+ - ntfywui_data:/data
+ environment:
+ - TZ=Europe/Berlin
+ # REQUIRED: strong random secret (>=32 bytes; base64 recommended)
+ - NTFYWUI_SECRET=${NTFYWUI_SECRET}
+ # First admin (only used if admins.json doesn't exist yet)
+ - NTFYWUI_BOOTSTRAP_USER=${NTFYWUI_BOOTSTRAP_USER}
+ - NTFYWUI_BOOTSTRAP_PASS=${NTFYWUI_BOOTSTRAP_PASS}
+ # Behind reverse proxy? Trust the proxy IP/CIDR to read X-Forwarded-For safely.
+ # - NTFYWUI_TRUST_PROXY=172.18.0.0/16
+
+volumes:
+ ntfy_etc:
+ ntfy_var_lib:
+ ntfywui_data:
diff --git a/examples/server.yml b/examples/server.yml
new file mode 100644
index 0000000..3e66447
--- /dev/null
+++ b/examples/server.yml
@@ -0,0 +1,15 @@
+# Example ntfy server.yml
+# Mount to /etc/ntfy/server.yml inside the ntfy container.
+#
+# Docs: https://docs.ntfy.sh/config/
+
+listen-http: ":80"
+base-url: "http://localhost" # set to your public URL
+cache-file: "/var/cache/ntfy/cache.db"
+
+# Enable authentication (required for user/access/token management)
+auth-file: "/var/lib/ntfy/user.db"
+auth-default-access: "deny-all"
+
+# Optional: log
+log-level: "info"
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0a0f9d1
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/yourorg/ntfywui
+
+go 1.22
diff --git a/internal/app/assets.go b/internal/app/assets.go
new file mode 100644
index 0000000..39681b9
--- /dev/null
+++ b/internal/app/assets.go
@@ -0,0 +1,23 @@
+package app
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+)
+
+//go:embed web/templates/*.html
+var templatesFS embed.FS
+
+//go:embed web/static/*
+var staticFS embed.FS
+
+func tfs() fs.FS {
+ sub, _ := fs.Sub(templatesFS, "web/templates")
+ return sub
+}
+
+func rfs() http.FileSystem {
+ sub, _ := fs.Sub(staticFS, "web/static")
+ return http.FS(sub)
+}
diff --git a/internal/app/flash.go b/internal/app/flash.go
new file mode 100644
index 0000000..a3184d7
--- /dev/null
+++ b/internal/app/flash.go
@@ -0,0 +1,29 @@
+package app
+
+import (
+ "net/http"
+
+ "github.com/yourorg/ntfywui/internal/security"
+)
+
+func (s *Server) setFlash(w http.ResponseWriter, r *http.Request, msg string) {
+ sess, ok := s.sessions.Get(r)
+ if !ok {
+ sess = &security.Session{}
+ }
+ sess.Flash = msg
+ _ = s.sessions.Save(w, sess)
+}
+
+func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
+ sess, ok := s.sessions.Get(r)
+ if !ok {
+ return ""
+ }
+ msg := sess.Flash
+ if msg != "" {
+ sess.Flash = ""
+ _ = s.sessions.Save(w, sess)
+ }
+ return msg
+}
diff --git a/internal/app/handlers_access.go b/internal/app/handlers_access.go
new file mode 100644
index 0000000..d51c396
--- /dev/null
+++ b/internal/app/handlers_access.go
@@ -0,0 +1,74 @@
+package app
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/yourorg/ntfywui/internal/store"
+)
+
+func (s *Server) handleAccess(w http.ResponseWriter, r *http.Request) {
+ admin, _ := s.currentAdmin(r)
+ switch r.Method {
+ case http.MethodGet:
+ users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
+ if err != nil {
+ s.renderer.Render(w, "error.html", PageData{Title: "Fehler", Admin: admin.Username, Role: string(admin.Role), Error: err.Error()})
+ return
+ }
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "access.html", PageData{
+ Title: "Access",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ CSRF: csrf,
+ Flash: flash,
+ Users: users,
+ })
+ case http.MethodPost:
+ if !roleAtLeast(admin.Role, store.RoleOperator) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ _ = r.ParseForm()
+ action := r.Form.Get("action")
+ username := cleanUser(r.Form.Get("username"))
+ switch action {
+ case "grant":
+ topic := cleanTopic(r.Form.Get("topic"))
+ perm := strings.TrimSpace(r.Form.Get("perm"))
+ if username == "" || topic == "" || perm == "" {
+ s.setFlash(w, r, "Username, Topic und Permission sind erforderlich")
+ http.Redirect(w, r, s.abs("/access"), http.StatusFound)
+ return
+ }
+ if err := s.ntfy.GrantAccess(s.ntfyCtx(r), username, topic, perm); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/access"), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_access_grant", username, map[string]string{"topic": topic, "perm": perm})
+ s.setFlash(w, r, "Access gesetzt")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ case "reset":
+ if username == "" {
+ s.setFlash(w, r, "Username erforderlich")
+ http.Redirect(w, r, s.abs("/access"), http.StatusFound)
+ return
+ }
+ if err := s.ntfy.ResetAccess(s.ntfyCtx(r), username); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/access"), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_access_reset", username, nil)
+ s.setFlash(w, r, "Access reset")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ default:
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/app/handlers_admins.go b/internal/app/handlers_admins.go
new file mode 100644
index 0000000..22e8a38
--- /dev/null
+++ b/internal/app/handlers_admins.go
@@ -0,0 +1,199 @@
+package app
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/yourorg/ntfywui/internal/security"
+ "github.com/yourorg/ntfywui/internal/store"
+)
+
+func (s *Server) handleAdmins(w http.ResponseWriter, r *http.Request) {
+ admin, _ := s.currentAdmin(r)
+ switch r.Method {
+ case http.MethodGet:
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "admins.html", PageData{
+ Title: "Admins",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ CSRF: csrf,
+ Flash: flash,
+ Admins: s.admins.List(),
+ })
+ case http.MethodPost:
+ _ = r.ParseForm()
+ action := r.Form.Get("action")
+ username := cleanUser(r.Form.Get("username"))
+ switch action {
+ case "create":
+ pass := r.Form.Get("password")
+ role := store.Role(strings.TrimSpace(r.Form.Get("role")))
+ if role == "" {
+ role = store.RoleOperator
+ }
+ if username == "" || pass == "" {
+ s.setFlash(w, r, "Username und Passwort erforderlich")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ if role != store.RoleViewer && role != store.RoleOperator && role != store.RoleAdmin {
+ s.setFlash(w, r, "Ungültige Rolle")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ if _, ok := s.admins.Get(username); ok {
+ s.setFlash(w, r, "Admin existiert bereits")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ salt := make([]byte, 16)
+ _, _ = randRead(salt)
+ hash := security.HashPasswordPBKDF2(pass, salt, 120_000)
+ a := store.Admin{
+ Username: username,
+ Role: role,
+ PassHash: hash,
+ CreatedAt: time.Now().Unix(),
+ }
+ if err := s.admins.Set(a); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_create", username, map[string]string{"role": string(role)})
+ s.setFlash(w, r, "Admin erstellt")
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ case "set-role":
+ role := store.Role(strings.TrimSpace(r.Form.Get("role")))
+ a, ok := s.admins.Get(username)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if role != store.RoleViewer && role != store.RoleOperator && role != store.RoleAdmin {
+ s.setFlash(w, r, "Ungültige Rolle")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ a.Role = role
+ if err := s.admins.Set(a); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_set_role", username, map[string]string{"role": string(role)})
+ s.setFlash(w, r, "Rolle geändert")
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ case "set-pass":
+ pass := r.Form.Get("password")
+ if pass == "" {
+ s.setFlash(w, r, "Passwort erforderlich")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ a, ok := s.admins.Get(username)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ salt := make([]byte, 16)
+ _, _ = randRead(salt)
+ a.PassHash = security.HashPasswordPBKDF2(pass, salt, 120_000)
+ if err := s.admins.Set(a); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_set_pass", username, nil)
+ s.setFlash(w, r, "Passwort geändert")
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ case "toggle-disable":
+ a, ok := s.admins.Get(username)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // Prevent self-lockout
+ if username == admin.Username {
+ s.setFlash(w, r, "Du kannst dich nicht selbst deaktivieren")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ a.Disabled = !a.Disabled
+ if err := s.admins.Set(a); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_toggle_disable", username, map[string]string{"disabled": boolStr(a.Disabled)})
+ s.setFlash(w, r, "Aktualisiert")
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ case "2fa-enable":
+ a, ok := s.admins.Get(username)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ secret, err := security.GenerateTOTPSecret()
+ if err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ a.TOTPSecret = secret
+ if err := s.admins.Set(a); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_2fa_enable", username, nil)
+ s.setFlash(w, r, "2FA Secret (speichern!): "+secret)
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ case "2fa-disable":
+ a, ok := s.admins.Get(username)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ a.TOTPSecret = ""
+ if err := s.admins.Set(a); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_2fa_disable", username, nil)
+ s.setFlash(w, r, "2FA deaktiviert")
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ case "delete":
+ // Prevent deleting self
+ if username == admin.Username {
+ s.setFlash(w, r, "Du kannst dich nicht selbst löschen")
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+ return
+ }
+ if err := s.admins.Delete(username); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ } else {
+ s.auditEvent(r, "webui_admin_delete", username, nil)
+ s.setFlash(w, r, "Admin gelöscht")
+ }
+ http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
+
+ default:
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func boolStr(b bool) string {
+ if b {
+ return "true"
+ }
+ return "false"
+}
diff --git a/internal/app/handlers_audit.go b/internal/app/handlers_audit.go
new file mode 100644
index 0000000..e3eb40a
--- /dev/null
+++ b/internal/app/handlers_audit.go
@@ -0,0 +1,33 @@
+package app
+
+import (
+ "net/http"
+)
+
+func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
+ admin, _ := s.currentAdmin(r)
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ evs, err := s.audit.Tail(200)
+ if err != nil {
+ s.renderer.Render(w, "error.html", PageData{
+ Title: "Fehler",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ Error: err.Error(),
+ })
+ return
+ }
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "audit.html", PageData{
+ Title: "Audit",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ CSRF: csrf,
+ Flash: flash,
+ Audit: evs,
+ })
+}
diff --git a/internal/app/handlers_auth.go b/internal/app/handlers_auth.go
new file mode 100644
index 0000000..ac9b7ad
--- /dev/null
+++ b/internal/app/handlers_auth.go
@@ -0,0 +1,88 @@
+package app
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/yourorg/ntfywui/internal/security"
+ "github.com/yourorg/ntfywui/internal/store"
+)
+
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ next := r.URL.Query().Get("next")
+ if next == "" {
+ next = "/users"
+ }
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "login.html", PageData{
+ Title: "Login",
+ CSRF: csrf,
+ Flash: flash,
+ Next: next,
+ })
+ case http.MethodPost:
+ _ = r.ParseForm()
+ user := cleanUser(r.Form.Get("username"))
+ pass := r.Form.Get("password")
+ totp := strings.TrimSpace(r.Form.Get("totp"))
+ next := r.Form.Get("next")
+ if next == "" {
+ next = "/users"
+ }
+ // CSRF checked by middleware in routes (we add it by calling s.csrf wrapper above in routes)
+ a, ok := s.admins.Authenticate(user, pass, totp)
+ if !ok {
+ s.audit.Append(store.AuditEvent{
+ Actor: user,
+ IP: security.RealIP(r, security.RealIPConfig{TrustedProxies: s.cfg.TrustedProxies}),
+ UA: r.UserAgent(),
+ Action: "login_failed",
+ })
+ s.setFlash(w, r, "Login fehlgeschlagen")
+ http.Redirect(w, r, s.abs("/login"), http.StatusFound)
+ return
+ }
+
+ sess, _ := s.sessions.Get(r)
+ if sess == nil {
+ sess = &security.Session{}
+ }
+ sess.User = a.Username
+ sess.Role = string(a.Role)
+ if sess.CSRF == "" {
+ tok, _ := security.NewCSRFToken()
+ sess.CSRF = tok
+ }
+ s.sessions.Save(w, sess)
+
+ s.auditEvent(r, "login_ok", a.Username, map[string]string{"role": string(a.Role)})
+ http.Redirect(w, r, s.abs(next), http.StatusFound)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ s.auditEvent(r, "logout", "", nil)
+ s.sessions.Clear(w)
+ http.Redirect(w, r, s.abs("/login"), http.StatusFound)
+}
+
+func (s *Server) csrfEnsure(w http.ResponseWriter, r *http.Request) (string, error) {
+ sess, ok := s.sessions.Get(r)
+ if !ok {
+ sess = &security.Session{}
+ }
+ if sess.CSRF == "" {
+ tok, err := security.NewCSRFToken()
+ if err != nil {
+ return "", err
+ }
+ sess.CSRF = tok
+ _ = s.sessions.Save(w, sess)
+ }
+ return sess.CSRF, nil
+}
diff --git a/internal/app/handlers_tokens.go b/internal/app/handlers_tokens.go
new file mode 100644
index 0000000..e9afffb
--- /dev/null
+++ b/internal/app/handlers_tokens.go
@@ -0,0 +1,77 @@
+package app
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/yourorg/ntfywui/internal/store"
+)
+
+func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
+ admin, _ := s.currentAdmin(r)
+ switch r.Method {
+ case http.MethodGet:
+ users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
+ if err != nil {
+ s.renderer.Render(w, "error.html", PageData{Title: "Fehler", Admin: admin.Username, Role: string(admin.Role), Error: err.Error()})
+ return
+ }
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "tokens.html", PageData{
+ Title: "Tokens",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ CSRF: csrf,
+ Flash: flash,
+ Users: users,
+ })
+ case http.MethodPost:
+ if !roleAtLeast(admin.Role, store.RoleOperator) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ _ = r.ParseForm()
+ action := r.Form.Get("action")
+ username := cleanUser(r.Form.Get("username"))
+ switch action {
+ case "add":
+ label := strings.TrimSpace(r.Form.Get("label"))
+ expires := strings.TrimSpace(r.Form.Get("expires"))
+ if username == "" {
+ s.setFlash(w, r, "Username erforderlich")
+ http.Redirect(w, r, s.abs("/tokens"), http.StatusFound)
+ return
+ }
+ tok, err := s.ntfy.TokenAdd(s.ntfyCtx(r), username, label, expires)
+ if err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/tokens"), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_token_add", username, map[string]string{"label": label, "expires": expires})
+ // Show token once
+ s.setFlash(w, r, "Token erstellt: "+tok)
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ case "remove":
+ token := strings.TrimSpace(r.Form.Get("token"))
+ if username == "" || token == "" {
+ s.setFlash(w, r, "Username und Token erforderlich")
+ http.Redirect(w, r, s.abs("/tokens"), http.StatusFound)
+ return
+ }
+ if err := s.ntfy.TokenRemove(s.ntfyCtx(r), username, token); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_token_remove", username, nil)
+ s.setFlash(w, r, "Token entfernt")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ default:
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/app/handlers_users.go b/internal/app/handlers_users.go
new file mode 100644
index 0000000..e39a4aa
--- /dev/null
+++ b/internal/app/handlers_users.go
@@ -0,0 +1,186 @@
+package app
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/yourorg/ntfywui/internal/store"
+)
+
+func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/users" {
+ http.NotFound(w, r)
+ return
+ }
+ admin, _ := s.currentAdmin(r)
+ switch r.Method {
+ case http.MethodGet:
+ users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
+ if err != nil {
+ s.renderer.Render(w, "error.html", PageData{
+ Title: "Fehler",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ Error: err.Error(),
+ })
+ return
+ }
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "users.html", PageData{
+ Title: "Users",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ CSRF: csrf,
+ Flash: flash,
+ Users: users,
+ })
+ case http.MethodPost:
+ _ = r.ParseForm()
+ action := r.Form.Get("action")
+ switch action {
+ case "create":
+ if !roleAtLeast(admin.Role, store.RoleOperator) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ username := cleanUser(r.Form.Get("username"))
+ role := strings.TrimSpace(r.Form.Get("role"))
+ tier := strings.TrimSpace(r.Form.Get("tier"))
+ pass := r.Form.Get("password")
+ if username == "" || pass == "" {
+ s.setFlash(w, r, "Username und Passwort sind erforderlich")
+ http.Redirect(w, r, s.abs("/users"), http.StatusFound)
+ return
+ }
+ if err := s.ntfy.AddUser(s.ntfyCtx(r), username, role, tier, pass); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/users"), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_user_add", username, map[string]string{"role": role, "tier": tier})
+ s.setFlash(w, r, "User erstellt: "+username)
+ http.Redirect(w, r, s.abs("/users"), http.StatusFound)
+ default:
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) handleUserDetail(w http.ResponseWriter, r *http.Request) {
+ // /users/{name} or /users/{name}/action
+ admin, _ := s.currentAdmin(r)
+ p := strings.TrimPrefix(r.URL.Path, "/users/")
+ if p == "" {
+ http.NotFound(w, r)
+ return
+ }
+ parts := strings.Split(strings.Trim(p, "/"), "/")
+ username := parts[0]
+ action := ""
+ if len(parts) > 1 {
+ action = parts[1]
+ }
+ switch r.Method {
+ case http.MethodGet:
+ users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
+ if err != nil {
+ s.renderer.Render(w, "error.html", PageData{
+ Title: "Fehler",
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ Error: err.Error(),
+ })
+ return
+ }
+ var u any
+ for _, x := range users {
+ if x.Username == username {
+ u = x
+ break
+ }
+ }
+ if u == nil {
+ http.NotFound(w, r)
+ return
+ }
+ toks, _ := s.ntfy.TokenList(s.ntfyCtx(r), username)
+ csrf, _ := s.csrfEnsure(w, r)
+ flash := s.popFlash(w, r)
+ s.renderer.Render(w, "user.html", PageData{
+ Title: "User: " + username,
+ Admin: admin.Username,
+ Role: string(admin.Role),
+ CSRF: csrf,
+ Flash: flash,
+ User: u,
+ Tokens: toks,
+ })
+ case http.MethodPost:
+ if !roleAtLeast(admin.Role, store.RoleOperator) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ _ = r.ParseForm()
+ switch action {
+ case "delete":
+ if err := s.ntfy.DelUser(s.ntfyCtx(r), username); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_user_del", username, nil)
+ s.setFlash(w, r, "User gelöscht: "+username)
+ http.Redirect(w, r, s.abs("/users"), http.StatusFound)
+ case "password":
+ pass := r.Form.Get("password")
+ if pass == "" {
+ s.setFlash(w, r, "Passwort erforderlich")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ if err := s.ntfy.ChangePass(s.ntfyCtx(r), username, pass); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_user_change_pass", username, nil)
+ s.setFlash(w, r, "Passwort geändert")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ case "role":
+ role := strings.TrimSpace(r.Form.Get("role"))
+ if role == "" {
+ s.setFlash(w, r, "Rolle erforderlich")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ if err := s.ntfy.ChangeRole(s.ntfyCtx(r), username, role); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_user_change_role", username, map[string]string{"role": role})
+ s.setFlash(w, r, "Rolle geändert")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ case "tier":
+ tier := strings.TrimSpace(r.Form.Get("tier"))
+ if tier == "" {
+ tier = "none"
+ }
+ if err := s.ntfy.ChangeTier(s.ntfyCtx(r), username, tier); err != nil {
+ s.setFlash(w, r, "Fehler: "+err.Error())
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ return
+ }
+ s.auditEvent(r, "ntfy_user_change_tier", username, map[string]string{"tier": tier})
+ s.setFlash(w, r, "Tier geändert")
+ http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
+ default:
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/app/rand.go b/internal/app/rand.go
new file mode 100644
index 0000000..09efbd4
--- /dev/null
+++ b/internal/app/rand.go
@@ -0,0 +1,5 @@
+package app
+
+import "crypto/rand"
+
+func randRead(b []byte) (int, error) { return rand.Read(b) }
diff --git a/internal/app/render.go b/internal/app/render.go
new file mode 100644
index 0000000..10cc9a1
--- /dev/null
+++ b/internal/app/render.go
@@ -0,0 +1,63 @@
+package app
+
+import (
+ "html/template"
+ "net/http"
+ "path"
+ "strings"
+)
+
+type Renderer struct {
+ basePath string
+ tpls *template.Template
+}
+
+func NewRenderer(basePath string) *Renderer {
+ funcs := template.FuncMap{
+ "abs": func(p string) string {
+ if basePath == "" {
+ return p
+ }
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return basePath + p
+ },
+ "join": path.Join,
+ }
+ t := template.New("base").Funcs(funcs)
+ t = template.Must(t.ParseFS(tfs(),
+ "layout.html",
+ "login.html",
+ "users.html",
+ "user.html",
+ "access.html",
+ "tokens.html",
+ "admins.html",
+ "audit.html",
+ "error.html",
+ ))
+ return &Renderer{basePath: basePath, tpls: t}
+}
+
+type PageData struct {
+ Title string
+ Admin string
+ Role string
+ CSRF string
+ Flash string
+ Error string
+
+ Users any
+ User any
+ Tokens any
+ Admins any
+ Audit any
+ Access any
+ Next string
+}
+
+func (r *Renderer) Render(w http.ResponseWriter, name string, data PageData) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = r.tpls.ExecuteTemplate(w, name, data)
+}
diff --git a/internal/app/server.go b/internal/app/server.go
new file mode 100644
index 0000000..3b6a88f
--- /dev/null
+++ b/internal/app/server.go
@@ -0,0 +1,232 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/yourorg/ntfywui/internal/ntfy"
+ "github.com/yourorg/ntfywui/internal/security"
+ "github.com/yourorg/ntfywui/internal/store"
+)
+
+type Config struct {
+ BasePath string
+ DataDir string
+ Secret []byte
+ CookieSecure bool
+ TrustedProxies []*net.IPNet
+ NtfyBin string
+ NtfyConfig string
+ NtfyTimeout time.Duration
+ Logger *log.Logger
+}
+
+type Server struct {
+ cfg Config
+ mux *http.ServeMux
+ renderer *Renderer
+ sessions *security.SessionManager
+ admins *store.AdminStore
+ audit *store.AuditLog
+ rl *security.RateLimiter
+ ntfy *ntfy.Client
+}
+
+func NewServer(cfg Config) *Server {
+ if cfg.Logger == nil {
+ cfg.Logger = log.New(os.Stdout, "", log.LstdFlags)
+ }
+ sess, err := security.NewSessionManager(cfg.Secret, "ntfywui_session", cfg.CookieSecure)
+ if err != nil {
+ panic(err)
+ }
+
+ adminPath := filepath.Join(cfg.DataDir, "admins.json")
+ adminStore, err := store.NewAdminStore(adminPath)
+ if err != nil {
+ panic(err)
+ }
+ auditPath := filepath.Join(cfg.DataDir, "audit.jsonl")
+ audit, err := store.NewAuditLog(auditPath)
+ if err != nil {
+ panic(err)
+ }
+
+ r := NewRenderer(cfg.BasePath)
+
+ s := &Server{
+ cfg: cfg,
+ mux: http.NewServeMux(),
+ renderer: r,
+ sessions: sess,
+ admins: adminStore,
+ audit: audit,
+ rl: security.NewRateLimiter(30, 10, 10*time.Minute), // 30 burst, 10 r/s
+ ntfy: &ntfy.Client{
+ Bin: cfg.NtfyBin,
+ Config: cfg.NtfyConfig,
+ Timeout: cfg.NtfyTimeout,
+ },
+ }
+ s.routes()
+ return s
+}
+
+func (s *Server) BasePath() string { return s.cfg.BasePath }
+
+func (s *Server) Close() error {
+ if s.audit != nil {
+ return s.audit.Close()
+ }
+ return nil
+}
+
+func (s *Server) BootstrapAdmin(user, pass string) error {
+ created, err := s.admins.EnsureBootstrap(user, pass)
+ if err != nil {
+ return err
+ }
+ if created {
+ s.cfg.Logger.Printf("bootstrap admin created: %s", user)
+ }
+ return nil
+}
+
+func (s *Server) Handler() http.Handler {
+ h := http.Handler(s.mux)
+
+ // BasePath support
+ if s.cfg.BasePath != "" {
+ h = http.StripPrefix(s.cfg.BasePath, h)
+ }
+
+ // Security middleware chain (outermost first)
+ ipCfg := security.RealIPConfig{TrustedProxies: s.cfg.TrustedProxies}
+ keyFn := func(r *http.Request) string { return security.RealIP(r, ipCfg) }
+
+ h = s.rl.Middleware(keyFn)(h)
+ h = security.SecureHeaders(h)
+
+ return h
+}
+
+func (s *Server) routes() {
+ // Public
+ s.mux.HandleFunc("/", s.handleIndex)
+ s.mux.Handle("/login", s.csrf(http.HandlerFunc(s.handleLogin)))
+ s.mux.HandleFunc("/logout", s.handleLogout)
+
+ // Protected areas
+ s.mux.Handle("/users", s.authRequired(store.RoleViewer, s.csrf(s.handleUsersList)))
+ s.mux.Handle("/users/", s.authRequired(store.RoleViewer, s.csrf(s.handleUserDetail))) // includes actions under /users/{name}/...
+ s.mux.Handle("/access", s.authRequired(store.RoleOperator, s.csrf(s.handleAccess)))
+ s.mux.Handle("/tokens", s.authRequired(store.RoleOperator, s.csrf(s.handleTokens)))
+ s.mux.Handle("/admins", s.authRequired(store.RoleAdmin, s.csrf(s.handleAdmins)))
+ s.mux.Handle("/audit", s.authRequired(store.RoleAdmin, s.csrf(s.handleAudit)))
+
+ // Static assets
+ s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(rfs())))
+}
+
+func (s *Server) csrf(next http.HandlerFunc) http.Handler {
+ f := security.CSRFFuncs{
+ GetCSRF: func(r *http.Request) (string, bool) {
+ sess, ok := s.sessions.Get(r)
+ if !ok {
+ return "", false
+ }
+ return sess.CSRF, sess.CSRF != ""
+ },
+ EnsureCSRF: func(w http.ResponseWriter, r *http.Request) (string, error) {
+ sess, ok := s.sessions.Get(r)
+ if !ok {
+ sess = &security.Session{}
+ }
+ if sess.CSRF == "" {
+ tok, err := security.NewCSRFToken()
+ if err != nil {
+ return "", err
+ }
+ sess.CSRF = tok
+ // Preserve existing login info
+ _ = s.sessions.Save(w, sess)
+ }
+ return sess.CSRF, nil
+ },
+ }
+ return security.CSRFMiddleware(f)(next)
+}
+
+func (s *Server) authRequired(minRole store.Role, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ admin, ok := s.currentAdmin(r)
+ if !ok {
+ // redirect to login
+ http.Redirect(w, r, s.abs("/login?next="+urlQueryEscape(r.URL.Path)), http.StatusFound)
+ return
+ }
+ if !roleAtLeast(admin.Role, minRole) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func roleAtLeast(have, need store.Role) bool {
+ order := map[store.Role]int{
+ store.RoleViewer: 1,
+ store.RoleOperator: 2,
+ store.RoleAdmin: 3,
+ }
+ return order[have] >= order[need]
+}
+
+func (s *Server) currentAdmin(r *http.Request) (store.Admin, bool) {
+ sess, ok := s.sessions.Get(r)
+ if !ok || sess.User == "" {
+ return store.Admin{}, false
+ }
+ a, ok := s.admins.Get(sess.User)
+ if !ok || a.Disabled {
+ return store.Admin{}, false
+ }
+ return a, true
+}
+
+func (s *Server) auditEvent(r *http.Request, action, target string, meta map[string]string) {
+ admin, _ := s.currentAdmin(r)
+ ip := security.RealIP(r, security.RealIPConfig{TrustedProxies: s.cfg.TrustedProxies})
+ s.audit.Append(store.AuditEvent{
+ Actor: admin.Username,
+ IP: ip,
+ UA: r.UserAgent(),
+ Action: action,
+ Target: target,
+ Meta: meta,
+ })
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ if _, ok := s.currentAdmin(r); ok {
+ http.Redirect(w, r, s.abs("/users"), http.StatusFound)
+ return
+ }
+ http.Redirect(w, r, s.abs("/login"), http.StatusFound)
+}
+
+var errBadRequest = errors.New("bad request")
+
+func (s *Server) ntfyCtx(r *http.Request) context.Context {
+ return r.Context()
+}
diff --git a/internal/app/util.go b/internal/app/util.go
new file mode 100644
index 0000000..0e9548f
--- /dev/null
+++ b/internal/app/util.go
@@ -0,0 +1,28 @@
+package app
+
+import (
+ "net/url"
+ "strings"
+)
+
+func (s *Server) abs(p string) string {
+ if s.cfg.BasePath == "" {
+ return p
+ }
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return s.cfg.BasePath + p
+}
+
+func urlQueryEscape(s string) string {
+ return url.QueryEscape(s)
+}
+
+func cleanUser(u string) string {
+ return strings.TrimSpace(u)
+}
+
+func cleanTopic(t string) string {
+ return strings.TrimSpace(t)
+}
diff --git a/internal/app/web/static/app.css b/internal/app/web/static/app.css
new file mode 100644
index 0000000..7bbf925
--- /dev/null
+++ b/internal/app/web/static/app.css
@@ -0,0 +1,131 @@
+/* Minimal dark UI, no external deps */
+:root{
+ --bg:#0b0f17;
+ --panel:#111827;
+ --panel2:#0f172a;
+ --text:#e5e7eb;
+ --muted:#9ca3af;
+ --border:#243041;
+ --accent:#22c55e;
+ --danger:#ef4444;
+ --warn:#f59e0b;
+}
+*{box-sizing:border-box}
+html,body{height:100%}
+body{
+ margin:0;
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji";
+ background: radial-gradient(1200px 600px at 10% 0%, #0f1b2e 0%, var(--bg) 55%);
+ color:var(--text);
+}
+a{color:inherit}
+code.mono, pre, code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}
+.page{max-width:1100px;margin:0 auto;padding:22px}
+.page.center{display:flex;align-items:center;justify-content:center;min-height:100%}
+.nav{
+ position:sticky;top:0;z-index:2;
+ display:flex;align-items:center;gap:16px;
+ padding:14px 18px;
+ border-bottom:1px solid var(--border);
+ background: rgba(15,23,42,.82);
+ backdrop-filter: blur(10px);
+}
+.brand a{font-weight:700;text-decoration:none}
+.links{display:flex;gap:14px;flex:1}
+.links a{opacity:.9;text-decoration:none}
+.links a:hover{opacity:1;text-decoration:underline}
+.user{display:flex;gap:10px;align-items:center}
+.badge{
+ padding:6px 10px;border:1px solid var(--border);
+ border-radius:999px;background:rgba(17,24,39,.7);color:var(--muted)
+}
+h1{margin:0 0 14px;font-size:28px}
+h2{margin:0 0 10px;font-size:18px}
+h3{margin:14px 0 8px;font-size:15px;color:var(--muted)}
+.card{
+ background: linear-gradient(180deg, rgba(17,24,39,.85), rgba(15,23,42,.85));
+ border:1px solid var(--border);
+ border-radius:14px;
+ padding:16px 16px;
+ box-shadow: 0 10px 40px rgba(0,0,0,.25);
+ margin: 0 0 16px;
+}
+label{display:block;margin:10px 0 6px;color:var(--muted);font-size:13px}
+input, select{
+ width:100%;
+ padding:10px 10px;
+ border-radius:10px;
+ border:1px solid var(--border);
+ background: rgba(2,6,23,.65);
+ color:var(--text);
+ outline:none;
+}
+input:focus, select:focus{border-color: rgba(34,197,94,.5); box-shadow: 0 0 0 3px rgba(34,197,94,.12)}
+.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
+@media (max-width:800px){.grid{grid-template-columns:1fr}.links{display:none}}
+.btn{
+ margin-top:12px;
+ display:inline-flex;align-items:center;justify-content:center;
+ padding:10px 14px;border-radius:12px;
+ border:1px solid rgba(34,197,94,.5);
+ background: rgba(34,197,94,.14);
+ color: var(--text);
+ cursor:pointer;
+}
+.btn:hover{background: rgba(34,197,94,.22)}
+.btn-ghost{
+ border-color: var(--border);
+ background: rgba(148,163,184,.08);
+}
+.btn-ghost:hover{background: rgba(148,163,184,.12)}
+.btn-danger{
+ border-color: rgba(239,68,68,.6);
+ background: rgba(239,68,68,.12);
+}
+.btn-danger:hover{background: rgba(239,68,68,.18)}
+.flash{
+ margin:10px 0;
+ padding:10px 12px;
+ border:1px solid rgba(34,197,94,.35);
+ border-radius:12px;
+ background: rgba(34,197,94,.10);
+}
+.flash-err{
+ border-color: rgba(239,68,68,.55);
+ background: rgba(239,68,68,.10);
+}
+.hint{margin-top:10px;color:var(--muted);font-size:13px;line-height:1.35}
+.table{
+ width:100%;
+ border-collapse:collapse;
+ overflow:hidden;
+}
+.table th, .table td{
+ text-align:left;
+ padding:10px 10px;
+ border-top:1px solid var(--border);
+ vertical-align:top;
+}
+.table th{color:var(--muted);font-size:12px;font-weight:600}
+.pill{
+ display:inline-flex;align-items:center;
+ padding:4px 8px;
+ border-radius:999px;
+ border:1px solid rgba(148,163,184,.25);
+ background: rgba(148,163,184,.08);
+ color: var(--text);
+ font-size:12px;
+ margin:2px 6px 2px 0;
+}
+.pill-muted{opacity:.7}
+.muted{color:var(--muted)}
+.pre{
+ margin:0;padding:12px;
+ border-radius:12px;
+ border:1px solid var(--border);
+ background: rgba(2,6,23,.65);
+ overflow:auto;
+}
+.row{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--border)}
+.inline{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
+.footer{padding:20px;color:var(--muted);border-top:1px solid var(--border)}
diff --git a/internal/app/web/templates/access.html b/internal/app/web/templates/access.html
new file mode 100644
index 0000000..6d122ad
--- /dev/null
+++ b/internal/app/web/templates/access.html
@@ -0,0 +1,61 @@
+{{define "access.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ Access
+ {{template "partials_flash" .}}
+
+
+
+
+
+
+ Hinweis: Access-Kontrolle basiert auf ntfy access und benötigt eine korrekt konfigurierte auth-file im server.yml.
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/app/web/templates/admins.html b/internal/app/web/templates/admins.html
new file mode 100644
index 0000000..5f70158
--- /dev/null
+++ b/internal/app/web/templates/admins.html
@@ -0,0 +1,105 @@
+{{define "admins.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ WebUI Admins
+ {{template "partials_flash" .}}
+
+
+ Neuen Admin erstellen
+
+
+
+
+ Liste
+
+ | User | Role | 2FA | Status | Aktionen |
+
+ {{range .Admins}}
+
+ | {{.Username}} |
+ {{.Role}} |
+ {{if .TOTPSecret}}enabled{{else}}off{{end}} |
+ {{if .Disabled}}disabled{{else}}active{{end}} |
+
+
+
+
+
+
+
+ {{if .TOTPSecret}}
+
+ {{else}}
+
+ {{end}}
+
+
+ |
+
+ {{end}}
+
+
+ 2FA Secret wird als Flash angezeigt. In einer Authenticator-App als TOTP-Secret (base32) hinzufügen.
+
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/app/web/templates/audit.html b/internal/app/web/templates/audit.html
new file mode 100644
index 0000000..12f6076
--- /dev/null
+++ b/internal/app/web/templates/audit.html
@@ -0,0 +1,31 @@
+{{define "audit.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ Audit Log (letzte 200)
+ {{template "partials_flash" .}}
+
+
+
+ | Time | Actor | IP | Action | Target |
+
+ {{range .Audit}}
+
+ {{.Time}} |
+ {{.Actor}} |
+ {{.IP}} |
+ {{.Action}} |
+ {{.Target}} |
+
+ {{end}}
+
+
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/app/web/templates/error.html b/internal/app/web/templates/error.html
new file mode 100644
index 0000000..4310dbb
--- /dev/null
+++ b/internal/app/web/templates/error.html
@@ -0,0 +1,18 @@
+{{define "error.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ Fehler
+ {{template "partials_flash" .}}
+
+ {{.Error}}
+ Tipp: In Docker sicherstellen, dass /etc/ntfy/server.yml und /var/lib/ntfy korrekt gemountet sind.
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/app/web/templates/layout.html b/internal/app/web/templates/layout.html
new file mode 100644
index 0000000..d83c3ab
--- /dev/null
+++ b/internal/app/web/templates/layout.html
@@ -0,0 +1,38 @@
+{{define "partials_head"}}
+
+
+
+ {{if .Title}}{{.Title}} – {{end}}ntfywui
+
+
+{{end}}
+
+{{define "partials_nav"}}
+
+{{end}}
+
+{{define "partials_flash"}}
+ {{if .Flash}}{{.Flash}}
{{end}}
+ {{if .Error}}{{.Error}}
{{end}}
+{{end}}
+
+{{define "partials_footer"}}
+
+{{end}}
diff --git a/internal/app/web/templates/login.html b/internal/app/web/templates/login.html
new file mode 100644
index 0000000..ae97df2
--- /dev/null
+++ b/internal/app/web/templates/login.html
@@ -0,0 +1,29 @@
+{{define "login.html"}}
+
+
+{{template "partials_head" .}}
+
+
+
+
Login
+ {{template "partials_flash" .}}
+
+
+
+ Tipp: Setze NTFYWUI_BOOTSTRAP_USER und NTFYWUI_BOOTSTRAP_PASS für den ersten Admin.
+
+
+
+
+
+{{end}}
diff --git a/internal/app/web/templates/tokens.html b/internal/app/web/templates/tokens.html
new file mode 100644
index 0000000..9644d05
--- /dev/null
+++ b/internal/app/web/templates/tokens.html
@@ -0,0 +1,40 @@
+{{define "tokens.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ Tokens
+ {{template "partials_flash" .}}
+
+
+ Token erstellen
+
+ Token wird als Flash angezeigt, danach nicht mehr.
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/app/web/templates/user.html b/internal/app/web/templates/user.html
new file mode 100644
index 0000000..577dec7
--- /dev/null
+++ b/internal/app/web/templates/user.html
@@ -0,0 +1,102 @@
+{{define "user.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ {{template "partials_flash" .}}
+
+ {{with .User}}{{ $uname := .Username }}
+ User: {{.Username}}
+
+
+
+ {{if or (eq $.Role "operator") (eq $.Role "admin")}}
+
+ Aktionen
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Access
+ {{range .Access}}{{.Perm}} → {{.Topic}}
{{end}}
+ Access wird mit ntfy access verwaltet (siehe Access-Seite).
+
+
+
+ Tokens
+ {{if $.Tokens}}
+ {{range $.Tokens}}
+
+ {{.Token}}
+
+
+ {{end}}
+ {{else}}
+ Keine Tokens (oder nicht auslesbar).
+ {{end}}
+
+ Token hinzufügen
+
+
+ {{end}}
+ {{end}}
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/app/web/templates/users.html b/internal/app/web/templates/users.html
new file mode 100644
index 0000000..f841c5c
--- /dev/null
+++ b/internal/app/web/templates/users.html
@@ -0,0 +1,65 @@
+{{define "users.html"}}
+
+
+{{template "partials_head" .}}
+
+{{template "partials_nav" .}}
+
+ Users
+ {{template "partials_flash" .}}
+
+ {{if or (eq .Role "operator") (eq .Role "admin")}}
+
+ Neuen ntfy User erstellen
+
+
+ {{end}}
+
+
+ Liste
+
+ | User | Role | Tier | Access |
+
+ {{range .Users}}
+
+ | {{.Username}} |
+ {{.Role}} |
+ {{.Tier}} |
+
+ {{range .Access}}{{.Perm}} → {{.Topic}} {{end}}
+ |
+
+ {{end}}
+
+
+
+
+{{template "partials_footer" .}}
+
+
+{{end}}
diff --git a/internal/ntfy/ntfy.go b/internal/ntfy/ntfy.go
new file mode 100644
index 0000000..322d526
--- /dev/null
+++ b/internal/ntfy/ntfy.go
@@ -0,0 +1,278 @@
+package ntfy
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type Client struct {
+ Bin string
+ Config string
+ Timeout time.Duration
+}
+
+type User struct {
+ Username string
+ Role string
+ Tier string
+ Access []AccessEntry
+}
+
+type AccessEntry struct {
+ Topic string
+ Perm string // read-write, read-only, write-only, deny, deny-all? etc.
+}
+
+type Token struct {
+ Token string
+ Label string
+ Expiry string
+}
+
+var (
+ reUserLine = regexp.MustCompile(`^user\s+(\S+)\s+\(role:\s*([^,]+),\s*tier:\s*([^)]+)\)`)
+ reAccessLine = regexp.MustCompile(`^\s*-\s+(.+?)\s+access\s+to\s+topic\s+(.+)$`) // e.g. "- read-only access to topic test"
+)
+
+// Run executes `ntfy ...` with --config.
+func (c *Client) Run(ctx context.Context, args []string, env map[string]string, stdin string) (string, string, int, error) {
+ if c.Bin == "" {
+ return "", "", 0, errors.New("ntfy binary not set")
+ }
+ if c.Config != "" {
+ args = append([]string{"--config", c.Config}, args...)
+ }
+ tctx := ctx
+ var cancel context.CancelFunc
+ if c.Timeout > 0 {
+ tctx, cancel = context.WithTimeout(ctx, c.Timeout)
+ defer cancel()
+ }
+ cmd := exec.CommandContext(tctx, c.Bin, args...)
+ if stdin != "" {
+ cmd.Stdin = strings.NewReader(stdin)
+ }
+ var outb, errb bytes.Buffer
+ cmd.Stdout = &outb
+ cmd.Stderr = &errb
+ if env != nil {
+ // inherit env automatically
+ for k, v := range env {
+ cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
+ }
+ }
+ err := cmd.Run()
+ exit := 0
+ if err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ exit = ee.ExitCode()
+ } else if errors.Is(err, context.DeadlineExceeded) {
+ return outb.String(), errb.String(), -1, fmt.Errorf("ntfy timeout")
+ } else {
+ return outb.String(), errb.String(), -1, err
+ }
+ }
+ return outb.String(), errb.String(), exit, nil
+}
+
+func (c *Client) ListUsers(ctx context.Context) ([]User, error) {
+ out, errOut, exit, err := c.Run(ctx, []string{"user", "list"}, nil, "")
+ if err != nil || exit != 0 {
+ return nil, fmt.Errorf("ntfy user list failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return parseUsers(out), nil
+}
+
+func parseUsers(out string) []User {
+ lines := strings.Split(out, "\n")
+ var users []User
+ var cur *User
+ for _, ln := range lines {
+ ln = strings.TrimRight(ln, "\r")
+ if m := reUserLine.FindStringSubmatch(ln); m != nil {
+ u := User{
+ Username: m[1],
+ Role: strings.TrimSpace(m[2]),
+ Tier: strings.TrimSpace(m[3]),
+ }
+ users = append(users, u)
+ cur = &users[len(users)-1]
+ continue
+ }
+ if cur != nil {
+ if strings.HasPrefix(strings.TrimSpace(ln), "-") {
+ // Try parse access entry line
+ // Example: "- read-only access to topic test"
+ // We also accept: "- read-write access to all topics (admin role)" -> store as Topic="*"
+ if strings.Contains(ln, "access to all topics") {
+ cur.Access = append(cur.Access, AccessEntry{Topic: "*", Perm: "read-write"})
+ continue
+ }
+ m := reAccessLine.FindStringSubmatch(ln)
+ if m != nil {
+ perm := strings.TrimSpace(m[1])
+ topic := strings.TrimSpace(m[2])
+ cur.Access = append(cur.Access, AccessEntry{Topic: topic, Perm: perm})
+ }
+ }
+ }
+ }
+ return users
+}
+
+func (c *Client) AddUser(ctx context.Context, username, role, tier, password string) error {
+ args := []string{"user", "add"}
+ if role != "" {
+ args = append(args, "--role="+role)
+ }
+ if tier != "" && tier != "none" {
+ args = append(args, "--tier="+tier)
+ }
+ args = append(args, username)
+ env := map[string]string{}
+ if password != "" {
+ env["NTFY_PASSWORD"] = password
+ }
+ _, errOut, exit, err := c.Run(ctx, args, env, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy user add failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) DelUser(ctx context.Context, username string) error {
+ _, errOut, exit, err := c.Run(ctx, []string{"user", "del", username}, nil, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy user del failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) ChangePass(ctx context.Context, username, password string) error {
+ env := map[string]string{"NTFY_PASSWORD": password}
+ _, errOut, exit, err := c.Run(ctx, []string{"user", "change-pass", username}, env, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy user change-pass failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) ChangeRole(ctx context.Context, username, role string) error {
+ _, errOut, exit, err := c.Run(ctx, []string{"user", "change-role", username, role}, nil, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy user change-role failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) ChangeTier(ctx context.Context, username, tier string) error {
+ _, errOut, exit, err := c.Run(ctx, []string{"user", "change-tier", username, tier}, nil, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy user change-tier failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) GrantAccess(ctx context.Context, username, topic, perm string) error {
+ _, errOut, exit, err := c.Run(ctx, []string{"access", username, topic, perm}, nil, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy access failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) ResetAccess(ctx context.Context, username string) error {
+ _, errOut, exit, err := c.Run(ctx, []string{"access", "--reset", username}, nil, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy access --reset failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+func (c *Client) TokenList(ctx context.Context, username string) ([]Token, error) {
+ out, errOut, exit, err := c.Run(ctx, []string{"token", "list", username}, nil, "")
+ if err != nil || exit != 0 {
+ return nil, fmt.Errorf("ntfy token list failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return parseTokens(out), nil
+}
+
+func (c *Client) TokenAdd(ctx context.Context, username, label, expires string) (string, error) {
+ args := []string{"token", "add"}
+ if expires != "" {
+ args = append(args, "--expires="+expires)
+ }
+ if label != "" {
+ args = append(args, "--label="+label)
+ }
+ args = append(args, username)
+ out, errOut, exit, err := c.Run(ctx, args, nil, "")
+ if err != nil || exit != 0 {
+ return "", fmt.Errorf("ntfy token add failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ // output contains token, usually like: "token tk_xxx added for user xyz"
+ tok := extractToken(out)
+ if tok == "" {
+ // sometimes printed on stderr; try there
+ tok = extractToken(errOut)
+ }
+ if tok == "" {
+ return "", fmt.Errorf("token added but could not parse token from output")
+ }
+ return tok, nil
+}
+
+func (c *Client) TokenRemove(ctx context.Context, username, token string) error {
+ _, errOut, exit, err := c.Run(ctx, []string{"token", "remove", username, token}, nil, "")
+ if err != nil || exit != 0 {
+ return fmt.Errorf("ntfy token remove failed: %v (exit=%d) stderr=%s", err, exit, strings.TrimSpace(errOut))
+ }
+ return nil
+}
+
+var reToken = regexp.MustCompile(`\b(tk_[A-Za-z0-9]+)\b`)
+
+func extractToken(s string) string {
+ m := reToken.FindStringSubmatch(s)
+ if m == nil {
+ return ""
+ }
+ return m[1]
+}
+
+func parseTokens(out string) []Token {
+ lines := strings.Split(out, "\n")
+ var toks []Token
+ // token list output varies; do best-effort parse: each line containing tk_...
+ for _, ln := range lines {
+ ln = strings.TrimSpace(strings.TrimRight(ln, "\r"))
+ if ln == "" {
+ continue
+ }
+ if !strings.Contains(ln, "tk_") {
+ continue
+ }
+ // naive: first token is token; remainder may have label/expiry
+ m := reToken.FindStringSubmatch(ln)
+ if m == nil {
+ continue
+ }
+ t := Token{Token: m[1]}
+ rest := strings.TrimSpace(strings.Replace(ln, t.Token, "", 1))
+ // attempt label=... exp=...
+ if strings.Contains(rest, "label:") {
+ if i := strings.Index(rest, "label:"); i >= 0 {
+ t.Label = strings.TrimSpace(rest[i+6:])
+ }
+ }
+ toks = append(toks, t)
+ }
+ return toks
+}
diff --git a/internal/security/csrf.go b/internal/security/csrf.go
new file mode 100644
index 0000000..add0f65
--- /dev/null
+++ b/internal/security/csrf.go
@@ -0,0 +1,52 @@
+package security
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "net/http"
+)
+
+type CSRFFuncs struct {
+ // Read session for csrf value
+ GetCSRF func(r *http.Request) (token string, ok bool)
+ // Save ensures session has csrf value
+ EnsureCSRF func(w http.ResponseWriter, r *http.Request) (token string, err error)
+}
+
+func NewCSRFToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func CSRFMiddleware(f CSRFFuncs) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Always ensure csrf exists for HTML GET requests
+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
+ _, _ = f.EnsureCSRF(w, r)
+ next.ServeHTTP(w, r)
+ return
+ }
+ // Validate for unsafe methods
+ token, ok := f.GetCSRF(r)
+ if !ok || token == "" {
+ http.Error(w, "csrf missing", http.StatusForbidden)
+ return
+ }
+ // Prefer header (JS), fallback to form value
+ got := r.Header.Get("X-CSRF-Token")
+ if got == "" {
+ _ = r.ParseForm()
+ got = r.Form.Get("csrf")
+ }
+ if got == "" || got != token {
+ http.Error(w, "csrf mismatch", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/internal/security/headers.go b/internal/security/headers.go
new file mode 100644
index 0000000..b842fa9
--- /dev/null
+++ b/internal/security/headers.go
@@ -0,0 +1,27 @@
+package security
+
+import "net/http"
+
+// SecureHeaders adds a baseline of security headers.
+// CSP is intentionally conservative; adjust if you add external assets.
+func SecureHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+ w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
+ w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
+ w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
+ w.Header().Set("Content-Security-Policy",
+ "default-src 'self'; "+
+ "script-src 'self'; "+
+ "style-src 'self'; "+
+ "img-src 'self' data:; "+
+ "object-src 'none'; "+
+ "base-uri 'none'; "+
+ "frame-ancestors 'none'; "+
+ "form-action 'self'")
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/security/pbkdf2.go b/internal/security/pbkdf2.go
new file mode 100644
index 0000000..2a24a09
--- /dev/null
+++ b/internal/security/pbkdf2.go
@@ -0,0 +1,96 @@
+package security
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+func pbkdf2SHA256(password, salt []byte, iter, keyLen int) []byte {
+ // PBKDF2 per RFC2898
+ hLen := 32 // sha256
+ numBlocks := (keyLen + hLen - 1) / hLen
+ var out []byte
+ for block := 1; block <= numBlocks; block++ {
+ t := pbkdf2F(password, salt, iter, block)
+ out = append(out, t...)
+ }
+ return out[:keyLen]
+}
+
+func pbkdf2F(password, salt []byte, iter, blockNum int) []byte {
+ // U1 = PRF(P, S || INT(blockNum))
+ // Uc = PRF(P, Uc-1)
+ // T = U1 XOR U2 XOR ... XOR Uiter
+ b := make([]byte, len(salt)+4)
+ copy(b, salt)
+ b[len(salt)+0] = byte(blockNum >> 24)
+ b[len(salt)+1] = byte(blockNum >> 16)
+ b[len(salt)+2] = byte(blockNum >> 8)
+ b[len(salt)+3] = byte(blockNum)
+
+ u := hmacSHA256(password, b)
+ t := make([]byte, len(u))
+ copy(t, u)
+ for i := 2; i <= iter; i++ {
+ u = hmacSHA256(password, u)
+ for j := range t {
+ t[j] ^= u[j]
+ }
+ }
+ return t
+}
+
+func hmacSHA256(key, msg []byte) []byte {
+ m := hmac.New(sha256.New, key)
+ m.Write(msg)
+ return m.Sum(nil)
+}
+
+func HashPasswordPBKDF2(password string, salt []byte, iter int) string {
+ key := pbkdf2SHA256([]byte(password), salt, iter, 32)
+ return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s",
+ iter,
+ base64.RawURLEncoding.EncodeToString(salt),
+ base64.RawURLEncoding.EncodeToString(key),
+ )
+}
+
+func VerifyPasswordPBKDF2(password, encoded string) (bool, error) {
+ // Go's fmt scanning does not support "scanset" verbs like %[^$]. Parse explicitly.
+ parts := strings.Split(encoded, "$")
+ if len(parts) != 4 {
+ return false, fmt.Errorf("parse hash: expected 4 parts, got %d", len(parts))
+ }
+ algo := parts[0]
+ iter, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return false, fmt.Errorf("parse hash iter: %w", err)
+ }
+ saltB64 := parts[2]
+ keyB64 := parts[3]
+ if algo != "pbkdf2_sha256" {
+ return false, fmt.Errorf("unsupported algo %q", algo)
+ }
+ salt, err := base64.RawURLEncoding.DecodeString(saltB64)
+ if err != nil {
+ return false, fmt.Errorf("salt decode: %w", err)
+ }
+ want, err := base64.RawURLEncoding.DecodeString(keyB64)
+ if err != nil {
+ return false, fmt.Errorf("key decode: %w", err)
+ }
+ got := pbkdf2SHA256([]byte(password), salt, iter, len(want))
+ // constant-time compare
+ if len(got) != len(want) {
+ return false, nil
+ }
+ var diff byte
+ for i := range got {
+ diff |= got[i] ^ want[i]
+ }
+ return diff == 0, nil
+}
diff --git a/internal/security/ratelimit.go b/internal/security/ratelimit.go
new file mode 100644
index 0000000..051b46e
--- /dev/null
+++ b/internal/security/ratelimit.go
@@ -0,0 +1,80 @@
+package security
+
+import (
+ "net/http"
+ "sync"
+ "time"
+)
+
+type bucket struct {
+ tokens float64
+ last time.Time
+ blocked time.Time
+}
+
+type RateLimiter struct {
+ mu sync.Mutex
+ capacity float64
+ refillPer float64 // tokens/sec
+ ttl time.Duration
+ buckets map[string]*bucket
+}
+
+func NewRateLimiter(capacity int, refillPerSec float64, ttl time.Duration) *RateLimiter {
+ return &RateLimiter{
+ capacity: float64(capacity),
+ refillPer: refillPerSec,
+ ttl: ttl,
+ buckets: map[string]*bucket{},
+ }
+}
+
+func (rl *RateLimiter) Allow(key string) bool {
+ now := time.Now()
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ b := rl.buckets[key]
+ if b == nil {
+ b = &bucket{tokens: rl.capacity, last: now}
+ rl.buckets[key] = b
+ }
+ // cleanup occasionally
+ if len(rl.buckets) > 10000 {
+ for k, v := range rl.buckets {
+ if now.Sub(v.last) > rl.ttl {
+ delete(rl.buckets, k)
+ }
+ }
+ }
+
+ elapsed := now.Sub(b.last).Seconds()
+ if elapsed > 0 {
+ b.tokens += elapsed * rl.refillPer
+ if b.tokens > rl.capacity {
+ b.tokens = rl.capacity
+ }
+ b.last = now
+ }
+ if b.tokens >= 1 {
+ b.tokens -= 1
+ return true
+ }
+ return false
+}
+
+func (rl *RateLimiter) Middleware(keyFn func(r *http.Request) string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ key := keyFn(r)
+ if key == "" {
+ key = "anon"
+ }
+ if !rl.Allow(key) {
+ w.Header().Set("Retry-After", "2")
+ http.Error(w, "rate limited", http.StatusTooManyRequests)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/internal/security/realip.go b/internal/security/realip.go
new file mode 100644
index 0000000..5098354
--- /dev/null
+++ b/internal/security/realip.go
@@ -0,0 +1,53 @@
+package security
+
+import (
+ "net"
+ "net/http"
+ "strings"
+)
+
+type RealIPConfig struct {
+ TrustedProxies []*net.IPNet
+}
+
+func (c RealIPConfig) IsTrusted(remoteAddr string) bool {
+ host, _, err := net.SplitHostPort(remoteAddr)
+ if err != nil {
+ host = remoteAddr
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ for _, n := range c.TrustedProxies {
+ if n.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// RealIP returns the best-effort client IP.
+// It only honors X-Forwarded-For when the direct peer is in TrustedProxies.
+func RealIP(r *http.Request, cfg RealIPConfig) string {
+ if cfg.IsTrusted(r.RemoteAddr) {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ // First IP is original client
+ parts := strings.Split(xff, ",")
+ if len(parts) > 0 {
+ ip := strings.TrimSpace(parts[0])
+ if net.ParseIP(ip) != nil {
+ return ip
+ }
+ }
+ }
+ if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" && net.ParseIP(xrip) != nil {
+ return xrip
+ }
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err == nil && net.ParseIP(host) != nil {
+ return host
+ }
+ return r.RemoteAddr
+}
diff --git a/internal/security/sessions.go b/internal/security/sessions.go
new file mode 100644
index 0000000..c3b7e67
--- /dev/null
+++ b/internal/security/sessions.go
@@ -0,0 +1,122 @@
+package security
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+)
+
+type SessionManager struct {
+ cookieName string
+ secure bool
+ sameSite http.SameSite
+ maxAge time.Duration
+ aead cipher.AEAD
+}
+
+func NewSessionManager(secret []byte, cookieName string, secure bool) (*SessionManager, error) {
+ // Derive 32-byte key for AES-256-GCM
+ key := hmacSHA256(secret, []byte("ntfywui session v1"))
+ if len(key) != 32 {
+ return nil, io.ErrUnexpectedEOF
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ return &SessionManager{
+ cookieName: cookieName,
+ secure: secure,
+ sameSite: http.SameSiteLaxMode,
+ maxAge: 12 * time.Hour,
+ aead: aead,
+ }, nil
+}
+
+// Session contents are encrypted+authenticated.
+type Session struct {
+ User string `json:"user"`
+ Role string `json:"role"`
+ CSRF string `json:"csrf"`
+ Flash string `json:"flash,omitempty"`
+ IssuedAt int64 `json:"iat"`
+ ExpiresAt int64 `json:"exp"`
+}
+
+func (sm *SessionManager) Get(r *http.Request) (*Session, bool) {
+ c, err := r.Cookie(sm.cookieName)
+ if err != nil || c.Value == "" {
+ return &Session{}, false
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(c.Value)
+ if err != nil || len(raw) < sm.aead.NonceSize() {
+ return &Session{}, false
+ }
+ nonce := raw[:sm.aead.NonceSize()]
+ ct := raw[sm.aead.NonceSize():]
+ pt, err := sm.aead.Open(nil, nonce, ct, nil)
+ if err != nil {
+ return &Session{}, false
+ }
+ var s Session
+ if err := json.Unmarshal(pt, &s); err != nil {
+ return &Session{}, false
+ }
+ now := time.Now().Unix()
+ if s.ExpiresAt != 0 && now > s.ExpiresAt {
+ return &Session{}, false
+ }
+ return &s, true
+}
+
+func (sm *SessionManager) Save(w http.ResponseWriter, s *Session) error {
+ now := time.Now()
+ if s.IssuedAt == 0 {
+ s.IssuedAt = now.Unix()
+ }
+ if s.ExpiresAt == 0 {
+ s.ExpiresAt = now.Add(sm.maxAge).Unix()
+ }
+ pt, err := json.Marshal(s)
+ if err != nil {
+ return err
+ }
+ nonce := make([]byte, sm.aead.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return err
+ }
+ ct := sm.aead.Seal(nil, nonce, pt, nil)
+ raw := append(nonce, ct...)
+ val := base64.RawURLEncoding.EncodeToString(raw)
+ http.SetCookie(w, &http.Cookie{
+ Name: sm.cookieName,
+ Value: val,
+ Path: "/",
+ HttpOnly: true,
+ Secure: sm.secure,
+ SameSite: sm.sameSite,
+ MaxAge: int(sm.maxAge.Seconds()),
+ })
+ return nil
+}
+
+func (sm *SessionManager) Clear(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: sm.cookieName,
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: sm.secure,
+ SameSite: sm.sameSite,
+ MaxAge: -1,
+ })
+}
diff --git a/internal/security/totp.go b/internal/security/totp.go
new file mode 100644
index 0000000..cd66f87
--- /dev/null
+++ b/internal/security/totp.go
@@ -0,0 +1,63 @@
+package security
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha1"
+ "encoding/base32"
+ "encoding/binary"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// GenerateTOTPSecret returns a base32 secret without padding.
+func GenerateTOTPSecret() (string, error) {
+ b := make([]byte, 20)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ enc := base32.StdEncoding.WithPadding(base32.NoPadding)
+ return enc.EncodeToString(b), nil
+}
+
+// VerifyTOTP verifies a 6-digit token with ±1 step skew (30s step).
+func VerifyTOTP(secretBase32, code string, now time.Time) bool {
+ code = strings.ReplaceAll(code, " ", "")
+ if len(code) != 6 {
+ return false
+ }
+ sec, err := decodeBase32NoPad(secretBase32)
+ if err != nil {
+ return false
+ }
+ t := now.Unix() / 30
+ for _, drift := range []int64{-1, 0, 1} {
+ if hotp(sec, uint64(t+drift)) == code {
+ return true
+ }
+ }
+ return false
+}
+
+func hotp(secret []byte, counter uint64) string {
+ var buf [8]byte
+ binary.BigEndian.PutUint64(buf[:], counter)
+ mac := hmac.New(sha1.New, secret)
+ mac.Write(buf[:])
+ sum := mac.Sum(nil)
+
+ off := sum[len(sum)-1] & 0x0f
+ bin := (int(sum[off])&0x7f)<<24 |
+ (int(sum[off+1])&0xff)<<16 |
+ (int(sum[off+2])&0xff)<<8 |
+ (int(sum[off+3]) & 0xff)
+ otp := bin % 1000000
+ return fmt.Sprintf("%06d", otp)
+}
+
+func decodeBase32NoPad(s string) ([]byte, error) {
+ s = strings.ToUpper(strings.ReplaceAll(s, " ", ""))
+ enc := base32.StdEncoding.WithPadding(base32.NoPadding)
+ return enc.DecodeString(s)
+}
diff --git a/internal/security/version.go b/internal/security/version.go
new file mode 100644
index 0000000..689559a
--- /dev/null
+++ b/internal/security/version.go
@@ -0,0 +1,4 @@
+package security
+
+// Version is a dummy constant used to avoid unused imports in main.
+const Version = "v0"
diff --git a/internal/store/admin.go b/internal/store/admin.go
new file mode 100644
index 0000000..c4bed2d
--- /dev/null
+++ b/internal/store/admin.go
@@ -0,0 +1,150 @@
+package store
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/yourorg/ntfywui/internal/security"
+)
+
+type Role string
+
+const (
+ RoleViewer Role = "viewer"
+ RoleOperator Role = "operator"
+ RoleAdmin Role = "admin"
+)
+
+type Admin struct {
+ Username string `json:"username"`
+ Role Role `json:"role"`
+ PassHash string `json:"pass_hash"`
+ TOTPSecret string `json:"totp_secret,omitempty"` // base32, optional
+ Disabled bool `json:"disabled"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+type AdminStore struct {
+ mu sync.Mutex
+ path string
+ admin map[string]Admin
+}
+
+func NewAdminStore(path string) (*AdminStore, error) {
+ s := &AdminStore{path: path, admin: map[string]Admin{}}
+ if err := s.load(); err != nil {
+ // If file doesn't exist, start empty
+ if !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ }
+ return s, nil
+}
+
+func (s *AdminStore) load() error {
+ b, err := os.ReadFile(s.path)
+ if err != nil {
+ return err
+ }
+ var m map[string]Admin
+ if err := json.Unmarshal(b, &m); err != nil {
+ return err
+ }
+ s.admin = m
+ return nil
+}
+
+func (s *AdminStore) saveLocked() error {
+ tmp := s.path + ".tmp"
+ b, err := json.MarshalIndent(s.admin, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
+ return err
+ }
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
+
+func (s *AdminStore) EnsureBootstrap(username, password string) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if _, ok := s.admin[username]; ok {
+ return false, nil
+ }
+ salt := make([]byte, 16)
+ _, _ = randRead(salt)
+ hash := security.HashPasswordPBKDF2(password, salt, 120_000)
+ s.admin[username] = Admin{
+ Username: username,
+ Role: RoleAdmin,
+ PassHash: hash,
+ CreatedAt: time.Now().Unix(),
+ }
+ return true, s.saveLocked()
+}
+
+func (s *AdminStore) List() []Admin {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]Admin, 0, len(s.admin))
+ for _, a := range s.admin {
+ out = append(out, a)
+ }
+ // stable sort not necessary for UI
+ return out
+}
+
+func (s *AdminStore) Get(username string) (Admin, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ a, ok := s.admin[username]
+ return a, ok
+}
+
+func (s *AdminStore) Set(a Admin) error {
+ if a.Username == "" {
+ return errors.New("username required")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.admin[a.Username] = a
+ return s.saveLocked()
+}
+
+func (s *AdminStore) Delete(username string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.admin, username)
+ return s.saveLocked()
+}
+
+func (s *AdminStore) Authenticate(username, password, totp string) (Admin, bool) {
+ s.mu.Lock()
+ a, ok := s.admin[username]
+ s.mu.Unlock()
+ if !ok || a.Disabled {
+ return Admin{}, false
+ }
+ okpw, _ := security.VerifyPasswordPBKDF2(password, a.PassHash)
+ if !okpw {
+ return Admin{}, false
+ }
+ if a.TOTPSecret != "" {
+ if totp == "" {
+ return Admin{}, false
+ }
+ if !security.VerifyTOTP(a.TOTPSecret, totp, time.Now()) {
+ return Admin{}, false
+ }
+ }
+ return a, true
+}
diff --git a/internal/store/audit.go b/internal/store/audit.go
new file mode 100644
index 0000000..1dc92f6
--- /dev/null
+++ b/internal/store/audit.go
@@ -0,0 +1,100 @@
+package store
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type AuditEvent struct {
+ Time string `json:"time"`
+ Actor string `json:"actor"`
+ IP string `json:"ip,omitempty"`
+ UA string `json:"ua,omitempty"`
+ Action string `json:"action"`
+ Target string `json:"target,omitempty"`
+ Meta map[string]string `json:"meta,omitempty"`
+}
+
+type AuditLog struct {
+ mu sync.Mutex
+ path string
+ fh *os.File
+}
+
+func NewAuditLog(path string) (*AuditLog, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+ return nil, err
+ }
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+ if err != nil {
+ return nil, err
+ }
+ return &AuditLog{path: path, fh: f}, nil
+}
+
+func (a *AuditLog) Close() error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.fh != nil {
+ return a.fh.Close()
+ }
+ return nil
+}
+
+func (a *AuditLog) Append(ev AuditEvent) {
+ ev.Time = time.Now().UTC().Format(time.RFC3339Nano)
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.fh == nil {
+ return
+ }
+ b, _ := json.Marshal(ev)
+ _, _ = a.fh.Write(append(b, '\n'))
+}
+
+func (a *AuditLog) Tail(max int) ([]AuditEvent, error) {
+ // Simple tail by reading whole file (OK for small audit logs).
+ // You can replace with a smarter tail if needed.
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.fh == nil {
+ return nil, nil
+ }
+ _ = a.fh.Sync()
+ b, err := os.ReadFile(a.path)
+ if err != nil {
+ return nil, err
+ }
+ lines := splitLines(b)
+ if max > 0 && len(lines) > max {
+ lines = lines[len(lines)-max:]
+ }
+ out := make([]AuditEvent, 0, len(lines))
+ for _, ln := range lines {
+ var ev AuditEvent
+ if json.Unmarshal(ln, &ev) == nil {
+ out = append(out, ev)
+ }
+ }
+ return out, nil
+}
+
+func splitLines(b []byte) [][]byte {
+ var out [][]byte
+ start := 0
+ for i := 0; i < len(b); i++ {
+ if b[i] == '\n' {
+ if i > start {
+ out = append(out, b[start:i])
+ }
+ start = i + 1
+ }
+ }
+ if start < len(b) {
+ out = append(out, b[start:])
+ }
+ return out
+}
diff --git a/internal/store/rand.go b/internal/store/rand.go
new file mode 100644
index 0000000..d357602
--- /dev/null
+++ b/internal/store/rand.go
@@ -0,0 +1,5 @@
+package store
+
+import "crypto/rand"
+
+func randRead(b []byte) (int, error) { return rand.Read(b) }