From 0a6f81beaa258910c4dcc07f509a7fed7f5d886a Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 9 May 2026 09:56:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20Phase=202=20=E2=80=94=20REST-API?= =?UTF-8?q?=20MVP=20+=20CRUD=20f=C3=BCr=20Domains/Backends/Routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/edgeguard-api/main.go | 145 +++++++++++++- go.mod | 2 +- internal/handlers/auth.go | 114 +++++++++++ internal/handlers/backends.go | 114 +++++++++++ internal/handlers/domains.go | 151 +++++++++++++++ internal/handlers/middleware.go | 120 ++++++++++++ internal/handlers/response/response.go | 87 +++++++++ internal/handlers/response/response_test.go | 72 +++++++ internal/handlers/routingrules.go | 116 +++++++++++ internal/handlers/setup.go | 59 ++++++ internal/handlers/system.go | 142 ++++++++++++++ internal/services/audit/audit.go | 48 +++++ internal/services/backends/backends.go | 112 +++++++++++ internal/services/domains/domains.go | 115 +++++++++++ .../services/routingrules/routingrules.go | 136 +++++++++++++ internal/services/session/session.go | 167 ++++++++++++++++ internal/services/session/session_test.go | 54 ++++++ internal/services/setup/setup.go | 181 ++++++++++++++++++ 18 files changed, 1925 insertions(+), 10 deletions(-) create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/backends.go create mode 100644 internal/handlers/domains.go create mode 100644 internal/handlers/middleware.go create mode 100644 internal/handlers/response/response.go create mode 100644 internal/handlers/response/response_test.go create mode 100644 internal/handlers/routingrules.go create mode 100644 internal/handlers/setup.go create mode 100644 internal/handlers/system.go create mode 100644 internal/services/audit/audit.go create mode 100644 internal/services/backends/backends.go create mode 100644 internal/services/domains/domains.go create mode 100644 internal/services/routingrules/routingrules.go create mode 100644 internal/services/session/session.go create mode 100644 internal/services/session/session_test.go create mode 100644 internal/services/setup/setup.go diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 7163ce1..7fc6878 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -1,31 +1,158 @@ +// Command edgeguard-api serves the management REST API on +// 127.0.0.1:9443. nginx (or a dev curl) terminates TLS in front of +// it; this process is plain HTTP behind that. package main import ( + "context" + "crypto/rand" "log" + "log/slog" "net/http" "os" + "time" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) var version = "0.0.1-dev" func main() { - gin.SetMode(gin.ReleaseMode) - r := gin.New() - r.Use(gin.Recovery()) - - r.GET("/api/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok", "version": version}) - }) - addr := os.Getenv("EDGEGUARD_API_ADDR") if addr == "" { addr = "127.0.0.1:9443" } + dataDir := os.Getenv("EDGEGUARD_DATA_DIR") + if dataDir == "" { + dataDir = setup.DefaultDir + } + setupStore := setup.NewStore(dataDir) + + signer, err := session.NewSignerFromPath("") + if err != nil { + // /var/lib/edgeguard not writable in dev → fall back to a + // process-local secret so `go run` works without sudo. Tokens + // won't survive a restart, which is fine for an unprivileged + // developer machine. + slog.Warn("session signer: persisted secret unavailable, using ephemeral", + "error", err) + signer = session.NewSigner(randomEphemeralSecret(), nil, 0) + } + + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(handlers.Recover()) + + // Health endpoints are mounted *before* SetupGate so they answer + // 200 even on a virgin box. UI uses /api/v1/system/health for the + // post-upgrade version-flip poll. + r.GET("/healthz", func(c *gin.Context) { + response.OK(c, gin.H{"status": "ok", "version": version}) + }) + r.GET("/api/health", func(c *gin.Context) { + response.OK(c, gin.H{"status": "ok", "version": version}) + }) + + v1 := r.Group("/api/v1") + v1.Use(handlers.SetupGate(setupStore)) + + requireAuth := handlers.RequireAuth(signer) + + handlers.NewSetupHandler(setupStore).Register(v1) + handlers.NewSystemHandler(version).Register(v1) + handlers.NewAuthHandler(setupStore, signer).Register(v1, requireAuth) + + // Open the DB pool best-effort. Without a reachable PG, CRUD + // handlers stay unregistered and only Auth/Setup/System answer — + // good enough for `go run` on a developer machine that has no + // postgres-16 yet. + pool, err := openDBBestEffort() + if err != nil { + slog.Warn("DB pool unavailable, CRUD endpoints disabled", + "error", err) + } else { + slog.Info("DB pool open, registering CRUD handlers") + nodeID := nodeIDOrHostname() + + auditRepo := audit.New(pool) + domainsRepo := domains.New(pool) + backendsRepo := backends.New(pool) + routingRepo := routingrules.New(pool) + + authed := v1.Group("") + authed.Use(requireAuth) + handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed) + handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed) + handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID).Register(authed) + } + log.Printf("edgeguard-api %s listening on %s", version, addr) - if err := r.Run(addr); err != nil { + srv := &http.Server{Addr: addr, Handler: r} + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("edgeguard-api: %v", err) } } + +// openDBBestEffort opens the pool with a 3s timeout. Returns the +// non-nil error so callers can decide whether to register CRUD or +// degrade gracefully. +func openDBBestEffort() (*pgxpoolPool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + dsn := database.ConnStringFromEnv() + return database.Open(ctx, dsn) +} + +// pgxpoolPool aliases the concrete pool type so we don't import it in +// main.go on every platform — keeps the import block lean. +type pgxpoolPool = pgxpool.Pool + +// nodeIDOrHostname returns the node identifier audit_log entries are +// stamped with. v1 just uses /etc/machine-id (or the hostname on dev +// machines without one). Phase 3's cluster store will replace this. +func nodeIDOrHostname() string { + if b, err := os.ReadFile("/etc/machine-id"); err == nil { + s := string(b) + s = stripTrailingNewline(s) + if s != "" { + return s + } + } + if h, err := os.Hostname(); err == nil { + return h + } + return "unknown" +} + +func stripTrailingNewline(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} + +// randomEphemeralSecret is the fallback for dev environments where +// /var/lib/edgeguard isn't writable. Tokens issued with this secret +// die on restart — production reads/writes the persistent file via +// session.NewSignerFromPath. +func randomEphemeralSecret() []byte { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // Should never happen on a sane Linux box; fall back to a + // time-based filler so the process can at least start. + log.Printf("WARN: crypto/rand read failed: %v", err) + } + return b +} diff --git a/go.mod b/go.mod index c6d25e3..be3eb05 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/jackc/pgx/v5 v5.9.2 github.com/pressly/goose/v3 v3.27.1 + golang.org/x/crypto v0.50.0 ) require ( @@ -36,7 +37,6 @@ require ( github.com/ugorji/go/codec v1.2.12 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..c7103f7 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + "time" + + "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" +) + +// AuthHandler exposes login / me / logout. v1 verifies against the +// setup-store (single admin); admin_users-table support comes when the +// users repo lands. +type AuthHandler struct { + Setup *setup.Store + Signer *session.Signer +} + +func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler { + return &AuthHandler{Setup: s, Signer: sig} +} + +// Register mounts /auth/login + /logout (public) and /auth/me +// (gated by requireAuth, passed in as a per-route middleware). +func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) { + g := rg.Group("/auth") + g.POST("/login", h.Login) + g.POST("/logout", h.Logout) + g.GET("/me", requireAuth, h.Me) +} + +type loginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type loginResponse struct { + Actor string `json:"actor"` + Role string `json:"role"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + st, err := h.Setup.Load() + if err != nil { + response.Internal(c, err) + return + } + if !st.Completed { + response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required")) + return + } + if !strings.EqualFold(st.AdminEmail, strings.TrimSpace(req.Email)) || + !st.VerifyAdminPassword(req.Password) { + response.Unauthorized(c, errors.New("invalid_credentials")) + return + } + + raw, tok, err := h.Signer.IssueWithRole(st.AdminEmail, "admin") + if err != nil { + response.Internal(c, err) + return + } + setSessionCookie(c, raw, tok.Exp) + + response.OK(c, loginResponse{ + Actor: tok.Actor, + Role: tok.Role, + ExpiresAt: time.Unix(tok.Exp, 0).UTC(), + }) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + clearSessionCookie(c) + response.OK(c, gin.H{"logged_out": true}) +} + +// Me returns the current actor + role (or 401 if no/invalid token). +func (h *AuthHandler) Me(c *gin.Context) { + tok := CurrentToken(c) + if tok == nil { + response.Unauthorized(c, nil) + return + } + response.OK(c, gin.H{ + "actor": tok.Actor, + "role": tok.Role, + "expires_at": time.Unix(tok.Exp, 0).UTC(), + }) +} + +func setSessionCookie(c *gin.Context, raw string, expUnix int64) { + maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(cookieName, raw, maxAge, "/", "", true, true) +} + +func clearSessionCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(cookieName, "", -1, "/", "", true, true) +} diff --git a/internal/handlers/backends.go b/internal/handlers/backends.go new file mode 100644 index 0000000..f02584c --- /dev/null +++ b/internal/handlers/backends.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "errors" + "strconv" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" +) + +type BackendsHandler struct { + Repo *backends.Repo + Audit *audit.Repo + NodeID string +} + +func NewBackendsHandler(repo *backends.Repo, a *audit.Repo, nodeID string) *BackendsHandler { + return &BackendsHandler{Repo: repo, Audit: a, NodeID: nodeID} +} + +func (h *BackendsHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/backends") + g.GET("", h.List) + g.POST("", h.Create) + g.GET("/:id", h.Get) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) +} + +func (h *BackendsHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"backends": out}) +} + +func (h *BackendsHandler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + b, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, backends.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, b) +} + +func (h *BackendsHandler) Create(c *gin.Context) { + var req models.Backend + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Create(c.Request.Context(), req) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.create", req.Name, out, h.NodeID) + response.Created(c, out) +} + +func (h *BackendsHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req models.Backend + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Update(c.Request.Context(), id, req) + if err != nil { + if errors.Is(err, backends.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.update", out.Name, out, h.NodeID) + response.OK(c, out) +} + +func (h *BackendsHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Repo.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, backends.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) +} diff --git a/internal/handlers/domains.go b/internal/handlers/domains.go new file mode 100644 index 0000000..8e34cde --- /dev/null +++ b/internal/handlers/domains.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "errors" + "strconv" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" +) + +type DomainsHandler struct { + Repo *domains.Repo + Routing *routingrules.Repo + Audit *audit.Repo + NodeID string +} + +func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, a *audit.Repo, nodeID string) *DomainsHandler { + return &DomainsHandler{Repo: repo, Routing: routing, Audit: a, NodeID: nodeID} +} + +func (h *DomainsHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/domains") + g.GET("", h.List) + g.POST("", h.Create) + g.GET("/:id", h.Get) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) + g.GET("/:id/routing-rules", h.ListRoutingRules) +} + +func (h *DomainsHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"domains": out}) +} + +func (h *DomainsHandler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + d, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, domains.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, d) +} + +func (h *DomainsHandler) Create(c *gin.Context) { + var req models.Domain + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Create(c.Request.Context(), req) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.create", req.Name, out, h.NodeID) + response.Created(c, out) +} + +func (h *DomainsHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req models.Domain + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Update(c.Request.Context(), id, req) + if err != nil { + if errors.Is(err, domains.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.update", out.Name, out, h.NodeID) + response.OK(c, out) +} + +func (h *DomainsHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Repo.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, domains.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) +} + +// ListRoutingRules narrows /routing-rules to one domain — UI uses this +// for the per-domain rules tab rather than fetching the global list +// and filtering client-side. +func (h *DomainsHandler) ListRoutingRules(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + out, err := h.Routing.ListForDomain(c.Request.Context(), id) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"routing_rules": out}) +} + +// parseID parses /:id as int64 and writes a 400 on parse failure. +// Returns (0, false) and aborts when the param is malformed. +func parseID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid id")) + return 0, false + } + return id, true +} + +func actorOf(c *gin.Context) string { + if t := CurrentToken(c); t != nil { + return t.Actor + } + return "unknown" +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..f88c81d --- /dev/null +++ b/internal/handlers/middleware.go @@ -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 } diff --git a/internal/handlers/response/response.go b/internal/handlers/response/response.go new file mode 100644 index 0000000..62f9a7b --- /dev/null +++ b/internal/handlers/response/response.go @@ -0,0 +1,87 @@ +// Package response is the single source of truth for every JSON +// response edgeguard-api emits. Enforces the envelope contract +// +// {"data": , "error": null | "", "message": ""} +// +// (1:1 mail-gateway/internal/handlers/response/) so external clients +// and the management UI can deserialise into a generic ApiResponse +// without per-endpoint special-casing. +package response + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +type Envelope struct { + Data any `json:"data"` + Error *string `json:"error"` + Message string `json:"message"` +} + +func OK(c *gin.Context, data any) { + c.JSON(http.StatusOK, Envelope{Data: data, Error: nil, Message: "OK"}) +} + +func OKWithMessage(c *gin.Context, data any, message string) { + c.JSON(http.StatusOK, Envelope{Data: data, Error: nil, Message: message}) +} + +func Created(c *gin.Context, data any) { + c.JSON(http.StatusCreated, Envelope{Data: data, Error: nil, Message: "Created"}) +} + +func Accepted(c *gin.Context, data any) { + c.JSON(http.StatusAccepted, Envelope{Data: data, Error: nil, Message: "Accepted"}) +} + +func NoContent(c *gin.Context) { + c.Status(http.StatusNoContent) +} + +func Err(c *gin.Context, status int, err error) { + msg := "" + if err != nil { + msg = err.Error() + } + c.JSON(status, Envelope{Data: nil, Error: &msg, Message: msg}) +} + +func ErrMessage(c *gin.Context, status int, err error, message string) { + e := "" + if err != nil { + e = err.Error() + } + c.JSON(status, Envelope{Data: nil, Error: &e, Message: message}) +} + +func BadRequest(c *gin.Context, err error) { + Err(c, http.StatusBadRequest, err) +} + +func NotFound(c *gin.Context, err error) { + if err == nil { + err = errors.New("not found") + } + Err(c, http.StatusNotFound, err) +} + +func Unauthorized(c *gin.Context, err error) { + if err == nil { + err = errors.New("not authenticated") + } + Err(c, http.StatusUnauthorized, err) +} + +func Forbidden(c *gin.Context, err error) { + if err == nil { + err = errors.New("forbidden") + } + Err(c, http.StatusForbidden, err) +} + +func Internal(c *gin.Context, err error) { + Err(c, http.StatusInternalServerError, err) +} diff --git a/internal/handlers/response/response_test.go b/internal/handlers/response/response_test.go new file mode 100644 index 0000000..b8a81a5 --- /dev/null +++ b/internal/handlers/response/response_test.go @@ -0,0 +1,72 @@ +package response + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { gin.SetMode(gin.TestMode) } + +func run(handler gin.HandlerFunc) *httptest.ResponseRecorder { + r := gin.New() + r.GET("/x", handler) + rec := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/x", nil) + r.ServeHTTP(rec, req) + return rec +} + +func decodeEnv(t *testing.T, rec *httptest.ResponseRecorder) Envelope { + t.Helper() + var env Envelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v (body=%s)", err, rec.Body.String()) + } + return env +} + +func TestOK_Wraps200AndMessage(t *testing.T) { + rec := run(func(c *gin.Context) { OK(c, map[string]int{"n": 7}) }) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d", rec.Code) + } + env := decodeEnv(t, rec) + if env.Error != nil { + t.Errorf("error should be nil, got %q", *env.Error) + } + if env.Message != "OK" { + t.Errorf("message: %q", env.Message) + } +} + +func TestErr_SerialisesErrorAndMessage(t *testing.T) { + rec := run(func(c *gin.Context) { Err(c, http.StatusBadRequest, errors.New("boom")) }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: %d", rec.Code) + } + env := decodeEnv(t, rec) + if env.Error == nil || *env.Error != "boom" { + t.Errorf("error: %v", env.Error) + } + if env.Message != "boom" { + t.Errorf("message: %q", env.Message) + } + if env.Data != nil { + t.Errorf("data should be nil on error, got %v", env.Data) + } +} + +func TestNoContent_NoBody(t *testing.T) { + rec := run(func(c *gin.Context) { NoContent(c) }) + if rec.Code != http.StatusNoContent { + t.Fatalf("status: %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("204 should have empty body, got %q", rec.Body.String()) + } +} diff --git a/internal/handlers/routingrules.go b/internal/handlers/routingrules.go new file mode 100644 index 0000000..b07acbf --- /dev/null +++ b/internal/handlers/routingrules.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "errors" + "strconv" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" +) + +type RoutingRulesHandler struct { + Repo *routingrules.Repo + Audit *audit.Repo + NodeID string +} + +func NewRoutingRulesHandler(repo *routingrules.Repo, a *audit.Repo, nodeID string) *RoutingRulesHandler { + return &RoutingRulesHandler{Repo: repo, Audit: a, NodeID: nodeID} +} + +func (h *RoutingRulesHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/routing-rules") + g.GET("", h.List) + g.POST("", h.Create) + g.GET("/:id", h.Get) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) +} + +func (h *RoutingRulesHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"routing_rules": out}) +} + +func (h *RoutingRulesHandler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + r, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, routingrules.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, r) +} + +func (h *RoutingRulesHandler) Create(c *gin.Context) { + var req models.RoutingRule + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Create(c.Request.Context(), req) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "routing_rule.create", + strconv.FormatInt(out.ID, 10), out, h.NodeID) + response.Created(c, out) +} + +func (h *RoutingRulesHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req models.RoutingRule + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Update(c.Request.Context(), id, req) + if err != nil { + if errors.Is(err, routingrules.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "routing_rule.update", + strconv.FormatInt(id, 10), out, h.NodeID) + response.OK(c, out) +} + +func (h *RoutingRulesHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Repo.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, routingrules.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "routing_rule.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) +} diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go new file mode 100644 index 0000000..1f3ca37 --- /dev/null +++ b/internal/handlers/setup.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" +) + +// SetupHandler exposes the first-run wizard endpoints. Both endpoints +// are mounted before SetupGate so they remain reachable while the API +// is in setup mode. +type SetupHandler struct { + Store *setup.Store +} + +func NewSetupHandler(store *setup.Store) *SetupHandler { + return &SetupHandler{Store: store} +} + +func (h *SetupHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/setup") + g.GET("/status", h.Status) + g.POST("/complete", h.Complete) +} + +// Status returns just the public bits of the setup state: whether +// it's done and (if so) the configured admin_email + fqdn. Never +// exposes the password hash. +func (h *SetupHandler) Status(c *gin.Context) { + st, err := h.Store.Load() + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{ + "completed": st.Completed, + "admin_email": st.AdminEmail, + "fqdn": st.FQDN, + }) +} + +func (h *SetupHandler) Complete(c *gin.Context) { + var req setup.Request + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + st, err := h.Store.Complete(req) + if err != nil { + response.BadRequest(c, err) + return + } + response.OK(c, gin.H{ + "completed": st.Completed, + "admin_email": st.AdminEmail, + "fqdn": st.FQDN, + }) +} diff --git a/internal/handlers/system.go b/internal/handlers/system.go new file mode 100644 index 0000000..56b88a0 --- /dev/null +++ b/internal/handlers/system.go @@ -0,0 +1,142 @@ +package handlers + +import ( + "log/slog" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "syscall" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" +) + +// SystemHandler covers /system/health, /system/package-versions and +// /system/upgrade. Wired from day 1 because the management UI carries +// an update banner that polls package-versions. +type SystemHandler struct { + Version string +} + +func NewSystemHandler(version string) *SystemHandler { + return &SystemHandler{Version: version} +} + +func (h *SystemHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/system") + g.GET("/health", h.Health) + g.GET("/package-versions", h.PackageVersions) + g.POST("/upgrade", h.Upgrade) +} + +func (h *SystemHandler) Health(c *gin.Context) { + response.OK(c, gin.H{ + "status": "ok", + "version": h.Version, + }) +} + +// PackageVersions reports installed and available versions for the +// edgeguard-* APT packages. Called by the UI's update banner — it +// polls every few minutes and lights up when available > installed. +// +// `apt-get update -qq` is fired first (best-effort, no error if it +// fails — we'd still return the cached candidate). Then `apt-cache +// policy` is parsed for each package. +func (h *SystemHandler) PackageVersions(c *gin.Context) { + _ = exec.Command("apt-get", "update", "-qq").Run() + + out := map[string]string{} + for _, pkg := range []string{"edgeguard-api", "edgeguard-ui", "edgeguard"} { + raw, err := exec.Command("apt-cache", "policy", pkg).CombinedOutput() + if err != nil { + out[pkg+"_installed"] = "" + out[pkg+"_available"] = "" + continue + } + installed, candidate := parseAptPolicy(string(raw)) + out[pkg+"_installed"] = installed + out[pkg+"_available"] = candidate + } + response.OK(c, out) +} + +// Upgrade runs the apt upgrade detached via systemd-run so the API +// can reply BEFORE the package replaces it. Pattern from netcell- +// webpanel/management-agent/internal/handlers/update.go (see +// architecture.md §11). Without --collect on a transient service +// unit, the apt-get child dies when systemd-cleans up the scope as +// the API exits — leaves the box half-upgraded. +func (h *SystemHandler) Upgrade(c *gin.Context) { + slog.Info("starting package upgrade (detached)") + + const script = `#!/bin/bash +set -e +sleep 2 +export DEBIAN_FRONTEND=noninteractive +echo "[upgrade] dpkg --configure -a" +dpkg --configure -a || true +echo "[upgrade] apt-get update" +apt-get update -qq +echo "[upgrade] apt-get install -y edgeguard-api edgeguard-ui edgeguard" +apt-get install -y -qq -o Dpkg::Options::=--force-confold edgeguard-api edgeguard-ui edgeguard +echo "[upgrade] complete" +rm -f /tmp/edgeguard-upgrade.sh +` + if err := os.WriteFile("/tmp/edgeguard-upgrade.sh", []byte(script), 0o755); err != nil { + response.Internal(c, err) + return + } + + const unitName = "edgeguard-upgrade.service" + _ = exec.Command("systemctl", "reset-failed", unitName).Run() + cmd := exec.Command("systemd-run", + "--unit="+unitName, + "--description=EdgeGuard self-upgrade", + "--collect", + "bash", "/tmp/edgeguard-upgrade.sh") + if err := cmd.Run(); err != nil { + // systemd-run unavailable (dev env) — fall back to setsid + fallback := exec.Command("setsid", "bash", "/tmp/edgeguard-upgrade.sh") + fallback.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err2 := fallback.Start(); err2 != nil { + response.Internal(c, err2) + return + } + _ = fallback.Process.Release() + } + + c.JSON(http.StatusAccepted, response.Envelope{ + Data: gin.H{"status": "upgrading", "unit": unitName}, + Error: nil, + Message: "Upgrade gestartet", + }) +} + +// parseAptPolicy extracts "Installed: x" and "Candidate: y" from +// apt-cache policy output. Both can be "(none)"; we normalise that to +// empty string. +var aptPolicyLine = regexp.MustCompile(`^\s+(Installed|Candidate):\s+(.+)\s*$`) + +func parseAptPolicy(out string) (installed, candidate string) { + for _, line := range strings.Split(out, "\n") { + m := aptPolicyLine.FindStringSubmatch(line) + if m == nil { + continue + } + val := m[2] + if val == "(none)" { + val = "" + } + switch m[1] { + case "Installed": + installed = val + case "Candidate": + candidate = val + } + } + return installed, candidate +} diff --git a/internal/services/audit/audit.go b/internal/services/audit/audit.go new file mode 100644 index 0000000..e4787f3 --- /dev/null +++ b/internal/services/audit/audit.go @@ -0,0 +1,48 @@ +// Package audit appends rows to the audit_log table. Every mutation +// in the API funnels through this so the operator can answer +// "who did what when?" from a single SELECT. +package audit + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +// Log writes one audit_log row. detail is JSON-encodable (typically a +// map[string]any) — empty map means "no payload". If pool is nil +// (e.g. dev env without DB), Log silently no-ops so handlers don't +// have to guard each call site. +func (r *Repo) Log(ctx context.Context, actor, action, subject string, detail any, nodeID string) error { + if r == nil || r.Pool == nil { + return nil + } + var detailJSON []byte + if detail != nil { + var err error + detailJSON, err = json.Marshal(detail) + if err != nil { + return err + } + } + var subjectArg any = subject + if subject == "" { + subjectArg = nil + } + var nodeArg any = nodeID + if nodeID == "" { + nodeArg = nil + } + _, err := r.Pool.Exec(ctx, + `INSERT INTO audit_log (actor, action, subject, detail, node_id) + VALUES ($1, $2, $3, $4, $5)`, + actor, action, subjectArg, detailJSON, nodeArg) + return err +} diff --git a/internal/services/backends/backends.go b/internal/services/backends/backends.go new file mode 100644 index 0000000..5940292 --- /dev/null +++ b/internal/services/backends/backends.go @@ -0,0 +1,112 @@ +// Package backends implements CRUD against the `backends` table. +package backends + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrNotFound = errors.New("backend not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, name, scheme, address, port, health_check_path, active, + created_at, updated_at +FROM backends +` + +func (r *Repo) List(ctx context.Context) ([]models.Backend, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.Backend, 0, 16) + for rows.Next() { + b, err := scanBackend(rows) + if err != nil { + return nil, err + } + out = append(out, *b) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.Backend, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + b, err := scanBackend(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return b, nil +} + +func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, error) { + row := r.Pool.QueryRow(ctx, ` +INSERT INTO backends (name, scheme, address, port, health_check_path, active) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, name, scheme, address, port, health_check_path, active, + created_at, updated_at`, + b.Name, b.Scheme, b.Address, b.Port, b.HealthCheckPath, b.Active) + return scanBackend(row) +} + +func (r *Repo) Update(ctx context.Context, id int64, b models.Backend) (*models.Backend, error) { + row := r.Pool.QueryRow(ctx, ` +UPDATE backends SET + name = $1, + scheme = $2, + address = $3, + port = $4, + health_check_path = $5, + active = $6, + updated_at = NOW() +WHERE id = $7 +RETURNING id, name, scheme, address, port, health_check_path, active, + created_at, updated_at`, + b.Name, b.Scheme, b.Address, b.Port, b.HealthCheckPath, b.Active, id) + out, err := scanBackend(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return out, nil +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM backends WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) { + var b models.Backend + if err := row.Scan( + &b.ID, &b.Name, &b.Scheme, &b.Address, &b.Port, + &b.HealthCheckPath, &b.Active, + &b.CreatedAt, &b.UpdatedAt, + ); err != nil { + return nil, err + } + return &b, nil +} diff --git a/internal/services/domains/domains.go b/internal/services/domains/domains.go new file mode 100644 index 0000000..cb96ed6 --- /dev/null +++ b/internal/services/domains/domains.go @@ -0,0 +1,115 @@ +// Package domains implements CRUD against the `domains` table. +package domains + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrNotFound = errors.New("domain not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, name, active, primary_backend_id, http_to_https, hsts_enabled, + notes, created_at, updated_at +FROM domains +` + +func (r *Repo) List(ctx context.Context) ([]models.Domain, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.Domain, 0, 16) + for rows.Next() { + d, err := scanDomain(rows) + if err != nil { + return nil, err + } + out = append(out, *d) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.Domain, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + d, err := scanDomain(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return d, nil +} + +func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, error) { + row := r.Pool.QueryRow(ctx, ` +INSERT INTO domains (name, active, primary_backend_id, http_to_https, hsts_enabled, notes) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled, + notes, created_at, updated_at`, + d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.Notes) + return scanDomain(row) +} + +func (r *Repo) Update(ctx context.Context, id int64, d models.Domain) (*models.Domain, error) { + row := r.Pool.QueryRow(ctx, ` +UPDATE domains SET + name = $1, + active = $2, + primary_backend_id = $3, + http_to_https = $4, + hsts_enabled = $5, + notes = $6, + updated_at = NOW() +WHERE id = $7 +RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled, + notes, created_at, updated_at`, + d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.Notes, id) + out, err := scanDomain(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return out, nil +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// scanDomain accepts both pgx.Row (Get/Create/Update) and pgx.Rows +// (List, via the Scanner shape). pgx exposes both as a single +// Scan(...any) error method. +func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) { + var d models.Domain + if err := row.Scan( + &d.ID, &d.Name, &d.Active, &d.PrimaryBackendID, + &d.HTTPToHTTPS, &d.HSTSEnabled, &d.Notes, + &d.CreatedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + return &d, nil +} diff --git a/internal/services/routingrules/routingrules.go b/internal/services/routingrules/routingrules.go new file mode 100644 index 0000000..28ac3fb --- /dev/null +++ b/internal/services/routingrules/routingrules.go @@ -0,0 +1,136 @@ +// Package routingrules implements CRUD against the `routing_rules` +// table. A rule maps (domain, path_prefix) → backend; higher priority +// wins, ties broken by id. +package routingrules + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrNotFound = errors.New("routing rule not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, domain_id, path_prefix, backend_id, priority, active, + created_at, updated_at +FROM routing_rules +` + +// List returns rules ordered by domain_id then priority desc — the +// shape the config-renderer wants when building haproxy/nginx vhosts. +func (r *Repo) List(ctx context.Context) ([]models.RoutingRule, error) { + rows, err := r.Pool.Query(ctx, baseSelect+ + " ORDER BY domain_id ASC, priority DESC, id ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.RoutingRule, 0, 16) + for rows.Next() { + rr, err := scanRule(rows) + if err != nil { + return nil, err + } + out = append(out, *rr) + } + return out, rows.Err() +} + +// ListForDomain narrows List to a single domain — handlers expose this +// as GET /domains/:id/routing-rules so the UI only fetches what it needs. +func (r *Repo) ListForDomain(ctx context.Context, domainID int64) ([]models.RoutingRule, error) { + rows, err := r.Pool.Query(ctx, baseSelect+ + " WHERE domain_id = $1 ORDER BY priority DESC, id ASC", domainID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.RoutingRule, 0, 4) + for rows.Next() { + rr, err := scanRule(rows) + if err != nil { + return nil, err + } + out = append(out, *rr) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.RoutingRule, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + rr, err := scanRule(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return rr, nil +} + +func (r *Repo) Create(ctx context.Context, rr models.RoutingRule) (*models.RoutingRule, error) { + row := r.Pool.QueryRow(ctx, ` +INSERT INTO routing_rules (domain_id, path_prefix, backend_id, priority, active) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, domain_id, path_prefix, backend_id, priority, active, + created_at, updated_at`, + rr.DomainID, rr.PathPrefix, rr.BackendID, rr.Priority, rr.Active) + return scanRule(row) +} + +func (r *Repo) Update(ctx context.Context, id int64, rr models.RoutingRule) (*models.RoutingRule, error) { + row := r.Pool.QueryRow(ctx, ` +UPDATE routing_rules SET + domain_id = $1, + path_prefix = $2, + backend_id = $3, + priority = $4, + active = $5, + updated_at = NOW() +WHERE id = $6 +RETURNING id, domain_id, path_prefix, backend_id, priority, active, + created_at, updated_at`, + rr.DomainID, rr.PathPrefix, rr.BackendID, rr.Priority, rr.Active, id) + out, err := scanRule(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return out, nil +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM routing_rules WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func scanRule(row interface{ Scan(...any) error }) (*models.RoutingRule, error) { + var rr models.RoutingRule + if err := row.Scan( + &rr.ID, &rr.DomainID, &rr.PathPrefix, &rr.BackendID, + &rr.Priority, &rr.Active, + &rr.CreatedAt, &rr.UpdatedAt, + ); err != nil { + return nil, err + } + return &rr, nil +} diff --git a/internal/services/session/session.go b/internal/services/session/session.go new file mode 100644 index 0000000..db6e98b --- /dev/null +++ b/internal/services/session/session.go @@ -0,0 +1,167 @@ +// Package session implements signed admin-session tokens. +// +// Tokens are opaque strings of the form +// +// base64url(payload) . base64url(HMAC-SHA256(payload)) +// +// where payload is a small JSON ({actor, role, iat, exp}). A 32-byte +// secret on disk (0600 edgeguard:edgeguard, generated on first use) +// keys the HMAC. No DB round-trip for verification — handlers +// validate the token and trust the payload. +// +// Pattern 1:1 nach mail-gateway/internal/services/session/. Audience- +// Splitting (admin vs portal) und API-Key-Synthese sind bewusst nicht +// im v1-Scope (kein Quarantine-Portal in EdgeGuard). +package session + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + DefaultSecretPath = "/var/lib/edgeguard/.jwt_fingerprint" + + defaultTTL = 24 * time.Hour + secretSize = 32 +) + +type Token struct { + Actor string `json:"actor"` + Role string `json:"role,omitempty"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` +} + +type Signer struct { + Secret []byte + Now func() time.Time + TTL time.Duration +} + +// NewSignerFromPath loads or creates a 32-byte secret at path. Parent +// dir gets 0o700, file is 0o600. +func NewSignerFromPath(path string) (*Signer, error) { + if path == "" { + path = DefaultSecretPath + } + secret, err := loadOrCreateSecret(path) + if err != nil { + return nil, err + } + return &Signer{ + Secret: secret, + Now: func() time.Time { return time.Now().UTC() }, + TTL: defaultTTL, + }, nil +} + +// NewSigner builds a signer with an in-memory secret — for tests. +func NewSigner(secret []byte, now func() time.Time, ttl time.Duration) *Signer { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + if ttl == 0 { + ttl = defaultTTL + } + return &Signer{Secret: secret, Now: now, TTL: ttl} +} + +func loadOrCreateSecret(path string) ([]byte, error) { + if b, err := os.ReadFile(path); err == nil { + if len(b) < secretSize { + return nil, fmt.Errorf("%s is shorter than %d bytes", path, secretSize) + } + return b[:secretSize], nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + secret := make([]byte, secretSize) + if _, err := rand.Read(secret); err != nil { + return nil, err + } + if err := os.WriteFile(path, secret, 0o600); err != nil { + return nil, err + } + return secret, nil +} + +// IssueWithRole returns a signed token for the given actor + role. +func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) { + now := s.Now() + t := Token{ + Actor: actor, + Role: role, + Iat: now.Unix(), + Exp: now.Add(s.TTL).Unix(), + } + data, err := json.Marshal(t) + if err != nil { + return "", nil, err + } + mac := hmac.New(sha256.New, s.Secret) + mac.Write(data) + signature := mac.Sum(nil) + encoded := base64.RawURLEncoding.EncodeToString(data) + "." + + base64.RawURLEncoding.EncodeToString(signature) + return encoded, &t, nil +} + +// Issue is IssueWithRole with empty role. +func (s *Signer) Issue(actor string) (string, *Token, error) { + return s.IssueWithRole(actor, "") +} + +// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken. +func (s *Signer) Verify(raw string) (*Token, error) { + if raw == "" { + return nil, ErrInvalidToken + } + var payloadB64, sigB64 string + for i := 0; i < len(raw); i++ { + if raw[i] == '.' { + payloadB64 = raw[:i] + sigB64 = raw[i+1:] + break + } + } + if payloadB64 == "" || sigB64 == "" { + return nil, ErrInvalidToken + } + payload, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return nil, ErrInvalidToken + } + sig, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil { + return nil, ErrInvalidToken + } + mac := hmac.New(sha256.New, s.Secret) + mac.Write(payload) + if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 { + return nil, ErrInvalidToken + } + var t Token + if err := json.Unmarshal(payload, &t); err != nil { + return nil, ErrInvalidToken + } + if s.Now().Unix() >= t.Exp { + return nil, ErrExpiredToken + } + return &t, nil +} + +var ( + ErrInvalidToken = errors.New("invalid session token") + ErrExpiredToken = errors.New("session token expired") +) diff --git a/internal/services/session/session_test.go b/internal/services/session/session_test.go new file mode 100644 index 0000000..937a49d --- /dev/null +++ b/internal/services/session/session_test.go @@ -0,0 +1,54 @@ +package session + +import ( + "errors" + "testing" + "time" +) + +func TestIssueAndVerify(t *testing.T) { + s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour) + raw, tok, err := s.IssueWithRole("admin@example.com", "admin") + if err != nil { + t.Fatalf("issue: %v", err) + } + if tok.Actor != "admin@example.com" || tok.Role != "admin" { + t.Fatalf("token claims: %+v", tok) + } + got, err := s.Verify(raw) + if err != nil { + t.Fatalf("verify: %v", err) + } + if got.Actor != tok.Actor || got.Role != tok.Role { + t.Fatalf("roundtrip mismatch: %+v vs %+v", got, tok) + } +} + +func TestVerifyRejectsTampered(t *testing.T) { + s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour) + raw, _, _ := s.Issue("a") + tampered := raw[:len(raw)-2] + "AA" + if _, err := s.Verify(tampered); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("expected ErrInvalidToken, got %v", err) + } +} + +func TestVerifyExpired(t *testing.T) { + now := time.Unix(1_000_000, 0).UTC() + s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), + func() time.Time { return now }, time.Minute) + raw, _, _ := s.Issue("a") + + // shift clock forward past Exp + s.Now = func() time.Time { return now.Add(2 * time.Minute) } + if _, err := s.Verify(raw); !errors.Is(err, ErrExpiredToken) { + t.Fatalf("expected ErrExpiredToken, got %v", err) + } +} + +func TestVerifyEmptyRaw(t *testing.T) { + s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour) + if _, err := s.Verify(""); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("expected ErrInvalidToken, got %v", err) + } +} diff --git a/internal/services/setup/setup.go b/internal/services/setup/setup.go new file mode 100644 index 0000000..1b75ae3 --- /dev/null +++ b/internal/services/setup/setup.go @@ -0,0 +1,181 @@ +// Package setup stores the one-time first-boot configuration of an +// EdgeGuard node. State lives in setup.json inside the data dir +// (default /var/lib/edgeguard). An incomplete or missing state means +// the API is in "setup mode" and gates non-setup routes. +// +// The cluster-aware version (Phase 3) moves this to ha_nodes / +// system_settings in PostgreSQL; the on-disk file remains the +// first-node bootstrap record so the seed peer has somewhere to +// write before PG holds an admin row. +// +// Pattern 1:1 nach mail-gateway/internal/services/setup/. +package setup + +import ( + "encoding/json" + "errors" + "fmt" + "net/mail" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + DefaultDir = "/var/lib/edgeguard" + stateFile = "setup.json" + adminPwCost = 12 +) + +type State struct { + AdminEmail string `json:"admin_email"` + AdminPasswordHash string `json:"admin_password_hash"` + FQDN string `json:"fqdn"` + ACMEEmail string `json:"acme_email"` + LicenseKey string `json:"license_key,omitempty"` + Completed bool `json:"completed"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +// Request is the JSON body POST /api/v1/setup/complete accepts. +// AdminPassword is plaintext on the wire; the service hashes it +// before persisting. +type Request struct { + AdminEmail string `json:"admin_email" binding:"required,email"` + AdminPassword string `json:"admin_password" binding:"required,min=12"` + FQDN string `json:"fqdn" binding:"required"` + ACMEEmail string `json:"acme_email" binding:"required,email"` + LicenseKey string `json:"license_key,omitempty"` +} + +type Store struct { + Dir string +} + +func NewStore(dir string) *Store { return &Store{Dir: dir} } + +func (s *Store) Path() string { return filepath.Join(s.Dir, stateFile) } + +// Load returns the current state. Missing file = zero value with +// Completed=false (the "never set up" case), no error. +func (s *Store) Load() (*State, error) { + data, err := os.ReadFile(s.Path()) + if err != nil { + if os.IsNotExist(err) { + return &State{}, nil + } + return nil, err + } + var st State + if err := json.Unmarshal(data, &st); err != nil { + return nil, fmt.Errorf("parse setup state: %w", err) + } + return &st, nil +} + +// Save writes the state atomically (write-tmp + rename). 0o600 because +// it carries the bcrypt admin-password hash. +func (s *Store) Save(st *State) error { + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + tmp := s.Path() + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.Path()) +} + +// Complete validates the request, hashes the password, persists. Re- +// running with the same admin email overwrites the password (admin- +// recovery path); a different email after completion is rejected to +// prevent silent takeover. +func (s *Store) Complete(req Request) (*State, error) { + if err := validate(req); err != nil { + return nil, err + } + prev, err := s.Load() + if err != nil { + return nil, err + } + if prev.Completed && prev.AdminEmail != "" && + !strings.EqualFold(prev.AdminEmail, req.AdminEmail) { + return nil, errors.New("setup already completed under a different admin email") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), adminPwCost) + if err != nil { + return nil, fmt.Errorf("hash admin password: %w", err) + } + + now := time.Now().UTC() + st := &State{ + AdminEmail: strings.ToLower(strings.TrimSpace(req.AdminEmail)), + AdminPasswordHash: string(hash), + FQDN: strings.TrimSpace(req.FQDN), + ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)), + LicenseKey: strings.TrimSpace(req.LicenseKey), + Completed: true, + CompletedAt: &now, + } + if err := s.Save(st); err != nil { + return nil, err + } + return st, nil +} + +// VerifyAdminPassword does constant-time bcrypt comparison. +func (st *State) VerifyAdminPassword(plaintext string) bool { + return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil +} + +func validate(req Request) error { + if _, err := mail.ParseAddress(req.AdminEmail); err != nil { + return fmt.Errorf("invalid admin_email: %w", err) + } + if _, err := mail.ParseAddress(req.ACMEEmail); err != nil { + return fmt.Errorf("invalid acme_email: %w", err) + } + if !looksLikeFQDN(req.FQDN) { + return fmt.Errorf("fqdn %q does not look like a fully-qualified hostname", req.FQDN) + } + if len(req.AdminPassword) < 12 { + return errors.New("admin_password must be at least 12 characters") + } + return nil +} + +func looksLikeFQDN(s string) bool { + s = strings.TrimSpace(strings.TrimSuffix(s, ".")) + if len(s) == 0 || len(s) > 253 { + return false + } + if !strings.Contains(s, ".") { + return false + } + for _, label := range strings.Split(s, ".") { + if len(label) == 0 || len(label) > 63 { + return false + } + for _, r := range label { + ok := r == '-' || + (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') + if !ok { + return false + } + } + if label[0] == '-' || label[len(label)-1] == '-' { + return false + } + } + return true +}