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:
114
internal/handlers/auth.go
Normal file
114
internal/handlers/auth.go
Normal 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)
|
||||
}
|
||||
114
internal/handlers/backends.go
Normal file
114
internal/handlers/backends.go
Normal 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)
|
||||
}
|
||||
151
internal/handlers/domains.go
Normal file
151
internal/handlers/domains.go
Normal 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"
|
||||
}
|
||||
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 }
|
||||
87
internal/handlers/response/response.go
Normal file
87
internal/handlers/response/response.go
Normal 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)
|
||||
}
|
||||
72
internal/handlers/response/response_test.go
Normal file
72
internal/handlers/response/response_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
116
internal/handlers/routingrules.go
Normal file
116
internal/handlers/routingrules.go
Normal 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)
|
||||
}
|
||||
59
internal/handlers/setup.go
Normal file
59
internal/handlers/setup.go
Normal 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
142
internal/handlers/system.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user