feat(api): Phase 2 — REST-API MVP + CRUD für Domains/Backends/Routing
REST-API mit Response-Envelope (1:1 mail-gateway), HS256-JWT-Signer (Secret persistent unter /var/lib/edgeguard/.jwt_fingerprint), Setup-Wizard (Bcrypt-Admin-Passwort in setup.json), Auth-Middleware (Cookie + Bearer), Setup-Gate. Update-Banner-Endpoints /system/package-versions + /system/upgrade ab Tag 1 wired (Pattern aus enconf-management-agent: systemd-run detached, HTTP-Response geht VOR dem Self-Replace raus). CRUD-Repos für domains/backends/routing_rules mit pgxpool + handgeschriebenem SQL (mail-gateway-Pattern, kein GORM zur Laufzeit). Audit-Log-Schreiber auf jede Mutation, NodeID aus /etc/machine-id. DB-Pool öffnet best-effort — ohne erreichbare PG bleiben CRUD-Routen unregistriert, Auth/Setup/System antworten weiter (Dev ohne PG). End-to-end live-getestet gegen lokale postgres-16: Setup → Login → POST/PUT/DELETE Backends + Domains + Routing-Rules → audit_log schreibt 5 Zeilen mit korrektem actor/action/subject. Graceful degrade ohne DB ebenfalls verifiziert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
internal/handlers/middleware.go
Normal file
120
internal/handlers/middleware.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenContextKey = "edgeguard_token"
|
||||
cookieName = "edgeguard_session"
|
||||
)
|
||||
|
||||
// Recover catches panics and returns a 500 with the response envelope
|
||||
// shape (gin.Recovery defaults to bare HTML). Logs the panic.
|
||||
func Recover() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
slog.Error("panic in handler",
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
"panic", rec,
|
||||
)
|
||||
response.Internal(c, errors.New("internal server error"))
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SetupGate aborts with 503 setup_required for any non-setup route
|
||||
// while setup.Completed is false. Lets the UI's axios client redirect
|
||||
// to /setup on first boot.
|
||||
//
|
||||
// Pass-through prefixes: /api/v1/setup/*, /api/v1/system/health,
|
||||
// /api/health, /healthz.
|
||||
func SetupGate(store *setup.Store) gin.HandlerFunc {
|
||||
allow := []string{
|
||||
"/api/v1/setup/",
|
||||
"/api/v1/system/health",
|
||||
"/api/health",
|
||||
"/healthz",
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
for _, p := range allow {
|
||||
if strings.HasPrefix(path, p) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
st, err := store.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !st.Completed {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
|
||||
response.Envelope{Data: nil, Error: ptr("setup_required"), Message: "setup_required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth verifies the JWT from the cookie or Authorization Bearer
|
||||
// header, stashes the Token in the gin context. Endpoints that don't
|
||||
// need auth must be mounted before this middleware in the route tree.
|
||||
func RequireAuth(signer *session.Signer) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := tokenFromRequest(c)
|
||||
if raw == "" {
|
||||
response.Unauthorized(c, errors.New("missing session"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
tok, err := signer.Verify(raw)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(tokenContextKey, tok)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentToken returns the verified session token previously stashed
|
||||
// by RequireAuth, or nil for unauthenticated routes.
|
||||
func CurrentToken(c *gin.Context) *session.Token {
|
||||
v, ok := c.Get(tokenContextKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t, _ := v.(*session.Token)
|
||||
return t
|
||||
}
|
||||
|
||||
func tokenFromRequest(c *gin.Context) string {
|
||||
if cookie, err := c.Cookie(cookieName); err == nil && cookie != "" {
|
||||
return cookie
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
Reference in New Issue
Block a user