Files
notify-gateway/internal/httpserver/server.go
T
2026-09-16 06:26:16 +02:00

514 lines
17 KiB
Go

package httpserver
import (
"crypto/sha256"
"crypto/subtle"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"html/template"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/example/notify-gateway/internal/auth"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/divera"
"github.com/example/notify-gateway/internal/gateway"
"github.com/example/notify-gateway/internal/mailingress"
"github.com/example/notify-gateway/internal/model"
"github.com/example/notify-gateway/internal/outbox"
)
type Server struct {
queue *gateway.Queue
mail *mailingress.Poller
loginMu sync.Mutex
logins map[string]loginBucket
store *config.Store
dispatcher *gateway.Dispatcher
divera *divera.Client
logger *log.Logger
}
func New(store *config.Store, dispatcher *gateway.Dispatcher, client *divera.Client, logger *log.Logger) *Server {
return &Server{store: store, dispatcher: dispatcher, divera: client, logger: logger}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.ready)
mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /ui/api/csrf", s.requireAdmin(s.csrf))
mux.HandleFunc("GET /ui/api/deliveries", s.requireAdmin(s.deliveries))
mux.HandleFunc("GET /ui/api/deliveries/{id}/attempts", s.requireAdmin(s.deliveryHistory))
mux.HandleFunc("POST /ui/api/deliveries/{id}/retry", s.requireAdmin(s.retryDelivery))
mux.HandleFunc("POST /in/discord", s.discord)
mux.HandleFunc("POST /login", s.login)
mux.HandleFunc("GET /login", s.loginPage)
mux.HandleFunc("POST /logout", s.requireAdmin(s.logout))
mux.HandleFunc("GET /ui/", s.requireAdmin(s.ui))
mux.HandleFunc("GET /ui/api/config", s.requireAdmin(s.apiConfig))
mux.HandleFunc("PUT /ui/api/config", s.requireAdmin(s.apiConfig))
mux.HandleFunc("GET /ui/api/divera247/catalog", s.requireAdmin(s.apiDiveraCatalog))
mux.HandleFunc("POST /ui/api/preview", s.requireAdmin(s.apiPreview))
mux.HandleFunc("POST /ui/password", s.requireAdmin(s.changePassword))
mux.HandleFunc("POST /ui/test-divera", s.requireAdmin(s.testDivera))
mux.HandleFunc("POST /in/webhook/{channel}", s.webhook)
mux.HandleFunc("PUT /in/webhook/{channel}", s.webhook)
mux.HandleFunc("POST /in/ntfy", s.ntfyJSON)
mux.HandleFunc("PUT /in/ntfy", s.ntfyJSON)
mux.HandleFunc("POST /ntfy", s.ntfyJSON)
mux.HandleFunc("PUT /ntfy", s.ntfyJSON)
mux.HandleFunc("POST /in/ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("PUT /in/ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("GET /in/ntfy/{topic}/trigger", s.ntfyTrigger)
mux.HandleFunc("GET /in/ntfy/{topic}/send", s.ntfyTrigger)
mux.HandleFunc("GET /in/ntfy/{topic}/publish", s.ntfyTrigger)
mux.HandleFunc("POST /ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("PUT /ntfy/{topic}", s.ntfyTopic)
mux.HandleFunc("GET /ntfy/{topic}/trigger", s.ntfyTrigger)
mux.HandleFunc("GET /ntfy/{topic}/send", s.ntfyTrigger)
mux.HandleFunc("GET /ntfy/{topic}/publish", s.ntfyTrigger)
mux.HandleFunc("POST /in/gotify/message", s.gotify)
mux.HandleFunc("POST /message", s.gotify)
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusFound) })
return securityHeaders(requestProtection(mux))
}
func securityHeaders(next http.Handler) http.Handler {
script := strings.Split(strings.Split(uiHTML, "<script>")[1], "</script>")[0]
// HTML parsing normalizes CRLF before CSP hashes are checked.
sum := sha256.Sum256([]byte(strings.ReplaceAll(script, "\r\n", "\n")))
scriptHash := base64.StdEncoding.EncodeToString(sum[:])
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'sha256-"+scriptHash+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true, "time": time.Now().UTC()})
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { renderLogin(w, "") }
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !s.allowLogin(r.RemoteAddr) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many login attempts", 429)
return
}
_ = r.ParseForm()
cfg := s.store.Get()
if r.Form.Get("username") != cfg.Server.AdminUsername || !auth.CheckPassword(cfg.Server.AdminPasswordHash, r.Form.Get("password")) {
renderLogin(w, "Login fehlgeschlagen")
return
}
if strings.HasPrefix(cfg.Server.AdminPasswordHash, "sha256$") && len(r.Form.Get("password")) <= 72 {
h, err := auth.HashPassword(r.Form.Get("password"))
if err != nil {
http.Error(w, "password migration failed", 500)
return
}
if err = s.store.Update(func(c *config.Config) error { c.Server.AdminPasswordHash = h; return nil }); err != nil {
http.Error(w, "password migration failed", 500)
return
}
}
token := auth.SignSession(cfg.Server.SessionSecret, cfg.Server.AdminUsername, time.Now().Add(12*time.Hour))
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil, MaxAge: 43200})
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, SameSite: http.SameSiteStrictMode})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Get()
c, err := r.Cookie("ng_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
u, ok := auth.VerifySession(cfg.Server.SessionSecret, c.Value, time.Now())
if !ok || u != cfg.Server.AdminUsername {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method != "GET" && r.Method != "HEAD" {
token := r.Header.Get("X-CSRF-Token")
if token == "" && strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
_ = r.ParseForm()
token = r.Form.Get("csrf_token")
}
if subtle.ConstantTimeCompare([]byte(token), []byte(csrfToken(cfg.Server.SessionSecret, c.Value))) != 1 {
http.Error(w, "invalid CSRF token", 403)
return
}
}
next(w, r)
}
}
//go:embed ui.html
var uiHTML string
func (s *Server) ui(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, uiHTML)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p := r.Form.Get("password")
if len(p) < 10 || len(p) > 72 {
http.Error(w, "Passwort muss 10 bis 72 UTF-8-Bytes enthalten", http.StatusBadRequest)
return
}
h, err := auth.HashPassword(p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.Update(func(c *config.Config) error {
c.Server.AdminPasswordHash = h
c.Server.SessionSecret = auth.RandomSecret()
return nil
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
}
func (s *Server) testDivera(w http.ResponseWriter, r *http.Request) {
resp, err := s.divera.PullAll(r.Context(), nil)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
}
func (s *Server) authorized(source, channel string, r *http.Request) bool {
cfg := s.store.Get()
if cfg.Ingress.AllowUnauthenticated {
return true
}
token := bearerOrToken(r)
var allowed []string
switch source {
case "ntfy":
if t := cfg.Ingress.NtfyTokens[channel]; t != "" {
allowed = append(allowed, t)
}
if t := cfg.Ingress.NtfyTokens["*"]; t != "" {
allowed = append(allowed, t)
}
case "webhook":
if t := cfg.Ingress.WebhookTokens[channel]; t != "" {
allowed = append(allowed, t)
}
if t := cfg.Ingress.WebhookTokens["*"]; t != "" {
allowed = append(allowed, t)
}
case "gotify":
allowed = append(allowed, cfg.Ingress.GotifyTokens...)
}
for _, a := range allowed {
if len(a) == len(token) && subtle.ConstantTimeCompare([]byte(a), []byte(token)) == 1 {
return true
}
}
return false
}
func bearerOrToken(r *http.Request) string {
if t := r.URL.Query().Get("token"); t != "" {
return t
}
if t := r.Header.Get("X-Gotify-Key"); t != "" {
return t
}
a := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(a), "bearer ") {
return strings.TrimSpace(a[7:])
}
if u, p, ok := r.BasicAuth(); ok {
if p != "" {
return p
}
return u
}
return ""
}
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
channel := r.PathValue("channel")
if !s.authorized("webhook", channel, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
raw, body, err := decodeFlexible(r)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
msg := model.InboundMessage{Source: "webhook", Channel: channel, Title: str(raw, "title", "subject"), Message: firstNonEmpty(str(raw, "message", "text", "body"), body), Address: str(raw, "address", "location"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) ntfyJSON(w http.ResponseWriter, r *http.Request) {
var raw map[string]any
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&raw); err != nil {
writeJSON(w, 400, map[string]any{"error": "invalid ntfy json"})
return
}
topic := str(raw, "topic")
if topic == "" {
writeJSON(w, 400, map[string]any{"error": "topic required"})
return
}
if !s.authorized("ntfy", topic, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: str(raw, "title"), Message: str(raw, "message"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) ntfyTopic(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !s.authorized("ntfy", topic, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
raw, body, err := decodeNtfy(r)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
title := firstNonEmpty(r.Header.Get("Title"), r.Header.Get("X-Title"), str(raw, "title"))
message := firstNonEmpty(str(raw, "message"), body)
priority := intval(raw, "priority")
if priority == 0 {
priority = parsePriority(firstNonEmpty(r.Header.Get("Priority"), r.Header.Get("X-Priority")))
}
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: title, Message: message, Priority: priority, Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) ntfyTrigger(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !s.authorized("ntfy", topic, r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: r.URL.Query().Get("title"), Message: firstNonEmpty(r.URL.Query().Get("message"), "triggered"), Priority: parsePriority(r.URL.Query().Get("priority")), Raw: map[string]any{"query": r.URL.Query()}, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) gotify(w http.ResponseWriter, r *http.Request) {
if !s.authorized("gotify", "", r) {
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
return
}
raw, body, err := decodeFlexible(r)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
if len(raw) == 0 {
_ = r.ParseForm()
raw = map[string]any{"title": r.Form.Get("title"), "message": r.Form.Get("message"), "priority": r.Form.Get("priority")}
}
msg := model.InboundMessage{Source: "gotify", Channel: firstNonEmpty(str(raw, "channel"), "default"), Title: str(raw, "title"), Message: firstNonEmpty(str(raw, "message"), body), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
s.deliver(w, r, msg)
}
func (s *Server) deliver(w http.ResponseWriter, r *http.Request, msg model.InboundMessage) {
if s.queue != nil {
// Never accept a gateway-generated HTTP notification back into the gateway.
if r.Header.Get("X-Notify-Gateway-Delivery") != "" {
writeJSON(w, 409, map[string]any{"error": "gateway loop detected"})
return
}
receipt, err := s.queue.Accept(r.Context(), msg, r.Header.Get("Idempotency-Key"))
if err != nil {
status := 503
var input *gateway.InputError
if errors.As(err, &input) {
status = 400
}
if errors.Is(err, outbox.ErrConflict) {
status = 409
}
message := "outbox unavailable"
if status != 503 {
message = err.Error()
}
writeJSON(w, status, map[string]any{"error": message})
return
}
writeJSON(w, 202, map[string]any{"ok": true, "receipt": receipt})
return
}
results, err := s.dispatcher.Dispatch(r.Context(), msg)
if err != nil {
s.logger.Printf("delivery failed: %v", err)
writeJSON(w, 502, map[string]any{"error": err.Error(), "results": results})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "results": results})
}
func decodeNtfy(r *http.Request) (map[string]any, string, error) {
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, "", err
}
raw := map[string]any{}
if strings.Contains(r.Header.Get("Content-Type"), "application/json") || (len(b) > 0 && b[0] == '{') {
if err := json.Unmarshal(b, &raw); err != nil {
return nil, "", err
}
return raw, "", nil
}
return raw, string(b), nil
}
func decodeFlexible(r *http.Request) (map[string]any, string, error) {
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, "", err
}
raw := map[string]any{}
ct := r.Header.Get("Content-Type")
if strings.Contains(ct, "application/json") || (len(b) > 0 && b[0] == '{') {
if err := json.Unmarshal(b, &raw); err != nil {
return nil, "", err
}
return raw, "", nil
}
if strings.Contains(ct, "application/x-www-form-urlencoded") {
vals, err := urlParse(string(b))
if err != nil {
return nil, "", err
}
for k, v := range vals {
if len(v) > 0 {
raw[k] = v[0]
}
}
return raw, "", nil
}
return raw, string(b), nil
}
func urlParse(s string) (map[string][]string, error) {
vals, err := netURLParseQuery(s)
return map[string][]string(vals), err
}
var netURLParseQuery = func(s string) (map[string][]string, error) {
out := map[string][]string{}
for _, p := range strings.Split(s, "&") {
if p == "" {
continue
}
kv := strings.SplitN(p, "=", 2)
k := strings.ReplaceAll(kv[0], "+", " ")
v := ""
if len(kv) > 1 {
v = strings.ReplaceAll(kv[1], "+", " ")
}
ku, err := urlQueryUnescape(k)
if err != nil {
return nil, err
}
vu, err := urlQueryUnescape(v)
if err != nil {
return nil, err
}
out[ku] = append(out[ku], vu)
}
return out, nil
}
var urlQueryUnescape = func(s string) (string, error) { return queryUnescape(s) }
func queryUnescape(s string) (string, error) { // minimal wrapper avoids exposing net/url in helpers
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s) {
n, err := strconv.ParseUint(s[i+1:i+3], 16, 8)
if err != nil {
return "", err
}
b.WriteByte(byte(n))
i += 2
} else {
b.WriteByte(s[i])
}
}
return b.String(), nil
}
func str(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
switch x := v.(type) {
case string:
return x
case json.Number:
return x.String()
}
}
}
return ""
}
func intval(m map[string]any, k string) int {
v, ok := m[k]
if !ok {
return 0
}
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case string:
n, _ := strconv.Atoi(x)
return n
case json.Number:
n, _ := strconv.Atoi(x.String())
return n
}
return 0
}
func parsePriority(s string) int { n, _ := strconv.Atoi(s); return n }
func firstNonEmpty(v ...string) string {
for _, s := range v {
if s != "" {
return s
}
}
return ""
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
var loginTpl = template.Must(template.New("login").Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Notify Gateway Login</title><style>body{font:16px system-ui;max-width:420px;margin:10vh auto;padding:1rem}input,button{width:100%;padding:.7rem;margin:.35rem 0;box-sizing:border-box}.err{color:#a00}</style></head><body><h1>Notify Gateway</h1>{{if .}}<p class="err">{{.}}</p>{{end}}<form method="post" action="/login"><input name="username" placeholder="Benutzer" autocomplete="username"><input type="password" name="password" placeholder="Passwort" autocomplete="current-password"><button>Anmelden</button></form></body></html>`))
func renderLogin(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTpl.Execute(w, msg)
}