Files
edgeguard-native/internal/handlers/audit.go
Debian 32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00

154 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
type AuditHandler struct {
Repo *audit.Repo
}
func NewAuditHandler(repo *audit.Repo) *AuditHandler { return &AuditHandler{Repo: 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 (1100, default 10).
func (h *AuditHandler) Recent(c *gin.Context) {
limit := 10
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
rows, err := h.Repo.ListRecent(c.Request.Context(), limit)
if err != nil {
response.Internal(c, err)
return
}
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 func() { _ = 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
}
}
}
}