Files
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

274 lines
9.1 KiB
Go
Raw Permalink 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 aggregator führt parallele Cluster-Reads gegen alle Peer-Nodes
// via mTLS aus.
//
// Pattern: ein Aggregator-Endpoint auf der Main-API (z.B.
// /api/v1/cluster/system/load) ruft Aggregator.FanOut() — das verteilt
// die Request parallel an alle Peers' Agent-Listener (:8443 mTLS) und
// sammelt die Antworten in einer Map[node_id]→Ergebnis. Timeouts pro
// Peer (3s default) verhindern dass ein hängender Peer die ganze Antwort
// blockt; partielle Ergebnisse + per-Peer-Fehler werden zurückgegeben.
//
// mTLS-Auth: ClientTLSConfig aus clustertls.Store. CA muss auf beiden
// Seiten dieselbe sein — sonst RequireAndVerifyClientCert lehnt ab.
package aggregator
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// DefaultAgentPort: alle Peers exposen ihren mTLS-Listener auf diesem
// Port. api_url in ha_nodes zeigt typischerweise auf den Public-3443-
// Port — wir derive'n den Agent-Port daraus, statt eine zweite Spalte
// in ha_nodes zu führen.
const DefaultAgentPort = 8443
// DefaultPeerTimeout: pro-Peer-Timeout. Aggregat-Caller sollten eine
// Obergrenze von max(N×PeerTimeout/parallel) im Kopf haben; in der
// Praxis ist alles parallel, also bestimmt der langsamste Peer die
// Latenz.
const DefaultPeerTimeout = 3 * time.Second
// Aggregator: dünner Wrapper mit ClientTLSConfig + http.Client.
type Aggregator struct {
HTTPClient *http.Client
AgentPort int
}
// New: liefert einen Aggregator der ClientTLSConfig verwendet. Wenn
// clientTLS == nil, geht der Client auf normales TLS-Verify zurück —
// für Tests nützlich, in Prod aber unsicher (würde Cert-Verify gegen
// System-Trust laufen, das den Cluster-CA nicht kennt).
func New(clientTLS *tls.Config) *Aggregator {
tr := &http.Transport{
TLSClientConfig: clientTLS,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 3 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
}
return &Aggregator{
HTTPClient: &http.Client{
Transport: tr,
Timeout: DefaultPeerTimeout,
},
AgentPort: DefaultAgentPort,
}
}
// PeerResult kapselt das Ergebnis eines parallelen Fan-Out-Calls.
// Wenn Err != nil ist Data leer; sonst enthält Data den raw-JSON-Body
// (Aufrufer entscheidet ob es per-Peer typed-unmarshalled oder als
// map[string]any belassen wird).
type PeerResult struct {
NodeID string `json:"node_id"`
FQDN string `json:"fqdn"`
OK bool `json:"ok"`
Data json.RawMessage `json:"data,omitempty"`
Err string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
// FanOut: ruft GET <agent-url>/<path> für jeden Peer in `peers` parallel
// und sammelt die Ergebnisse in einer slice (stabile Sortierung nach
// Peer-FQDN für deterministisches UI-Rendering).
//
// `path` ist relativ, z.B. "/agent/system/load". `localID` wird als
// Marker übergeben damit der Aufrufer den eigenen Node von der Map
// ausschließen kann.
func (a *Aggregator) FanOut(ctx context.Context, peers []models.HANode, path, localID string) []PeerResult {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
results := make([]PeerResult, len(peers))
var wg sync.WaitGroup
for i, p := range peers {
if p.ID == localID {
// Eigener Node nicht über mTLS dial'n — wäre teuer + im
// Aufrufer wahrscheinlich der lokale Path
results[i] = PeerResult{NodeID: p.ID, FQDN: p.FQDN, OK: false, Err: "skipped: local node"}
continue
}
wg.Add(1)
i := i
p := p
go func() {
defer wg.Done()
results[i] = a.callPeer(ctx, p, path)
}()
}
wg.Wait()
return results
}
// callPeer macht den Einzel-Call. Wandelt p.APIURL in https://host:8443/
// um (Port übersteuert, Pfad ersetzt). Bei Connection-Fehler / Timeout
// liefert ein PeerResult mit OK=false zurück.
func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string) PeerResult {
start := time.Now()
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
target, err := agentURL(p.APIURL, a.AgentPort, path)
if err != nil {
res.Err = "bad api_url: " + err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
reqCtx, cancel := context.WithTimeout(ctx, DefaultPeerTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
resp, err := a.HTTPClient.Do(req)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
if resp.StatusCode != http.StatusOK {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
// Agent-Endpoints liefern die Standard-API-Envelope zurück
// ({"data": {...}, "error": null, "message": "ok"}). Wir entpacken
// das `data`-Feld so dass der Aufrufer direkt die Nutzlast bekommt —
// konsistent mit dem Lokal-Pfad (der marshalt die Struct direkt ohne
// Envelope).
var env struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &env); err == nil && len(env.Data) > 0 {
res.Data = env.Data
} else {
res.Data = body
}
res.Duration = time.Since(start).Milliseconds()
return res
}
// agentURL: nimmt z.B. "https://node1.example.com:3443" + port=8443 +
// path="/agent/system/load" und liefert "https://node1.example.com:8443/agent/system/load".
// Wir tauschen den Port aus, behalten Schema + Host (nur).
func agentURL(apiURL string, agentPort int, path string) (string, error) {
if apiURL == "" {
return "", errors.New("empty api_url")
}
u, err := url.Parse(apiURL)
if err != nil {
return "", err
}
if u.Scheme == "" {
u.Scheme = "https"
}
host := u.Hostname()
if host == "" {
return "", errors.New("api_url has no host")
}
u.Host = net.JoinHostPort(host, fmt.Sprint(agentPort))
u.Path = path
u.RawQuery = ""
return u.String(), nil
}
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
// auf dem Secondary auszulösen.
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) PeerResult {
start := time.Now()
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
target, err := agentURL(p.APIURL, a.AgentPort, path)
if err != nil {
res.Err = "bad api_url: " + err.Error()
return res
}
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, nil)
if err != nil {
res.Err = err.Error()
return res
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.HTTPClient.Do(req)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
res.Duration = time.Since(start).Milliseconds()
return res
}
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) PeerResult {
start := time.Now()
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
target, err := agentURL(p.APIURL, a.AgentPort, path)
if err != nil {
res.Err = "bad api_url: " + err.Error()
return res
}
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
res.Err = err.Error()
return res
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.HTTPClient.Do(req)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
res.Data = respBody
res.Duration = time.Since(start).Milliseconds()
return res
}
// Compile-time check dass cluster importiert wird (für Drift-Detection
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
// die Abhängigkeit.
var _ = cluster.ComputeConfigHash