Files
edgeguard-native/internal/handlers/firewall_log.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

185 lines
4.7 KiB
Go

package handlers
import (
"context"
"errors"
"log/slog"
"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/firewalllog"
)
// FirewallLogHandler exposes:
//
// GET /api/v1/firewall/log?since=&until=&rule_id=&src=&dst=&proto=&action=&limit=
// WS /api/v1/firewall/log/live
//
// since/until: RFC3339 timestamp. limit defaults to 200, max 1000.
// Die HTTP-Variante liest direkt das jsonl-File (Historie der letzten
// 14 Tage), die WS-Variante streamt aus dem In-Memory-Ringbuffer +
// jeden neuen Event ab dem Connect.
type FirewallLogHandler struct {
Tailer *firewalllog.Tailer
LogPath string
}
func NewFirewallLogHandler(tailer *firewalllog.Tailer, path string) *FirewallLogHandler {
if path == "" {
path = firewalllog.DefaultLogPath
}
return &FirewallLogHandler{Tailer: tailer, LogPath: path}
}
func (h *FirewallLogHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/firewall")
g.GET("/log", h.Tail)
g.GET("/log/live", h.Live)
}
// Tail liest die letzten matching Events aus dem rotated jsonl-File.
// Die HTTP-Antwort ist eine Liste — keine Pagination, der Ring-Cap
// (limit, max 1000) ist die Grenze. UI ruft das beim Page-Open auf
// und holt danach Live-Events über den WS.
func (h *FirewallLogHandler) Tail(c *gin.Context) {
f := parseFilter(c)
entries, err := firewalllog.ReadTail(h.LogPath, f)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"entries": entries, "count": len(entries)})
}
// upgrader: kein CheckOrigin (Same-Origin durch HAProxy bzw. dev-vite-
// proxy; ohne Origin-Check geht's außerdem in der Dev-Konsole auf).
var fwLogUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 32 * 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
// Live upgraded auf WebSocket, schickt einen Initial-Snapshot aus dem
// Ringbuffer (gefiltert nach Query-Params) und dann jeden neuen Event
// als JSON-Zeile.
func (h *FirewallLogHandler) Live(c *gin.Context) {
if h.Tailer == nil {
response.Err(c, http.StatusServiceUnavailable,
errors.New("firewall log tailer not running"))
return
}
conn, err := fwLogUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
// Upgrade-Failures sind Browser-side; nichts loggen
return
}
defer func() { _ = conn.Close() }()
f := parseFilter(c)
// 1) Snapshot aus dem Ring — UI sieht sofort die letzten Events.
for _, e := range h.Tailer.Snapshot(f) {
if err := conn.WriteJSON(e); err != nil {
return
}
}
// 2) Subscribe + stream forward.
ch, unsub := h.Tailer.Subscribe()
defer unsub()
// Read-loop nebenher um Close-Frames + Ping-Pong sauber zu
// behandeln. Wenn der Client zumacht, returnen wir aus der Write-
// Schleife.
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
go func() {
defer cancel()
for {
if _, _, err := conn.NextReader(); err != nil {
return
}
}
}()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
_ = conn.SetWriteDeadline(time.Now().Add(60 * time.Second))
for {
select {
case <-ctx.Done():
return
case e, ok := <-ch:
if !ok {
return
}
if !f.Matches(&e) {
continue
}
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteJSON(e); err != nil {
return
}
case <-ticker.C:
// Ping um stale-connection-detection durch HAProxy auf
// timeout client/tunnel zu vermeiden.
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteControl(websocket.PingMessage, nil,
time.Now().Add(5*time.Second)); err != nil {
return
}
}
}
}
func parseFilter(c *gin.Context) firewalllog.Filter {
f := firewalllog.Filter{
RuleID: c.Query("rule_id"),
SrcIP: c.Query("src"),
DstIP: c.Query("dst"),
Proto: c.Query("proto"),
Action: c.Query("action"),
}
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 s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.Limit = n
}
}
if f.Limit <= 0 {
f.Limit = 200
}
if f.Limit > 1000 {
f.Limit = 1000
}
return f
}
// startFirewallLogTailer is a small helper main.go invokes — wird
// hier definiert damit der Lifecycle (ctx, slog.Warn) eine Heimat hat.
func StartFirewallLogTailer(ctx context.Context, t *firewalllog.Tailer) {
if t == nil {
return
}
go func() {
if err := t.Start(ctx); err != nil && err != context.Canceled {
slog.Warn("firewalllog tailer exited", "error", err)
}
}()
}