Compare commits
2 Commits
90191c50d8
...
da56e85edf
| Author | SHA1 | Date | |
|---|---|---|---|
| da56e85edf | |||
| 06e55c441e |
4
.env.example
Normal file
4
.env.example
Normal 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
|
||||||
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: release-tag
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
jobs:
|
||||||
|
release-image:
|
||||||
|
runs-on: ubuntu-fast
|
||||||
|
env:
|
||||||
|
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
|
||||||
|
DOCKER_LATEST: latest
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with: # replace it with your local IP
|
||||||
|
config-inline: |
|
||||||
|
[registry."${{ vars.DOCKER_REGISTRY }}"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Get Meta
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: | # replace it with your local IP and tags
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Build Go binary
|
||||||
|
FROM golang:1.25-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.23
|
||||||
|
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
21
LICENSE
Normal 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.
|
||||||
69
README.md
69
README.md
@@ -1,2 +1,69 @@
|
|||||||
# ntfywui
|
# ntfywui (Go) – Webinterface für ntfy User/ACL/Token Verwaltung
|
||||||
|
|
||||||
|
Dieses Projekt stellt ein **sicheres Webinterface** bereit, um die ntfy **CLI-Administration** (Users, ACL/Access, Tokens) bequem im Browser zu machen.
|
||||||
|
|
||||||
|
- **Golang, Standardbibliothek-only** (keine externen deps)
|
||||||
|
- Arbeitet **gegen die lokale ntfy Auth-DB** über die ntfy CLI (im Container enthalten)
|
||||||
|
- **CSRF-Schutz**, **Secure Headers (CSP)**, **verschlüsselte Sessions (AES-GCM)**, **Rate-Limit**
|
||||||
|
- **WebUI-Admins** separat von ntfy-Users (inkl. optionalem TOTP-2FA)
|
||||||
|
- **Audit Log** (JSONL)
|
||||||
|
|
||||||
|
## Quickstart (Docker Compose)
|
||||||
|
|
||||||
|
1) `.env` anlegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# SECRET erzeugen (Beispiel): openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Stelle sicher, dass `/etc/ntfy/server.yml` Authentication aktiv hat, z.B.:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
|
auth-default-access: "deny-all"
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- ntfy: http://localhost:8080
|
||||||
|
- ntfywui: http://localhost:8090
|
||||||
|
|
||||||
|
## Wichtige Hinweise (Sicherheit)
|
||||||
|
|
||||||
|
- **NTFYWUI_SECRET ist Pflicht** (>=32 Bytes; base64 empfohlen).
|
||||||
|
- Setze `NTFYWUI_COOKIE_SECURE=true` (Default). Für reines HTTP-Lab ggf. `false`.
|
||||||
|
- Wenn du einen Reverse Proxy davor hast, setze `NTFYWUI_TRUST_PROXY` nur auf **deine Proxy-CIDRs**,
|
||||||
|
damit `X-Forwarded-For` nicht spoofbar ist.
|
||||||
|
- Vergib WebUI-Admin-Rechte sparsam:
|
||||||
|
- `viewer`: read-only (Users anzeigen)
|
||||||
|
- `operator`: Users/ACL/Tokens verwalten
|
||||||
|
- `admin`: zusätzlich Admins/Audit verwalten
|
||||||
|
|
||||||
|
## Konfiguration (Env/Flags)
|
||||||
|
|
||||||
|
- `NTFYWUI_LISTEN` (Default `:8080`)
|
||||||
|
- `NTFYWUI_BASE_PATH` (z.B. `/ntfywui`)
|
||||||
|
- `NTFYWUI_DATA_DIR` (Default `/data`) – `admins.json`, `audit.jsonl`
|
||||||
|
- `NTFYWUI_SECRET` (required)
|
||||||
|
- `NTFYWUI_COOKIE_SECURE` (Default `true`)
|
||||||
|
- `NTFYWUI_TRUST_PROXY` (CIDRs, z.B. `10.0.0.0/8,172.16.0.0/12`)
|
||||||
|
- `NTFYWUI_NTFY_BIN` (Default `/usr/bin/ntfy`)
|
||||||
|
- `NTFYWUI_NTFY_CONFIG` (Default `/etc/ntfy/server.yml`)
|
||||||
|
- `NTFYWUI_NTFY_TIMEOUT` (Default `10s`)
|
||||||
|
- Bootstrap:
|
||||||
|
- `NTFYWUI_BOOTSTRAP_USER`
|
||||||
|
- `NTFYWUI_BOOTSTRAP_PASS`
|
||||||
|
|
||||||
|
## Limitierungen
|
||||||
|
|
||||||
|
- Das UI parst die Textausgabe von `ntfy user list` / `ntfy token list` best-effort.
|
||||||
|
- Für sehr große Auth-DBs kann `Audit Tail` (einfaches File-Read) angepasst werden.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT – mach damit was du willst.
|
||||||
|
|||||||
182
cmd/ntfywui/main.go
Normal file
182
cmd/ntfywui/main.go
Normal 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
9
data/admins.json
Normal 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
3
data/audit.jsonl
Normal 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
44
docker-compose.yml
Normal 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
15
examples/server.yml
Normal 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"
|
||||||
23
internal/app/assets.go
Normal file
23
internal/app/assets.go
Normal 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
29
internal/app/flash.go
Normal 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
|
||||||
|
}
|
||||||
74
internal/app/handlers_access.go
Normal file
74
internal/app/handlers_access.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
199
internal/app/handlers_admins.go
Normal file
199
internal/app/handlers_admins.go
Normal 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"
|
||||||
|
}
|
||||||
33
internal/app/handlers_audit.go
Normal file
33
internal/app/handlers_audit.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
88
internal/app/handlers_auth.go
Normal file
88
internal/app/handlers_auth.go
Normal 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
|
||||||
|
}
|
||||||
77
internal/app/handlers_tokens.go
Normal file
77
internal/app/handlers_tokens.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
186
internal/app/handlers_users.go
Normal file
186
internal/app/handlers_users.go
Normal 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
5
internal/app/rand.go
Normal 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
63
internal/app/render.go
Normal 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
232
internal/app/server.go
Normal 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
28
internal/app/util.go
Normal 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)
|
||||||
|
}
|
||||||
131
internal/app/web/static/app.css
Normal file
131
internal/app/web/static/app.css
Normal 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)}
|
||||||
61
internal/app/web/templates/access.html
Normal file
61
internal/app/web/templates/access.html
Normal 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}}
|
||||||
105
internal/app/web/templates/admins.html
Normal file
105
internal/app/web/templates/admins.html
Normal 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}}
|
||||||
31
internal/app/web/templates/audit.html
Normal file
31
internal/app/web/templates/audit.html
Normal 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}}
|
||||||
18
internal/app/web/templates/error.html
Normal file
18
internal/app/web/templates/error.html
Normal 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}}
|
||||||
38
internal/app/web/templates/layout.html
Normal file
38
internal/app/web/templates/layout.html
Normal 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}}
|
||||||
29
internal/app/web/templates/login.html
Normal file
29
internal/app/web/templates/login.html
Normal 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}}
|
||||||
40
internal/app/web/templates/tokens.html
Normal file
40
internal/app/web/templates/tokens.html
Normal 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}}
|
||||||
102
internal/app/web/templates/user.html
Normal file
102
internal/app/web/templates/user.html
Normal 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}}
|
||||||
65
internal/app/web/templates/users.html
Normal file
65
internal/app/web/templates/users.html
Normal 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
278
internal/ntfy/ntfy.go
Normal 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
52
internal/security/csrf.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/security/headers.go
Normal file
27
internal/security/headers.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
96
internal/security/pbkdf2.go
Normal file
96
internal/security/pbkdf2.go
Normal 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
|
||||||
|
}
|
||||||
80
internal/security/ratelimit.go
Normal file
80
internal/security/ratelimit.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/security/realip.go
Normal file
53
internal/security/realip.go
Normal 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
|
||||||
|
}
|
||||||
122
internal/security/sessions.go
Normal file
122
internal/security/sessions.go
Normal 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
63
internal/security/totp.go
Normal 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)
|
||||||
|
}
|
||||||
4
internal/security/version.go
Normal file
4
internal/security/version.go
Normal 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
150
internal/store/admin.go
Normal 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
100
internal/store/audit.go
Normal 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
5
internal/store/rand.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "crypto/rand"
|
||||||
|
|
||||||
|
func randRead(b []byte) (int, error) { return rand.Read(b) }
|
||||||
Reference in New Issue
Block a user