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>
185 lines
4.7 KiB
Go
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)
|
|
}
|
|
}()
|
|
}
|