diff --git a/VERSION b/VERSION index eea6f62..be1dcc8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.72 +1.0.73 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index d7ab364..8bf0dda 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -52,7 +52,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.72" +var version = "1.0.73" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index cb9f86e..50da00f 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.72" +var version = "1.0.73" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 7c397ea..b1bedad 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -28,7 +28,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.72" +var version = "1.0.73" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index a8e14ec..55a9ead 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -1,9 +1,13 @@ package handlers import ( + "context" + "net/http" "strconv" + "time" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" @@ -18,10 +22,12 @@ 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("/live", h.Live) } // Recent returns the most recent audit_log entries — used by the -// dashboard's recent-activity card. ?limit=N (1–100, default 10). +// dashboard fallback path (z.B. wenn WebSocket nicht verbinden kann). +// ?limit=N (1–100, default 10). func (h *AuditHandler) Recent(c *gin.Context) { limit := 10 if v := c.Query("limit"); v != "" { @@ -36,3 +42,74 @@ func (h *AuditHandler) Recent(c *gin.Context) { } response.OK(c, gin.H{"entries": rows}) } + +// auditUpgrader: same-origin durch HAProxy, kein CheckOrigin. +var auditUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 4 * 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Live upgraded auf WebSocket: sendet einen Snapshot der letzten 50 +// audit_log-Rows, danach jeden neuen Eintrag direkt aus dem +// Repo.broadcast()-Channel. +func (h *AuditHandler) Live(c *gin.Context) { + conn, err := auditUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + // Snapshot + if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil { + // In aufsteigender Reihenfolge schicken (newest last) damit der + // Client nach unten scrollt + neue Events natürlich anhängt. + for i := len(rows) - 1; i >= 0; i-- { + if err := conn.WriteJSON(rows[i]); err != nil { + return + } + } + } + + // Live-Subscribe + ch, unsub := h.Repo.Subscribe() + defer unsub() + + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + // Read-Loop für close-frame + ping-pong + go func() { + defer cancel() + for { + if _, _, err := conn.NextReader(); err != nil { + return + } + } + }() + + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + _ = conn.SetWriteDeadline(time.Now().Add(60 * time.Second)) + + for { + select { + case <-ctx.Done(): + return + case e, ok := <-ch: + if !ok { + return + } + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteJSON(e); err != nil { + return + } + case <-ping.C: + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteControl(websocket.PingMessage, nil, + time.Now().Add(5*time.Second)); err != nil { + return + } + } + } +} diff --git a/internal/services/audit/audit.go b/internal/services/audit/audit.go index 28ab7a2..f03cb23 100644 --- a/internal/services/audit/audit.go +++ b/internal/services/audit/audit.go @@ -6,6 +6,7 @@ package audit import ( "context" "encoding/json" + "sync" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -13,9 +14,48 @@ import ( type Repo struct { Pool *pgxpool.Pool + + subsMu sync.RWMutex + subs map[chan Entry]struct{} } -func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } +func New(pool *pgxpool.Pool) *Repo { + return &Repo{Pool: pool, subs: map[chan Entry]struct{}{}} +} + +// Subscribe gibt einen Channel für Live-Audit-Events zurück + ein +// Unsubscribe-Cleanup. Channel-Buffer 32 — bei stehenden Clients +// werden Events gedropt (non-blocking-send). +func (r *Repo) Subscribe() (<-chan Entry, func()) { + c := make(chan Entry, 32) + r.subsMu.Lock() + if r.subs == nil { + r.subs = map[chan Entry]struct{}{} + } + r.subs[c] = struct{}{} + r.subsMu.Unlock() + return c, func() { + r.subsMu.Lock() + delete(r.subs, c) + r.subsMu.Unlock() + close(c) + } +} + +func (r *Repo) broadcast(e Entry) { + r.subsMu.RLock() + subs := make([]chan Entry, 0, len(r.subs)) + for c := range r.subs { + subs = append(subs, c) + } + r.subsMu.RUnlock() + for _, c := range subs { + select { + case c <- e: + default: + } + } +} // Entry mirrors one audit_log row — ListRecent returns these for // the dashboard's recent-activity card. @@ -82,9 +122,34 @@ func (r *Repo) Log(ctx context.Context, actor, action, subject string, detail an if nodeID == "" { nodeArg = nil } - _, err := r.Pool.Exec(ctx, - `INSERT INTO audit_log (actor, action, subject, detail, node_id) - VALUES ($1, $2, $3, $4, $5)`, - actor, action, subjectArg, detailJSON, nodeArg) - return err + // RETURNING id+created_at damit der Subscribe-Channel direkt einen + // vollständigen Entry verteilen kann — Subscriber müssen nicht + // erneut die DB hitten für die Anzeige. + var e Entry + e.Actor = actor + e.Action = action + if subject != "" { + s := subject + e.Subject = &s + } + if len(detailJSON) > 0 { + e.Detail = json.RawMessage(detailJSON) + } + if nodeID != "" { + n := nodeID + e.NodeID = &n + } + err := r.Pool.QueryRow(ctx, ` +INSERT INTO audit_log (actor, action, subject, detail, node_id) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, created_at`, + actor, action, subjectArg, detailJSON, nodeArg). + Scan(&e.ID, &e.CreatedAt) + if err != nil { + return err + } + // Broadcast nach erfolgreichem INSERT — wenn DB ablehnt, sollen + // Subscribers das Event auch nicht sehen. + r.broadcast(e) + return nil } diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 5b5cb4b..6a20b7a 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -83,7 +83,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.72' +const VERSION = '1.0.73' // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // -