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:
Debian
2026-05-09 09:56:10 +02:00
parent 106ef95f6d
commit 0a6f81beaa
18 changed files with 1925 additions and 10 deletions

114
internal/handlers/auth.go Normal file
View File

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

View File

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

View File

@@ -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"
}

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

View File

@@ -0,0 +1,87 @@
// Package response is the single source of truth for every JSON
// response edgeguard-api emits. Enforces the envelope contract
//
// {"data": <payload>, "error": null | "<string>", "message": "<string>"}
//
// (1:1 mail-gateway/internal/handlers/response/) so external clients
// and the management UI can deserialise into a generic ApiResponse<T>
// 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)
}

View File

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

View File

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

View File

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

142
internal/handlers/system.go Normal file
View File

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