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" .}} + +
+

Grant

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Reset

+
+ + + + + +
+
+ +
+ 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

+ + + + {{range .Admins}} + + + + + + + + {{end}} + +
UserRole2FAStatusAktionen
{{.Username}}{{.Role}}{{if .TOTPSecret}}enabled{{else}}off{{end}}{{if .Disabled}}disabled{{else}}active{{end}} +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + +
+ + {{if .TOTPSecret}} +
+ + + + +
+ {{else}} +
+ + + + +
+ {{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" .}} + +
+ + + + {{range .Audit}} + + + + + + + + {{end}} + +
TimeActorIPActionTarget
{{.Time}}{{.Actor}}{{.IP}}{{.Action}}{{.Target}}
+
+
+{{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}}

+ +
+

Details

+
+
Role
{{.Role}}
+
Tier
{{.Tier}}
+
+
+ + {{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

+
+ + + +
+
+
+
+ +
Der Token wird nur einmal als Flash angezeigt – direkt kopieren.
+
+
+ {{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

+ + + + {{range .Users}} + + + + + + + {{end}} + +
UserRoleTierAccess
{{.Username}}{{.Role}}{{.Tier}} + {{range .Access}}{{.Perm}} → {{.Topic}} {{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) }