This commit is contained in:
2026-01-12 13:51:52 +01:00
parent 90191c50d8
commit 06e55c441e
44 changed files with 3066 additions and 1 deletions

4
.env.example Normal file
View File

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

30
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

View File

@@ -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.

182
cmd/ntfywui/main.go Normal file
View File

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

9
data/admins.json Normal file
View File

@@ -0,0 +1,9 @@
{
"admin": {
"username": "admin",
"role": "admin",
"pass_hash": "pbkdf2_sha256$120000$zBmvdO4DjRapCKHSLCZAEQ$mo3uzViHoac1_OlhDdNiZIOMLg3eMbiMbuIQkJ6BrAA",
"disabled": false,
"created_at": 1768204361
}
}

3
data/audit.jsonl Normal file
View File

@@ -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"}

44
docker-compose.yml Normal file
View File

@@ -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:

15
examples/server.yml Normal file
View File

@@ -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"

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/yourorg/ntfywui
go 1.22

23
internal/app/assets.go Normal file
View File

@@ -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)
}

29
internal/app/flash.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

5
internal/app/rand.go Normal file
View File

@@ -0,0 +1,5 @@
package app
import "crypto/rand"
func randRead(b []byte) (int, error) { return rand.Read(b) }

63
internal/app/render.go Normal file
View File

@@ -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)
}

232
internal/app/server.go Normal file
View File

@@ -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()
}

28
internal/app/util.go Normal file
View File

@@ -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)
}

View File

@@ -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)}

View File

@@ -0,0 +1,61 @@
{{define "access.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
<h1>Access</h1>
{{template "partials_flash" .}}
<section class="card">
<h2>Grant</h2>
<form method="post" action="{{abs "/access"}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="grant">
<div class="grid">
<div>
<label>User</label>
<select name="username" required>
{{range .Users}}<option value="{{.Username}}">{{.Username}}</option>{{end}}
</select>
</div>
<div>
<label>Topic / Pattern</label>
<input name="topic" placeholder="alerts_* oder mytopic" required>
</div>
<div>
<label>Permission</label>
<select name="perm" required>
<option value="read-write">read-write</option>
<option value="read-only">read-only</option>
<option value="write-only">write-only</option>
<option value="deny">deny</option>
</select>
</div>
</div>
<button class="btn" type="submit">Setzen</button>
</form>
</section>
<section class="card">
<h2>Reset</h2>
<form method="post" action="{{abs "/access"}}" onsubmit="return confirm('Access wirklich resetten?')">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="reset">
<label>User</label>
<select name="username" required>
{{range .Users}}<option value="{{.Username}}">{{.Username}}</option>{{end}}
</select>
<button class="btn btn-danger" type="submit">Reset</button>
</form>
</section>
<div class="hint">
Hinweis: Access-Kontrolle basiert auf <code>ntfy access</code> und benötigt eine korrekt konfigurierte <code>auth-file</code> im <code>server.yml</code>.
</div>
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,105 @@
{{define "admins.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
<h1>WebUI Admins</h1>
{{template "partials_flash" .}}
<section class="card">
<h2>Neuen Admin erstellen</h2>
<form method="post" action="{{abs "/admins"}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="create">
<div class="grid">
<div><label>Username</label><input name="username" required></div>
<div><label>Passwort</label><input name="password" type="password" required></div>
<div>
<label>Rolle</label>
<select name="role">
<option value="viewer">viewer</option>
<option value="operator" selected>operator</option>
<option value="admin">admin</option>
</select>
</div>
</div>
<button class="btn" type="submit">Erstellen</button>
</form>
</section>
<section class="card">
<h2>Liste</h2>
<table class="table">
<thead><tr><th>User</th><th>Role</th><th>2FA</th><th>Status</th><th>Aktionen</th></tr></thead>
<tbody>
{{range .Admins}}
<tr>
<td>{{.Username}}</td>
<td>{{.Role}}</td>
<td>{{if .TOTPSecret}}<span class="pill">enabled</span>{{else}}<span class="pill pill-muted">off</span>{{end}}</td>
<td>{{if .Disabled}}<span class="pill pill-muted">disabled</span>{{else}}<span class="pill">active</span>{{end}}</td>
<td>
<form class="inline" method="post" action="{{abs "/admins"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="set-role">
<input type="hidden" name="username" value="{{.Username}}">
<select name="role">
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<button class="btn btn-ghost" type="submit">Set role</button>
</form>
<form class="inline" method="post" action="{{abs "/admins"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="set-pass">
<input type="hidden" name="username" value="{{.Username}}">
<input name="password" type="password" placeholder="new password">
<button class="btn btn-ghost" type="submit">Set pass</button>
</form>
<form class="inline" method="post" action="{{abs "/admins"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="toggle-disable">
<input type="hidden" name="username" value="{{.Username}}">
<button class="btn btn-ghost" type="submit">Toggle</button>
</form>
{{if .TOTPSecret}}
<form class="inline" method="post" action="{{abs "/admins"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="2fa-disable">
<input type="hidden" name="username" value="{{.Username}}">
<button class="btn btn-ghost" type="submit">2FA off</button>
</form>
{{else}}
<form class="inline" method="post" action="{{abs "/admins"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="2fa-enable">
<input type="hidden" name="username" value="{{.Username}}">
<button class="btn btn-ghost" type="submit">2FA on</button>
</form>
{{end}}
<form class="inline" method="post" action="{{abs "/admins"}}" onsubmit="return confirm('Admin löschen?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="username" value="{{.Username}}">
<button class="btn btn-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="hint">2FA Secret wird als Flash angezeigt. In einer Authenticator-App als TOTP-Secret (base32) hinzufügen.</div>
</section>
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,31 @@
{{define "audit.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
<h1>Audit Log (letzte 200)</h1>
{{template "partials_flash" .}}
<section class="card">
<table class="table">
<thead><tr><th>Time</th><th>Actor</th><th>IP</th><th>Action</th><th>Target</th></tr></thead>
<tbody>
{{range .Audit}}
<tr>
<td><code class="mono">{{.Time}}</code></td>
<td>{{.Actor}}</td>
<td><code class="mono">{{.IP}}</code></td>
<td>{{.Action}}</td>
<td>{{.Target}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,18 @@
{{define "error.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
<h1>Fehler</h1>
{{template "partials_flash" .}}
<section class="card">
<pre class="pre">{{.Error}}</pre>
<div class="hint">Tipp: In Docker sicherstellen, dass <code>/etc/ntfy/server.yml</code> und <code>/var/lib/ntfy</code> korrekt gemountet sind.</div>
</section>
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,38 @@
{{define "partials_head"}}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} {{end}}ntfywui</title>
<link rel="stylesheet" href="{{abs "/static/app.css"}}">
</head>
{{end}}
{{define "partials_nav"}}
<nav class="nav">
<div class="brand"><a href="{{abs "/users"}}">ntfywui</a></div>
{{if .Admin}}
<div class="links">
<a href="{{abs "/users"}}">Users</a>
{{if or (eq .Role "operator") (eq .Role "admin")}}<a href="{{abs "/access"}}">Access</a>{{end}}
{{if or (eq .Role "operator") (eq .Role "admin")}}<a href="{{abs "/tokens"}}">Tokens</a>{{end}}
{{if eq .Role "admin"}}<a href="{{abs "/admins"}}">Admins</a>{{end}}
{{if eq .Role "admin"}}<a href="{{abs "/audit"}}">Audit</a>{{end}}
</div>
<div class="user">
<span class="badge">{{.Admin}} ({{.Role}})</span>
<a class="btn btn-ghost" href="{{abs "/logout"}}">Logout</a>
</div>
{{end}}
</nav>
{{end}}
{{define "partials_flash"}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="flash flash-err">{{.Error}}</div>{{end}}
{{end}}
{{define "partials_footer"}}
<footer class="footer">
<div>ntfywui Webverwaltung für ntfy (CLI-basiert). Standardbibliothek-only.</div>
</footer>
{{end}}

View File

@@ -0,0 +1,29 @@
{{define "login.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
<div class="page center">
<div class="card">
<h1>Login</h1>
{{template "partials_flash" .}}
<form method="post" action="{{abs "/login"}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="next" value="{{.Next}}">
<label>Username</label>
<input name="username" autocomplete="username" required>
<label>Password</label>
<input name="password" type="password" autocomplete="current-password" required>
<label>TOTP (optional)</label>
<input name="totp" inputmode="numeric" autocomplete="one-time-code" placeholder="123456">
<button class="btn" type="submit">Anmelden</button>
</form>
<div class="hint">
Tipp: Setze <code>NTFYWUI_BOOTSTRAP_USER</code> und <code>NTFYWUI_BOOTSTRAP_PASS</code> für den ersten Admin.
</div>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,40 @@
{{define "tokens.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
<h1>Tokens</h1>
{{template "partials_flash" .}}
<section class="card">
<h2>Token erstellen</h2>
<form method="post" action="{{abs "/tokens"}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="add">
<div class="grid">
<div>
<label>User</label>
<select name="username" required>
{{range .Users}}<option value="{{.Username}}">{{.Username}}</option>{{end}}
</select>
</div>
<div>
<label>Label (optional)</label>
<input name="label">
</div>
<div>
<label>Expires (optional)</label>
<input name="expires" placeholder="120d, 24h, ...">
</div>
</div>
<button class="btn" type="submit">Create</button>
</form>
<div class="hint">Token wird als Flash angezeigt, danach nicht mehr.</div>
</section>
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,102 @@
{{define "user.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
{{template "partials_flash" .}}
{{with .User}}{{ $uname := .Username }}
<h1>User: {{.Username}}</h1>
<section class="card">
<h2>Details</h2>
<div class="grid">
<div><span class="muted">Role</span><div>{{.Role}}</div></div>
<div><span class="muted">Tier</span><div>{{.Tier}}</div></div>
</div>
</section>
{{if or (eq $.Role "operator") (eq $.Role "admin")}}
<section class="card">
<h2>Aktionen</h2>
<div class="grid">
<form method="post" action="{{abs (print "/users/" .Username "/password")}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Neues Passwort</label>
<input name="password" type="password" required>
<button class="btn" type="submit">Passwort ändern</button>
</form>
<form method="post" action="{{abs (print "/users/" .Username "/role")}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Role</label>
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<button class="btn" type="submit">Rolle setzen</button>
</form>
<form method="post" action="{{abs (print "/users/" .Username "/tier")}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Tier</label>
<input name="tier" placeholder="none/pro/...">
<button class="btn" type="submit">Tier setzen</button>
</form>
</div>
<form method="post" action="{{abs (print "/users/" .Username "/delete")}}" onsubmit="return confirm('User wirklich löschen?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn btn-danger" type="submit">User löschen</button>
</form>
</section>
<section class="card">
<h2>Access</h2>
{{range .Access}}<div class="pill">{{.Perm}} → {{.Topic}}</div>{{end}}
<div class="hint">Access wird mit <code>ntfy access</code> verwaltet (siehe Access-Seite).</div>
</section>
<section class="card">
<h2>Tokens</h2>
{{if $.Tokens}}
{{range $.Tokens}}
<div class="row">
<code class="mono">{{.Token}}</code>
<form method="post" action="{{abs "/tokens"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="username" value="{{$uname}}">
<input type="hidden" name="token" value="{{.Token}}">
<button class="btn btn-ghost" type="submit">Remove</button>
</form>
</div>
{{end}}
{{else}}
<div class="muted">Keine Tokens (oder nicht auslesbar).</div>
{{end}}
<h3>Token hinzufügen</h3>
<form method="post" action="{{abs "/tokens"}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="add">
<input type="hidden" name="username" value="{{.Username}}">
<div class="grid">
<div><label>Label (optional)</label><input name="label"></div>
<div><label>Expires (optional)</label><input name="expires" placeholder="120d, 24h, ..."></div>
</div>
<button class="btn" type="submit">Token erstellen</button>
<div class="hint">Der Token wird nur einmal als Flash angezeigt direkt kopieren.</div>
</form>
</section>
{{end}}
{{end}}
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,65 @@
{{define "users.html"}}
<!doctype html>
<html lang="de">
{{template "partials_head" .}}
<body>
{{template "partials_nav" .}}
<main class="page">
<h1>Users</h1>
{{template "partials_flash" .}}
{{if or (eq .Role "operator") (eq .Role "admin")}}
<section class="card">
<h2>Neuen ntfy User erstellen</h2>
<form method="post" action="{{abs "/users"}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="create">
<div class="grid">
<div>
<label>Username</label>
<input name="username" required>
</div>
<div>
<label>Passwort</label>
<input name="password" type="password" required>
</div>
<div>
<label>Rolle</label>
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div>
<label>Tier (optional)</label>
<input name="tier" placeholder="none/pro/...">
</div>
</div>
<button class="btn" type="submit">Erstellen</button>
</form>
</section>
{{end}}
<section class="card">
<h2>Liste</h2>
<table class="table">
<thead><tr><th>User</th><th>Role</th><th>Tier</th><th>Access</th></tr></thead>
<tbody>
{{range .Users}}
<tr>
<td><a href="{{abs (print "/users/" .Username)}}">{{.Username}}</a></td>
<td>{{.Role}}</td>
<td>{{.Tier}}</td>
<td>
{{range .Access}}<span class="pill">{{.Perm}} → {{.Topic}}</span> {{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
{{template "partials_footer" .}}
</body>
</html>
{{end}}

278
internal/ntfy/ntfy.go Normal file
View File

@@ -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
}

52
internal/security/csrf.go Normal file
View File

@@ -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)
})
}
}

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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
}

View File

@@ -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,
})
}

63
internal/security/totp.go Normal file
View File

@@ -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)
}

View File

@@ -0,0 +1,4 @@
package security
// Version is a dummy constant used to avoid unused imports in main.
const Version = "v0"

150
internal/store/admin.go Normal file
View File

@@ -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
}

100
internal/store/audit.go Normal file
View File

@@ -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
}

5
internal/store/rand.go Normal file
View File

@@ -0,0 +1,5 @@
package store
import "crypto/rand"
func randRead(b []byte) (int, error) { return rand.Read(b) }