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

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