feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)
Backend: - Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date) - NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle) - System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler) - Domain-Response-Headers + Rate-Limit (Migration 0024) - Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out - apt-Service für Update-Banner (apt-get update + Versionsprüfung) - Backup-Retry mit exponential backoff (retry_apt 3×) - publish.sh fail-fast + cleanup-old.sh (max 10 Versionen) Frontend: - Audit-Log-Page (/audit) mit Filter + Pagination - ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+) - Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix) - EmptyState-Komponente überall ausgerollt - SSL: Aggregate-Karte (total/expiring/expired/errors) - Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now - NTP: Sync-Status-Karte (chronyc tracking live) - Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats - Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN) - Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler) - Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention - Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint - System-Regeln im Firewall als eigener Tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,47 @@ func NewAuditHandler(repo *audit.Repo) *AuditHandler { return &AuditHandler{Repo
|
||||
func (h *AuditHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/audit")
|
||||
g.GET("/recent", h.Recent)
|
||||
g.GET("/search", h.Search)
|
||||
g.GET("/live", h.Live)
|
||||
}
|
||||
|
||||
// Search filtert audit_log nach Actor/Action/Subject/Date-Range.
|
||||
// Query-Params: actor, action, subject (alle ILIKE-Substring),
|
||||
// since/until (RFC3339), limit (max 500), offset.
|
||||
func (h *AuditHandler) Search(c *gin.Context) {
|
||||
f := audit.SearchFilter{
|
||||
Actor: c.Query("actor"),
|
||||
Action: c.Query("action"),
|
||||
Subject: c.Query("subject"),
|
||||
}
|
||||
if s := c.Query("since"); s != "" {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
f.Since = &t
|
||||
}
|
||||
}
|
||||
if s := c.Query("until"); s != "" {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
f.Until = &t
|
||||
}
|
||||
}
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
if v := c.Query("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Offset = n
|
||||
}
|
||||
}
|
||||
rows, err := h.Repo.Search(c.Request.Context(), f)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"entries": rows, "limit": f.Limit, "offset": f.Offset})
|
||||
}
|
||||
|
||||
// Recent returns the most recent audit_log entries — used by the
|
||||
// dashboard fallback path (z.B. wenn WebSocket nicht verbinden kann).
|
||||
// ?limit=N (1–100, default 10).
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
@@ -19,12 +20,22 @@ import (
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
return &AuthHandler{Setup: s, Signer: sig}
|
||||
}
|
||||
|
||||
// WithAudit: Audit-Repo + NodeID damit Password-Operationen (change,
|
||||
// reset, login-success/fail) ins audit_log fließen.
|
||||
func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -33,6 +44,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", requireAuth, h.Me)
|
||||
g.POST("/reset-password", h.ResetPassword)
|
||||
g.POST("/change-password", requireAuth, h.ChangePassword)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -122,6 +134,60 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
// ResetPassword: keine Session, deshalb "self-reset" als Actor
|
||||
// damit der Audit-Trail zeigt dass es kein admin-mediated Reset war.
|
||||
_ = h.Audit.Log(c.Request.Context(), "self-reset", "auth.password.reset",
|
||||
"", gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=12"`
|
||||
}
|
||||
|
||||
// ChangePassword: authenticated User wechselt sein eigenes Passwort.
|
||||
// Anders als ResetPassword (CLI-Token-Flow für vergessenes Passwort)
|
||||
// braucht das hier das current_password als Confirmation — verhindert
|
||||
// dass eine kompromittierte Session den Account übernimmt ohne dass
|
||||
// das alte Passwort bekannt ist.
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
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 == nil || !st.Completed {
|
||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||
return
|
||||
}
|
||||
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
|
||||
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
|
||||
// nur der eine Admin-User; trotzdem prüfen wir das current_password
|
||||
// gegen die persistierte Hash.
|
||||
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
|
||||
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
|
||||
// sicherheitsbewusster: clearSession + force re-login. Wir
|
||||
// halten's hier ruhig.
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,149 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// ClusterHandler exposes cluster-state endpoints. v1 ist read-only;
|
||||
// /status liefert eine strukturierte UI-Sicht (local + peers + health),
|
||||
// /nodes bleibt als simpler list-endpoint für Tools/Scripts.
|
||||
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||
// strukturierte UI-Sicht (local + peers + health), /nodes ist die
|
||||
// simple list, /system/load fan-outet via mTLS-Aggregator zu allen
|
||||
// Peers und liefert pro Node die /proc-Metriken.
|
||||
//
|
||||
// Aggregator kann nil sein (clustertls nicht initialisiert) — dann
|
||||
// liefert /cluster/system/load nur den lokalen Wert.
|
||||
type ClusterHandler struct {
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Aggregator *aggregator.Aggregator
|
||||
|
||||
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
|
||||
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
|
||||
TLSStore *clustertls.Store
|
||||
Tokens *jointoken.Service
|
||||
|
||||
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||
PeerReloader PeerReloader
|
||||
}
|
||||
|
||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
return &ClusterHandler{Store: store, LocalID: localID}
|
||||
}
|
||||
|
||||
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
|
||||
// wird /cluster/system/load die Peers via mTLS abklappern.
|
||||
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
|
||||
h.Aggregator = a
|
||||
return h
|
||||
}
|
||||
|
||||
// WithJoinFlow: Cluster-CA + Join-Token-Service. Nur wenn beide gesetzt
|
||||
// sind exposen wir /cluster/join-tokens (admin) + /cluster/issue-cert (public).
|
||||
func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken.Service) *ClusterHandler {
|
||||
h.TLSStore = store
|
||||
h.Tokens = tokens
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/cluster")
|
||||
g.GET("/nodes", h.ListNodes)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/system/load", h.SystemLoad)
|
||||
g.DELETE("/nodes/:id", h.DeleteNode)
|
||||
if h.TLSStore != nil {
|
||||
g.GET("/cert-status", h.CertStatus)
|
||||
g.POST("/renew-self", h.RenewSelf)
|
||||
}
|
||||
if h.TLSStore != nil && h.Tokens != nil {
|
||||
g.POST("/join-tokens", h.GenerateJoinToken)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
|
||||
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
|
||||
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
|
||||
// die Cluster-Page wird inkonsistent.
|
||||
//
|
||||
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
|
||||
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
|
||||
// Peer kann nicht mehr auf :8443/:16379 connecten.
|
||||
func (h *ClusterHandler) DeleteNode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, simpleError("missing id"))
|
||||
return
|
||||
}
|
||||
if id == h.LocalID {
|
||||
response.BadRequest(c, simpleError("cannot remove the local node — would auto-recreate on next heartbeat"))
|
||||
return
|
||||
}
|
||||
if err := h.Store.Delete(c.Request.Context(), id); err != nil {
|
||||
if err == cluster.ErrNotFound {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: firewall render after peer delete failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
slog.Info("cluster: peer removed", "id", id, "actor", actorOf(c))
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
|
||||
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
|
||||
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
|
||||
// (NICHT auf authed).
|
||||
func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
if h.TLSStore == nil || h.Tokens == nil {
|
||||
return
|
||||
}
|
||||
g := rg.Group("/cluster")
|
||||
g.POST("/issue-cert", h.IssueCert)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die Peer-only-Endpoints auf dem mTLS-Agent-
|
||||
// Listener. Auth läuft über das Peer-Cert (RequireAndVerifyClientCert
|
||||
// im ServerTLSConfig); der CN des Cert ist die FQDN des Peers.
|
||||
//
|
||||
// /agent/cluster/peers — joining Peer trägt sich nach erfolgreichem
|
||||
// issue-cert hier ein, damit der Primary ihn in ha_nodes hat (mit
|
||||
// status='joining') und der nächste Firewall-Render-Lauf seine IP
|
||||
// ins peer_ipv4-Set aufnimmt.
|
||||
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/cluster")
|
||||
g.POST("/peers", h.AgentRegisterPeer)
|
||||
}
|
||||
|
||||
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||
// Firewall + ggfs. andere Configs regeneriert (damit peer_ipv4-Set
|
||||
// frisch ist). Wird vom main.go gesetzt.
|
||||
type PeerReloader func(ctx context.Context) error
|
||||
|
||||
// WithPeerReloader: nach jedem AgentRegisterPeer-Aufruf gefeuert.
|
||||
func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||
h.PeerReloader = r
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||
@@ -116,3 +233,292 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// SystemLoad aggregiert /proc-Metriken aller Peers via mTLS-Aggregator.
|
||||
// Liefert ein Array { node_id, fqdn, ok, data, error, duration_ms }.
|
||||
// Lokaler Node wird IMMER eingefügt (direkter Call statt mTLS-Roundtrip).
|
||||
func (h *ClusterHandler) SystemLoad(c *gin.Context) {
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
results := make([]aggregator.PeerResult, 0, len(all))
|
||||
// Lokalen Node selbst befragen: wir rufen die Resources-Funktion
|
||||
// inline statt einen mTLS-Loopback aufzubauen — auch wenn der
|
||||
// Agent-Listener läuft, ist ein direkter Call billiger.
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
local := localSystemLoad()
|
||||
raw, _ := json.Marshal(local)
|
||||
results = append(results, aggregator.PeerResult{
|
||||
NodeID: n.ID,
|
||||
FQDN: n.FQDN,
|
||||
OK: true,
|
||||
Data: raw,
|
||||
Duration: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
if h.Aggregator != nil {
|
||||
peers := make([]models.HANode, 0, len(all))
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, n)
|
||||
}
|
||||
// /agent/system/resources auf dem Peer-Agent-Listener (:8443 mTLS).
|
||||
fan := h.Aggregator.FanOut(c.Request.Context(), peers, "/agent/system/resources", h.LocalID)
|
||||
results = append(results, fan...)
|
||||
}
|
||||
response.OK(c, gin.H{"nodes": results})
|
||||
}
|
||||
|
||||
// localSystemLoad ruft die selben Werte wie /system/resources, aber
|
||||
// als bare struct (kein gin.Context). Damit liefert SystemLoad pro Node
|
||||
// dasselbe Format wie der Agent-Endpoint.
|
||||
func localSystemLoad() any {
|
||||
// SystemHandler.Resources nutzt ein internes `resources` struct.
|
||||
// Wir duplizieren die /proc-Reads nicht — der Agent-Listener mountet
|
||||
// denselben Handler und liefert die JSON-Struktur. Für den lokalen
|
||||
// Path liefern wir das Snapshot über computeLocalSystemResources.
|
||||
return computeLocalSystemResources()
|
||||
}
|
||||
|
||||
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||
|
||||
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
|
||||
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
|
||||
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
||||
token, exp, err := h.Tokens.Generate()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
|
||||
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
|
||||
// kann.
|
||||
caCert, _, err := h.TLSStore.LoadCA()
|
||||
caFP := ""
|
||||
if err == nil && caCert != nil {
|
||||
caFP = jointoken.CAFingerprint16(caCert.Raw)
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"token": token,
|
||||
"expires_at": exp.UTC().Format(time.RFC3339),
|
||||
"ca_fingerprint": caFP,
|
||||
})
|
||||
}
|
||||
|
||||
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
|
||||
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
||||
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
||||
// Auth nötig (der Joiner hat noch keine).
|
||||
type issueCertRequest struct {
|
||||
Token string `json:"token"`
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
type issueCertResponse struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
PeerCert string `json:"peer_cert"`
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
var req issueCertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Token == "" || req.CSR == "" {
|
||||
response.BadRequest(c, errInvalidJoinRequest)
|
||||
return
|
||||
}
|
||||
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
|
||||
// und vom falschen Host einlöst.
|
||||
consumedBy := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
// CSR signieren.
|
||||
peerCert, err := h.TLSStore.SignCSR(req.CSR, nil)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
caPEM, err := h.TLSStore.CACertPEM()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, issueCertResponse{
|
||||
CACert: caPEM,
|
||||
PeerCert: peerCert,
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||
|
||||
type simpleError string
|
||||
|
||||
func (e simpleError) Error() string { return string(e) }
|
||||
|
||||
// ── Cluster-Cert-Status + Renewal ─────────────────────────────────────
|
||||
|
||||
// CertStatus liefert Metadata zu CA + Peer-Cert (Common Name, Expiry,
|
||||
// days_remaining). UI nutzt das für Expiry-Warnungen.
|
||||
func (h *ClusterHandler) CertStatus(c *gin.Context) {
|
||||
out := gin.H{
|
||||
"has_ca": h.TLSStore.HasCA(),
|
||||
"has_peer": h.TLSStore.HasPeer(),
|
||||
}
|
||||
if h.TLSStore.HasCA() {
|
||||
if info, err := h.TLSStore.CACertInfo(); err == nil {
|
||||
out["ca"] = info
|
||||
}
|
||||
}
|
||||
if h.TLSStore.HasPeer() {
|
||||
if info, err := h.TLSStore.PeerCertInfo(); err == nil {
|
||||
out["peer"] = info
|
||||
}
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// RenewSelf re-signed das eigene peer.crt mit der eigenen CA. Nur
|
||||
// sinnvoll auf einer Founder-Box; Joiner haben keine eigene CA.
|
||||
//
|
||||
// Nach Renew muss edgeguard-api restartet werden damit der Agent-
|
||||
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
|
||||
// triggern das NICHT automatisch (würde die HTTP-Response abreißen);
|
||||
// stattdessen liefern wir einen Hinweis im Response.
|
||||
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||
if !h.TLSStore.HasCA() {
|
||||
response.BadRequest(c, simpleError("no local CA — joiners cannot self-renew"))
|
||||
return
|
||||
}
|
||||
// Common-Name aus dem existierenden Peer-Cert übernehmen damit der
|
||||
// Cert weiterhin auf die aktuelle FQDN passt.
|
||||
cn := "edgeguard-node"
|
||||
if info, err := h.TLSStore.PeerCertInfo(); err == nil && info.CommonName != "" {
|
||||
cn = info.CommonName
|
||||
}
|
||||
if err := h.TLSStore.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
info, err := h.TLSStore.PeerCertInfo()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"peer": info,
|
||||
"restart_hint": "systemctl restart edgeguard-api",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Phase 3.5: Auto-Register beim Cluster-Join ────────────────────────
|
||||
|
||||
// registerPeerRequest: vom Joiner via mTLS-POST an /agent/cluster/peers.
|
||||
// CN des Client-Cert authentifiziert den Peer. Wir nehmen nur die Felder
|
||||
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
|
||||
// ha_nodes-Felder überschreiben.
|
||||
type registerPeerRequest struct {
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
// Validiert dass der Client-Cert-CN zur fqdn passt (verhindert Cross-
|
||||
// Peer-Hijack) und upsertet die Row in ha_nodes mit status='joining'.
|
||||
// Phase 3.2 Heartbeat wird die Status auf 'online' ändern sobald der
|
||||
// Joiner seinen eigenen Heartbeat-Tick startet.
|
||||
func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
var req registerPeerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.ID == "" || req.FQDN == "" {
|
||||
response.BadRequest(c, simpleError("id + fqdn required"))
|
||||
return
|
||||
}
|
||||
// Cert-CN-Check: TLS-Layer hat den Cert bereits gegen unsere CA
|
||||
// verifiziert; jetzt prüfen wir dass der CN zur claimed FQDN passt.
|
||||
// Sonst könnte ein peer1.example.com Cert nutzen um peer2.example.com
|
||||
// in ha_nodes zu schreiben.
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
response.Forbidden(c, simpleError("no client cert presented"))
|
||||
return
|
||||
}
|
||||
cn := c.Request.TLS.PeerCertificates[0].Subject.CommonName
|
||||
if cn != req.FQDN {
|
||||
slog.Warn("cluster: peer cert CN does not match registration FQDN",
|
||||
"cn", cn, "fqdn", req.FQDN)
|
||||
response.Forbidden(c, simpleError("cert CN does not match fqdn"))
|
||||
return
|
||||
}
|
||||
|
||||
// Wir bauen ein models.HANode zusammen + nutzen den existierenden
|
||||
// UpsertSelf. (UpsertSelf nimmt eine HANode für einen registrierenden
|
||||
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
||||
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
||||
// wir brauchen.)
|
||||
n := models.HANode{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
FQDN: req.FQDN,
|
||||
APIURL: req.APIURL,
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
}
|
||||
if req.PublicIP != "" {
|
||||
v := req.PublicIP
|
||||
n.PublicIP = &v
|
||||
}
|
||||
if req.InternalIP != "" {
|
||||
v := req.InternalIP
|
||||
n.InternalIP = &v
|
||||
}
|
||||
if req.MgmtIP != "" {
|
||||
v := req.MgmtIP
|
||||
n.MgmtIP = &v
|
||||
}
|
||||
if req.Version != "" {
|
||||
v := req.Version
|
||||
n.Version = &v
|
||||
}
|
||||
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
||||
// nachrendern.
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after AgentRegisterPeer", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("cluster: peer registered via mTLS",
|
||||
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
||||
"client_cn", cn, "remote", c.ClientIP())
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"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/domainheaders"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
type DomainsHandler struct {
|
||||
Repo *domains.Repo
|
||||
Routing *routingrules.Repo
|
||||
Headers *domainheaders.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
|
||||
@@ -29,8 +31,8 @@ type DomainsHandler struct {
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||
return &DomainsHandler{Repo: repo, Routing: routing, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, headers *domainheaders.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||
return &DomainsHandler{Repo: repo, Routing: routing, Headers: headers, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) reload(ctx context.Context, op string) {
|
||||
@@ -50,6 +52,11 @@ func (h *DomainsHandler) Register(rg *gin.RouterGroup) {
|
||||
g.PUT("/:id", h.Update)
|
||||
g.DELETE("/:id", h.Delete)
|
||||
g.GET("/:id/routing-rules", h.ListRoutingRules)
|
||||
|
||||
g.GET("/:id/headers", h.ListHeaders)
|
||||
g.POST("/:id/headers", h.CreateHeader)
|
||||
g.PUT("/:id/headers/:hid", h.UpdateHeader)
|
||||
g.DELETE("/:id/headers/:hid", h.DeleteHeader)
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) List(c *gin.Context) {
|
||||
@@ -161,6 +168,127 @@ func parseID(c *gin.Context) (int64, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// ── Response-Header CRUD ──────────────────────────────────────────────
|
||||
//
|
||||
// Header werden über `http-response set-header` von HAProxy gesetzt
|
||||
// (siehe internal/haproxy/haproxy.cfg.tpl). Name ist case-insensitive
|
||||
// unique pro Domain — Doppel-Eintrag erkennt PG selbst via Unique-Index
|
||||
// und liefert 409 zurück.
|
||||
|
||||
func (h *DomainsHandler) ListHeaders(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.ListForDomain(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"headers": out})
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) CreateHeader(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DomainResponseHeader
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
req.DomainID = id
|
||||
if !validHeaderName(req.Name) {
|
||||
response.BadRequest(c, errors.New("invalid header name"))
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.create", out.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "header.create")
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) UpdateHeader(c *gin.Context) {
|
||||
_, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid header id"))
|
||||
return
|
||||
}
|
||||
var req models.DomainResponseHeader
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if !validHeaderName(req.Name) {
|
||||
response.BadRequest(c, errors.New("invalid header name"))
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.Update(c.Request.Context(), hid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.update", out.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "header.update")
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) DeleteHeader(c *gin.Context) {
|
||||
_, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid header id"))
|
||||
return
|
||||
}
|
||||
if err := h.Headers.Delete(c.Request.Context(), hid); err != nil {
|
||||
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.delete",
|
||||
strconv.FormatInt(hid, 10), gin.H{"id": hid}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "header.delete")
|
||||
}
|
||||
|
||||
// validHeaderName: RFC 7230 token chars. Wir engen das auf eine
|
||||
// vernünftige Untermenge ein (a-z A-Z 0-9 + `-`), damit niemand
|
||||
// versehentlich Newlines oder Quotes in den HAProxy-Renderer schiebt
|
||||
// und die Config-Datei sprengt.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" || len(name) > 100 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-':
|
||||
// ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func actorOf(c *gin.Context) string {
|
||||
if t := CurrentToken(c); t != nil {
|
||||
return t.Actor
|
||||
|
||||
@@ -3,7 +3,10 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -37,6 +40,7 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/ntp")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/status", h.Status)
|
||||
|
||||
p := g.Group("/pools")
|
||||
p.GET("", h.ListPools)
|
||||
@@ -46,6 +50,74 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
p.DELETE("/:id", h.DeletePool)
|
||||
}
|
||||
|
||||
// Status liefert den aktuellen chrony-Tracking-Status via `chronyc tracking`.
|
||||
// Output-Felder: synced (bool), reference (Quell-Server), stratum (int),
|
||||
// offset_ms (float), freq_ppm (float), rms_offset_ms (float), error (string
|
||||
// wenn chronyc nicht verfügbar oder System-Takt nicht sync'd).
|
||||
func (h *NTPHandler) Status(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "tracking").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"synced": false,
|
||||
"error": "chronyc nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp := parseChronyTracking(string(out))
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
type chronyStatus struct {
|
||||
Synced bool `json:"synced"`
|
||||
Reference string `json:"reference"`
|
||||
Stratum int `json:"stratum"`
|
||||
OffsetMs float64 `json:"offset_ms"`
|
||||
FreqPPM float64 `json:"freq_ppm"`
|
||||
RMSOffsetMs float64 `json:"rms_offset_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func parseChronyTracking(out string) chronyStatus {
|
||||
s := chronyStatus{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Reference ID":
|
||||
// "Reference ID : A29FC801 (time.cloudflare.com)"
|
||||
if i := strings.Index(val, "("); i >= 0 {
|
||||
s.Reference = strings.Trim(val[i:], "()")
|
||||
} else {
|
||||
s.Reference = val
|
||||
}
|
||||
s.Synced = val != "00000000 ()"
|
||||
case "Stratum":
|
||||
fmt.Sscanf(val, "%d", &s.Stratum)
|
||||
if s.Stratum > 0 && s.Stratum < 16 {
|
||||
s.Synced = true
|
||||
}
|
||||
case "System time":
|
||||
// "0.000012345 seconds fast of NTP time"
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
s.OffsetMs = v * 1000
|
||||
case "Frequency":
|
||||
// "-12.345 ppm slow" or "+12.345 ppm fast"
|
||||
fmt.Sscanf(val, "%f", &s.FreqPPM)
|
||||
case "RMS offset":
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
s.RMSOffsetMs = v * 1000
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *NTPHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
@@ -11,22 +12,40 @@ import (
|
||||
// are mounted before SetupGate so they remain reachable while the API
|
||||
// is in setup mode.
|
||||
type SetupHandler struct {
|
||||
Store *setup.Store
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||
return &SetupHandler{Store: store}
|
||||
}
|
||||
|
||||
// WithAudit injiziert Audit-Repo + Node-ID damit Mutationen (contact-emails)
|
||||
// in audit_log landen. Optional — wenn Audit nil bleibt, läuft die
|
||||
// Mutation, aber ohne Log-Eintrag.
|
||||
func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.GET("/status", h.Status)
|
||||
g.POST("/complete", h.Complete)
|
||||
}
|
||||
|
||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||
// den Admin-Modus brauchen — Aufrufer hat requireAuth schon dran.
|
||||
func (h *SetupHandler) RegisterAuthed(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.POST("/contact-emails", h.SetContactEmails)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// it's done and (if so) the configured admin_email + acme_email +
|
||||
// fqdn. Never exposes the password hash.
|
||||
func (h *SetupHandler) Status(c *gin.Context) {
|
||||
st, err := h.Store.Load()
|
||||
if err != nil {
|
||||
@@ -36,10 +55,45 @@ func (h *SetupHandler) Status(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// SetContactEmails: Admin-only Update der zwei E-Mail-Felder.
|
||||
// Sessions bleiben aktiv (Cookie referenziert den alten Actor); auf
|
||||
// nächstem Login zählt der neue Wert.
|
||||
func (h *SetupHandler) SetContactEmails(c *gin.Context) {
|
||||
var req struct {
|
||||
AdminEmail string `json:"admin_email" binding:"required,email"`
|
||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Store.SetContactEmails(req.AdminEmail, req.ACMEEmail); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Store.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "setup.contact_emails",
|
||||
st.AdminEmail, gin.H{
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Complete(c *gin.Context) {
|
||||
var req setup.Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -16,20 +15,55 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// SystemHandler covers /system/health, /system/package-versions,
|
||||
// /system/upgrade, /system/auto-update + /system/maintenance.
|
||||
//
|
||||
// Setup + HAProxyReloader sind optional — werden für /maintenance
|
||||
// gebraucht. Wenn nil sind die Maintenance-Endpoints noch verfügbar
|
||||
// aber returnen 503 (Setup nicht abgeschlossen).
|
||||
type SystemHandler struct {
|
||||
Version string
|
||||
Version string
|
||||
Setup *setup.Store
|
||||
HAProxyReloader func(stdcontext.Context) error
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSystemHandler(version string) *SystemHandler {
|
||||
return &SystemHandler{Version: version}
|
||||
}
|
||||
|
||||
// WithMaintenance: setup + reloader injection. Aufrufer in main.go
|
||||
// nach DB-Pool-Open.
|
||||
func (h *SystemHandler) WithMaintenance(setupStore *setup.Store, reloader func(stdcontext.Context) error) *SystemHandler {
|
||||
h.Setup = setupStore
|
||||
h.HAProxyReloader = reloader
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAudit: Audit-Repo + NodeID damit Toggle-Aktionen (Maintenance,
|
||||
// Auto-Update) ins audit_log fließen.
|
||||
func (h *SystemHandler) WithAudit(a *audit.Repo, nodeID string) *SystemHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// WithDB: Pool für DB-Size + andere PG-introspection-Endpoints.
|
||||
func (h *SystemHandler) WithDB(pool *pgxpool.Pool) *SystemHandler {
|
||||
h.Pool = pool
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/system")
|
||||
g.GET("/health", h.Health)
|
||||
@@ -38,6 +72,32 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/interfaces", h.Interfaces)
|
||||
g.GET("/services", h.Services)
|
||||
g.GET("/resources", h.Resources)
|
||||
g.GET("/auto-update", h.AutoUpdate)
|
||||
g.POST("/auto-update", h.ToggleAutoUpdate)
|
||||
g.GET("/maintenance", h.Maintenance)
|
||||
g.POST("/maintenance", h.ToggleMaintenance)
|
||||
g.GET("/backup-retention", h.BackupRetention)
|
||||
g.POST("/backup-retention", h.SetBackupRetention)
|
||||
g.GET("/audit-retention", h.AuditRetention)
|
||||
g.POST("/audit-retention", h.SetAuditRetention)
|
||||
g.GET("/db-size", h.DBSize)
|
||||
g.POST("/haproxy-reload", h.HAProxyReload)
|
||||
g.POST("/render-configs", h.RenderConfigs)
|
||||
g.POST("/service-restart", h.ServiceRestart)
|
||||
g.GET("/upgrade-status", h.UpgradeStatus)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
||||
// Agent-Engine (Port :8443). Der Cluster-Aggregator auf der Main-API
|
||||
// ruft diese Endpoints parallel auf allen Peers ab und kompiliert das
|
||||
// Ergebnis für /cluster/system/load.
|
||||
//
|
||||
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
|
||||
// auf jedem Peer triggern), KEIN /upgrade.
|
||||
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/system")
|
||||
g.GET("/health", h.Health)
|
||||
g.GET("/resources", h.Resources)
|
||||
}
|
||||
|
||||
// servicesToCheck is the curated list shown on the dashboard
|
||||
@@ -141,6 +201,14 @@ type resources struct {
|
||||
// Resources reads /proc + statfs for the box-level metrics card.
|
||||
// All best-effort — missing files just leave the field at zero.
|
||||
func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
response.OK(c, computeLocalSystemResources())
|
||||
}
|
||||
|
||||
// computeLocalSystemResources extrahiert die /proc + statfs reads aus
|
||||
// dem Handler damit der Cluster-Aggregator denselben Snapshot ohne
|
||||
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
|
||||
// die jeweiligen Felder einfach auf 0.
|
||||
func computeLocalSystemResources() resources {
|
||||
r := resources{}
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(data))
|
||||
@@ -195,7 +263,383 @@ func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, r)
|
||||
return r
|
||||
}
|
||||
|
||||
// Maintenance liefert den aktuellen Whole-Box-Maintenance-Status.
|
||||
// Wenn Setup nicht initialisiert: enabled=false.
|
||||
func (h *SystemHandler) Maintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.OK(c, gin.H{"enabled": false, "message": ""})
|
||||
return
|
||||
}
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil || st == nil {
|
||||
response.OK(c, gin.H{"enabled": false, "message": ""})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"enabled": st.MaintenanceMode,
|
||||
"message": st.MaintenanceMessage,
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleMaintenance schaltet whole-box maintenance an/aus.
|
||||
// Persistiert in setup-State + triggert HAProxy-Render damit das
|
||||
// 503-Block aktiv wird (oder verschwindet).
|
||||
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable,
|
||||
simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetMaintenanceMode(req.Enabled, req.Message); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.HAProxyReloader != nil {
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
slog.Warn("system: haproxy reload after maintenance toggle failed", "error", err)
|
||||
}
|
||||
}
|
||||
if h.Audit != nil {
|
||||
action := "system.maintenance.off"
|
||||
if req.Enabled {
|
||||
action = "system.maintenance.on"
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
|
||||
"", gin.H{"enabled": req.Enabled, "message": req.Message}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": req.Enabled, "message": req.Message})
|
||||
}
|
||||
|
||||
type simpleErr string
|
||||
|
||||
func (e simpleErr) Error() string { return string(e) }
|
||||
|
||||
// BackupRetention liefert keep_n. 0 = Default (backup.DefaultKeepN).
|
||||
func (h *SystemHandler) BackupRetention(c *gin.Context) {
|
||||
keep := 0
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
keep = st.BackupRetentionKeep
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"keep": keep, "default": 14})
|
||||
}
|
||||
|
||||
// SetBackupRetention persistiert die Operator-gewählte Retention.
|
||||
// keep=0 → wieder Default, keep=1..365 → custom.
|
||||
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetBackupRetention(req.Keep); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.backup_retention",
|
||||
"", gin.H{"keep": req.Keep}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"keep": req.Keep})
|
||||
}
|
||||
|
||||
// AuditRetention liefert die konfigurierte Retention in Tagen.
|
||||
// 0 = Default (90 — siehe scheduler/main.go).
|
||||
func (h *SystemHandler) AuditRetention(c *gin.Context) {
|
||||
days := 0
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
days = st.AuditRetentionDays
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"days": days, "default": 90})
|
||||
}
|
||||
|
||||
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
|
||||
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAuditRetention(req.Days); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.audit_retention",
|
||||
"", gin.H{"days": req.Days}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"days": req.Days})
|
||||
}
|
||||
|
||||
// DBSize liefert pg_database_size + Top-N Tabellen-Größen für Capacity-
|
||||
// Planning. Operator sieht so welche Tabellen Disk fressen (typisch
|
||||
// audit_log + firewall_log → siehe Retention-Settings).
|
||||
type dbSizeTable struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
HumanSz string `json:"human_size"`
|
||||
}
|
||||
type dbSizeResponse struct {
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
HumanTotal string `json:"human_total"`
|
||||
Tables []dbSizeTable `json:"top_tables"`
|
||||
}
|
||||
|
||||
func (h *SystemHandler) DBSize(c *gin.Context) {
|
||||
if h.Pool == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
var total int64
|
||||
var totalH string
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
SELECT pg_database_size(current_database()),
|
||||
pg_size_pretty(pg_database_size(current_database()))`).Scan(&total, &totalH); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Top 10 user-tables nach total_relation_size (inkl. Indizes + TOAST).
|
||||
rows, err := h.Pool.Query(ctx, `
|
||||
SELECT c.relname,
|
||||
pg_total_relation_size(c.oid),
|
||||
pg_size_pretty(pg_total_relation_size(c.oid))
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r' AND n.nspname = 'public'
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := dbSizeResponse{TotalBytes: total, HumanTotal: totalH, Tables: []dbSizeTable{}}
|
||||
for rows.Next() {
|
||||
var t dbSizeTable
|
||||
if err := rows.Scan(&t.Name, &t.Bytes, &t.HumanSz); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out.Tables = append(out.Tables, t)
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
|
||||
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
|
||||
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
|
||||
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
|
||||
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||
if err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.haproxy_reload",
|
||||
"", gin.H{}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// restartAllowlist sind die Dienste die der Operator über die UI neu
|
||||
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
|
||||
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
|
||||
var restartAllowlist = map[string]bool{
|
||||
"haproxy": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
"chrony": true,
|
||||
"nftables": true,
|
||||
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
|
||||
"edgeguard-scheduler": true,
|
||||
}
|
||||
|
||||
// ServiceRestart startet einen Dienst aus der Allowlist via
|
||||
// `systemctl restart`. Gibt 400 zurück wenn der Dienst nicht auf der
|
||||
// Allowlist steht, 500 wenn systemctl fehlschlägt.
|
||||
func (h *SystemHandler) ServiceRestart(c *gin.Context) {
|
||||
var req struct {
|
||||
Service string `json:"service" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service required"))
|
||||
return
|
||||
}
|
||||
svc := strings.TrimSpace(req.Service)
|
||||
if !restartAllowlist[svc] {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service not in allowlist: "+svc))
|
||||
return
|
||||
}
|
||||
unitName := svc + ".service"
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", unitName).CombinedOutput()
|
||||
if err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_restart",
|
||||
svc, gin.H{"service": svc}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "service": svc})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
|
||||
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
|
||||
// dass die generierte Config nicht mehr mit der DB übereinstimmt
|
||||
// (Drift, Manual-Edit, etc.).
|
||||
//
|
||||
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
|
||||
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
|
||||
// gekoppelt; für die fehlt aktuell ein generisches "render all".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
|
||||
}
|
||||
|
||||
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
|
||||
// Reads systemctl show + journalctl der edgeguard-upgrade.service
|
||||
// transient unit. Hilft beim Debuggen wenn der Update-Banner nach
|
||||
// einem Click nicht weg geht — typische Ursachen (apt-Resolver-fail,
|
||||
// dpkg-broken) sind direkt im Log sichtbar.
|
||||
type upgradeStatusResponse struct {
|
||||
State string `json:"state"` // inactive / activating / failed / ...
|
||||
Result string `json:"result"` // success / exit-code / ...
|
||||
ExecMainPID int `json:"exec_main_pid"` // 0 wenn nie gelaufen
|
||||
ExitCode int `json:"exit_code"` // exit-status des letzten Laufs
|
||||
StartedAt string `json:"started_at"` // RFC3339 oder leer
|
||||
FinishedAt string `json:"finished_at"` // RFC3339 oder leer
|
||||
Log []string `json:"log"` // letzte N Zeilen aus journalctl
|
||||
}
|
||||
|
||||
func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
|
||||
out := upgradeStatusResponse{Log: []string{}}
|
||||
|
||||
// systemctl show liefert key=value pairs für die transient unit.
|
||||
// Wenn die Unit nie existiert hat → leeres Output / inactive.
|
||||
if data, err := exec.Command("systemctl", "show", "edgeguard-upgrade.service",
|
||||
"--no-page",
|
||||
"-p", "ActiveState",
|
||||
"-p", "Result",
|
||||
"-p", "ExecMainPID",
|
||||
"-p", "ExecMainStatus",
|
||||
"-p", "ExecMainStartTimestamp",
|
||||
"-p", "ExecMainExitTimestamp",
|
||||
).CombinedOutput(); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ActiveState":
|
||||
out.State = kv[1]
|
||||
case "Result":
|
||||
out.Result = kv[1]
|
||||
case "ExecMainPID":
|
||||
out.ExecMainPID, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStatus":
|
||||
out.ExitCode, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStartTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.StartedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
case "ExecMainExitTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.FinishedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte 200 Zeilen Journal — reicht für apt-output + Stack-Traces.
|
||||
if data, err := exec.Command("journalctl",
|
||||
"-u", "edgeguard-upgrade.service",
|
||||
"--no-pager", "-n", "200", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
// Leere "no entries"-Antwort als leeres Log zurückgeben.
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// AutoUpdate liefert den aktuellen Status (Conf-File existiert?).
|
||||
func (h *SystemHandler) AutoUpdate(c *gin.Context) {
|
||||
response.OK(c, gin.H{"enabled": aptsvc.AutoUpdateEnabled()})
|
||||
}
|
||||
|
||||
// ToggleAutoUpdate schaltet automatische Updates an/aus. Schreibt
|
||||
// /etc/apt/apt.conf.d/52edgeguard-auto-updates (sudo tee) bzw.
|
||||
// entfernt es (sudo rm). Idempotent.
|
||||
func (h *SystemHandler) ToggleAutoUpdate(c *gin.Context) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetAutoUpdate(req.Enabled); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
action := "system.auto_update.off"
|
||||
if req.Enabled {
|
||||
action = "system.auto_update.on"
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
|
||||
"", gin.H{"enabled": req.Enabled}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": req.Enabled})
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Health(c *gin.Context) {
|
||||
@@ -207,29 +651,16 @@ func (h *SystemHandler) Health(c *gin.Context) {
|
||||
|
||||
// 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.
|
||||
// polls every 30s 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.
|
||||
// Implementierung delegiert an internal/services/apt: dort sitzt der
|
||||
// 5-min-Throttle für apt-get update, der LC_ALL=C-Fix für deutsche
|
||||
// Locales und das Background-Refresh-Timer. `?force=1` (UI-Button
|
||||
// „Jetzt prüfen") bypassed den Throttle, damit nach einem `make publish`
|
||||
// nicht 5 min auf das nächste Tick gewartet werden muss.
|
||||
func (h *SystemHandler) PackageVersions(c *gin.Context) {
|
||||
// API läuft als edgeguard-User; ohne sudo schreibt apt-get update
|
||||
// nicht in /var/lib/apt/lists und der candidate bleibt veraltet.
|
||||
// Sudoers-Eintrag in postinst whitelisted exakt diese Zeile.
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/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
|
||||
}
|
||||
force := c.Query("force") == "1" || c.Query("force") == "true"
|
||||
out := aptsvc.PackageVersions(c.Request.Context(), force)
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -248,16 +679,42 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
|
||||
// /var/lib/edgeguard ist edgeguard-owned + persistent + von
|
||||
// beiden Namespaces aus zugänglich.
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
|
||||
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
|
||||
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
|
||||
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
|
||||
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
|
||||
// nachzuziehen. Befund 2026-05-17.
|
||||
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
|
||||
|
||||
retry_apt() {
|
||||
local attempt=0
|
||||
local max=3
|
||||
local wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then
|
||||
return 0
|
||||
fi
|
||||
if [ $attempt -lt $max ]; then
|
||||
echo "[upgrade] failed (likely Packages-index race), waiting ${wait_for}s before retry"
|
||||
sleep $wait_for
|
||||
wait_for=$((wait_for * 2))
|
||||
fi
|
||||
done
|
||||
echo "[upgrade] all $max attempts failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
@@ -401,27 +858,3 @@ func flagsToList(f net.Flags) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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