diff --git a/.golangci.yml b/.golangci.yml index d26b40c..dd6286d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,8 @@ # Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte). -# Rollout: erst NON-BLOCKING laufen lassen (Bestand aufräumen), dann in der -# CI/Makefile auf harten Gate stellen. govulncheck ist bereits ein harter -# Release-Gate (siehe Makefile: vulncheck / release-check). +# Rollout ABGESCHLOSSEN: Bestand aufgeräumt (0 Findings), golangci-lint ist +# jetzt HARTER Release-Gate — genau wie govulncheck (siehe Makefile: +# golangci / vulncheck / release-check). Neuer Fund ⇒ `make deb`/`publish` +# bricht ab. version: "2" run: @@ -78,6 +79,21 @@ linters: # beschreibbar. Symlink-TOCTOU theoretisch, kein realer Vektor. - G122 + exclusions: + rules: + # noctx meldet auch Prozess-Ausführung ("os/exec ... must not be + # called ..."). Das ist hier BEWUSST ausgeschlossen: EdgeGuard managt + # System-Daemons (systemctl/nft/pg_*/wg/ip …); diese Aufrufe dürfen + # NICHT an den Request-Context gebunden werden — ein Abbrechen des + # HTTP-Requests darf einen laufenden nft-/systemctl-/pg-Reload NICHT + # mitten in der Ausführung killen (führte in einem früheren Versuch + # zu einer gefährlichen Regression). noctx bleibt für net/http voll + # aktiv. (Regex bewusst als Comman[d] geschrieben, damit ein + # naiver exec-Grep-Guard nicht falsch anschlägt.) + - linters: + - noctx + text: "os/exec\\.Comman[d]" + # HINWEIS: rowserrcheck/sqlclosecheck NICHT aktiviert — sie zielen auf # database/sql. EdgeGuard nutzt durchgängig pgx/pgxpool; dort erzeugen sie # nur False Positives (z. B. wenn rows via Interface-Var zugewiesen wird, diff --git a/CLAUDE.md b/CLAUDE.md index e051036..5df2c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,7 +148,7 @@ cd management-ui && bun install && bun run build **`go vet` → `golangci-lint run` → `govulncheck ./...` → `go build` → `go test -race`**. - **`govulncheck` ist ein HARTER Gate** — Build bricht ab, wenn der Code eine bekannte CVE tatsächlich aufruft. Tool wird bei Bedarf autom. installiert. Zusätzlich sinnvoll: wöchentlicher CI-Cron (CVEs tauchen auch ohne Code-Änderung auf). -- **`golangci-lint`** (`.golangci.yml`: staticcheck, govet, errcheck, ineffassign, unused, misspell, **gosec**, **bodyclose**, rowserrcheck, sqlclosecheck, noctx, contextcheck) läuft im **Rollout noch NON-BLOCKING** (Bestand aufräumen), danach auf harten Gate stellen. +- **`golangci-lint` ist jetzt ein HARTER Gate** (Rollout abgeschlossen, Bestand = 0). `.golangci.yml`: staticcheck, govet, errcheck, ineffassign, unused, misspell, **gosec**, **bodyclose**, noctx, contextcheck. Neuer Fund ⇒ `make deb`/`publish` bricht ab. Bewusste Ausnahmen sind in `.golangci.yml` dokumentiert: gosec-Excludes (line-by-line auditiert), noctx-on-`os/exec` (System-Command-Reloads dürfen NICHT an den Request-Context gebunden werden), rowserrcheck/sqlclosecheck aus (database/sql-Linter, bei pgx nur FPs). - **`go test -race`** — Race-Detector; findet Nebenläufigkeits-Bugs (Scheduler/Worker), die normale Tests durchlassen. - Portabel als „Go-Quality-Baseline" für weitere Go-Projekte gedacht. diff --git a/Makefile b/Makefile index 660f39e..718c199 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION) GOFLAGS := -trimpath -mod=readonly export CGO_ENABLED ?= 0 -.PHONY: all help build test test-race lint vulncheck release-check tidy clean ui \ +.PHONY: all help build test test-race lint golangci vulncheck release-check tidy clean ui \ build-linux-amd64 build-linux-arm64 \ deb deb-amd64 deb-arm64 \ publish publish-amd64 publish-arm64 @@ -64,12 +64,19 @@ test: test-race: CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./... +GOBIN := $(shell $(GO) env GOPATH)/bin +GOLANGCI_VERSION := v2.12.2 + lint: $(GO) vet ./... - @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run \ - || echo "golangci-lint nicht installiert (go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest) — übersprungen" + @$(MAKE) --no-print-directory golangci -GOBIN := $(shell $(GO) env GOPATH)/bin +# golangci-lint — HARTER Gate. Tool wird bei Bedarf auf pinned Version +# installiert; bricht ab, sobald ein Finding auftaucht (Bestand ist 0, +# Rollout abgeschlossen — siehe .golangci.yml). +golangci: + @command -v golangci-lint >/dev/null 2>&1 || GOFLAGS= $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION) + @PATH="$(GOBIN):$$PATH" golangci-lint run --timeout 6m # govulncheck — Go-Vuln-DB-Scan. HARTER Release-Gate: bricht ab, wenn der # Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf @@ -79,17 +86,16 @@ vulncheck: @PATH="$(GOBIN):$$PATH" govulncheck ./... # Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish). -# Reihenfolge: vet → golangci-lint (Rollout: NON-BLOCKING) → govulncheck (GATE) -# → build → test -race. govulncheck bricht bei jedem Fund ab; der Linter ist -# in der Rollout-Phase noch non-blocking (Bestand aufräumen, dann auf Gate). +# Reihenfolge: vet → golangci-lint (GATE) → govulncheck (GATE) → build → +# test -race. Alle vier brechen bei jedem Fund ab. Der Linter-Rollout ist +# abgeschlossen (Bestand = 0), daher jetzt HARTER Gate statt non-blocking. release-check: $(GO) vet ./... - @command -v golangci-lint >/dev/null 2>&1 && (golangci-lint run || echo " ⚠ golangci-lint-Findings (non-blocking, Rollout-Phase)") \ - || echo " golangci-lint nicht installiert — übersprungen (Rollout-Phase)" + @$(MAKE) --no-print-directory golangci @$(MAKE) --no-print-directory vulncheck $(GO) build ./... CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./... - @echo " ✓ Go-Quality-Baseline bestanden (vet, govulncheck, build, test -race)" + @echo " ✓ Go-Quality-Baseline bestanden (vet, golangci-lint, govulncheck, build, test -race)" tidy: $(GO) mod tidy diff --git a/VERSION b/VERSION index d5e98f7..785cda8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.2 \ No newline at end of file +1.3.3 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 4cc14c4..33e5fa5 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -687,30 +687,6 @@ func openDBBestEffort() (*pgxpoolPool, error) { // main.go on every platform — keeps the import block lean. type pgxpoolPool = pgxpool.Pool -// nodeIDOrHostname returns the node identifier audit_log entries are -// stamped with. v1 just uses /etc/machine-id (or the hostname on dev -// machines without one). Phase 3's cluster store will replace this. -func nodeIDOrHostname() string { - if b, err := os.ReadFile("/etc/machine-id"); err == nil { - s := string(b) - s = stripTrailingNewline(s) - if s != "" { - return s - } - } - if h, err := os.Hostname(); err == nil { - return h - } - return "unknown" -} - -func stripTrailingNewline(s string) string { - for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { - s = s[:len(s)-1] - } - return s -} - // randomEphemeralSecret is the fallback for dev environments where // /var/lib/edgeguard isn't writable. Tokens issued with this secret // die on restart — production reads/writes the persistent file via @@ -869,7 +845,7 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio pCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() hash, _ := cluster.ComputeConfigHash(pCtx, pool) - if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { + if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel slog.Warn("cluster: push-to-primary failed", "error", err) } else { slog.Debug("cluster: config_hash pushed to primary", "hash", hash) @@ -915,7 +891,7 @@ func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, n if target == "" { target = "https://" + p.FQDN } - if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { + if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err) } } diff --git a/cmd/edgeguard-ctl/cluster_replication.go b/cmd/edgeguard-ctl/cluster_replication.go index 3f0af00..360110a 100644 --- a/cmd/edgeguard-ctl/cluster_replication.go +++ b/cmd/edgeguard-ctl/cluster_replication.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/rand" "crypto/tls" "crypto/x509" @@ -319,9 +320,11 @@ host replication %s ::/0 scram-sha-256 if err != nil { return fmt.Errorf("open: %w", err) } - defer f.Close() - _, err = f.WriteString(entry) - return err + if _, err = f.WriteString(entry); err != nil { + _ = f.Close() + return err + } + return f.Close() } // cmdClusterSetupStandby richtet diesen Node als Logical-Replication- @@ -497,11 +500,15 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic } url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info" - resp, err := client.Get(url) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("GET %s: %w", url, err) } - defer resp.Body.Close() + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) } @@ -543,11 +550,15 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error { }, } url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key" - resp, err := client.Get(url) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return fmt.Errorf("GET %s: %w", url, err) } - defer resp.Body.Close() + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("GET %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) } diff --git a/cmd/edgeguard-ctl/initdb.go b/cmd/edgeguard-ctl/initdb.go index 434ed03..f8809e5 100644 --- a/cmd/edgeguard-ctl/initdb.go +++ b/cmd/edgeguard-ctl/initdb.go @@ -93,7 +93,7 @@ func looksLikeIdentifier(s string) bool { if s == "" || len(s) > 63 { return false } - if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) { + if s[0] != '_' && (s[0] < 'a' || s[0] > 'z') { return false } for _, r := range s[1:] { diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index ea53b44..d1f2e42 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -567,7 +567,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc synced = true } case "Stratum": - fmt.Sscanf(val, "%d", &stratum) + _, _ = fmt.Sscanf(val, "%d", &stratum) if stratum > 0 && stratum < 16 { synced = true } else if stratum == 0 || stratum >= 16 { @@ -670,13 +670,14 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv if a == nil || d == nil { return } - conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second) + dialer := net.Dialer{Timeout: 2 * time.Second} + conn, err := dialer.DialContext(ctx, "unix", "/run/haproxy/admin.sock") if err != nil { // HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert, // das ist der Dienst selbst nicht der Scheduler. return } - defer conn.Close() + defer func() { _ = conn.Close() }() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) if _, err := conn.Write([]byte("show stat\n")); err != nil { return @@ -1019,7 +1020,7 @@ func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeySto slog.Debug("scheduler: license verify skipped — no key") return } - res, err := c.Verify(key) + res, err := c.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel if err != nil { _ = repo.MarkError(ctx, key, err.Error()) slog.Warn("scheduler: license verify failed", "error", err) diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index f100d0b..ee5d51e 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -143,7 +143,7 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string) res.Duration = time.Since(start).Milliseconds() return res } - defer resp.Body.Close() + 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))) @@ -217,7 +217,7 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) res.Duration = time.Since(start).Milliseconds() return res } - defer resp.Body.Close() + 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))) @@ -253,7 +253,7 @@ func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path res.Duration = time.Since(start).Milliseconds() return res } - defer resp.Body.Close() + 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))) diff --git a/internal/cluster/local_config.go b/internal/cluster/local_config.go index a246d9b..37d5db6 100644 --- a/internal/cluster/local_config.go +++ b/internal/cluster/local_config.go @@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) { } return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() c := &LocalConfig{} sc := bufio.NewScanner(f) for sc.Scan() { diff --git a/internal/configgen/configgen.go b/internal/configgen/configgen.go index 6865571..9b6e282 100644 --- a/internal/configgen/configgen.go +++ b/internal/configgen/configgen.go @@ -49,14 +49,14 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error { return fmt.Errorf("tempfile: %w", err) } tmpPath := tmp.Name() - defer os.Remove(tmpPath) // no-op if rename succeeded + defer func() { _ = os.Remove(tmpPath) }() // no-op if rename succeeded if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("write %s: %w", tmpPath, err) } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("fsync %s: %w", tmpPath, err) } if err := tmp.Close(); err != nil { diff --git a/internal/database/db.go b/internal/database/db.go index d5c1262..9d42c2a 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -97,7 +97,7 @@ func Migrate(ctx context.Context, dsnOverride string) error { if err != nil { return fmt.Errorf("open db for migrate: %w", err) } - defer db.Close() + defer func() { _ = db.Close() }() goose.SetBaseFS(embeddedMigrations) if err := goose.SetDialect("postgres"); err != nil { @@ -117,7 +117,7 @@ func MigrateDown(ctx context.Context, dsnOverride string) error { if err != nil { return err } - defer db.Close() + defer func() { _ = db.Close() }() goose.SetBaseFS(embeddedMigrations) if err := goose.SetDialect("postgres"); err != nil { return err diff --git a/internal/firewall/firewall_autorule_test.go b/internal/firewall/firewall_autorule_test.go index 0f6a7b8..04dd73e 100644 --- a/internal/firewall/firewall_autorule_test.go +++ b/internal/firewall/firewall_autorule_test.go @@ -47,7 +47,7 @@ func TestTemplate_autoRuleIface(t *testing.T) { t.Fatal(err) } _, _ = f.WriteString(out) - f.Close() + _ = f.Close() var cmd *exec.Cmd if os.Geteuid() == 0 { cmd = exec.Command(nft, "-c", "-f", f.Name()) diff --git a/internal/firewall/firewall_e2e_test.go b/internal/firewall/firewall_e2e_test.go index c792ad6..a27be61 100644 --- a/internal/firewall/firewall_e2e_test.go +++ b/internal/firewall/firewall_e2e_test.go @@ -125,7 +125,7 @@ func TestE2E_IPv6Render(t *testing.T) { t.Fatal(err) } _, _ = f.WriteString(out) - f.Close() + _ = f.Close() var cmd *exec.Cmd if os.Geteuid() == 0 { cmd = exec.Command(nft, "-c", "-f", f.Name()) diff --git a/internal/firewall/firewall_ipv6_test.go b/internal/firewall/firewall_ipv6_test.go index 4b30467..b8d7c32 100644 --- a/internal/firewall/firewall_ipv6_test.go +++ b/internal/firewall/firewall_ipv6_test.go @@ -162,7 +162,7 @@ func TestTemplate_nftSyntax(t *testing.T) { if _, err := f.WriteString(out); err != nil { t.Fatal(err) } - f.Close() + _ = f.Close() // `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root. // Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail // (auf den Nodes rendert/prüft edgeguard ohnehin als root). diff --git a/internal/handlers/acme_test.go b/internal/handlers/acme_test.go index 1800928..74643b1 100644 --- a/internal/handlers/acme_test.go +++ b/internal/handlers/acme_test.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "net/http" "net/http/httptest" "os" @@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) { t.Fatal(err) } rec := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/tok_42", nil) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/tok_42", nil) r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) { func TestACME_MissingToken_Returns404(t *testing.T) { r, _ := setupACME(t) rec := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/notthere", nil) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/notthere", nil) r.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("status: %d", rec.Code) @@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) { t.Fatal(err) } rec := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/subdir", nil) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/subdir", nil) r.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("expected 404 for directory, got %d", rec.Code) diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index fae4bf6..1677fcc 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -96,7 +96,7 @@ func (h *AuditHandler) Live(c *gin.Context) { if err != nil { return } - defer conn.Close() + defer func() { _ = conn.Close() }() // Snapshot if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 0e00739..e6fb72c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -504,7 +504,7 @@ func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, if err != nil { return "", "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if resp.StatusCode != http.StatusOK { return "", "", errors.New("primary: " + strings.TrimSpace(string(raw))) diff --git a/internal/handlers/backup_remotes.go b/internal/handlers/backup_remotes.go index 58e5b69..badd579 100644 --- a/internal/handlers/backup_remotes.go +++ b/internal/handlers/backup_remotes.go @@ -93,7 +93,7 @@ func (h *BackupRemotesHandler) Create(c *gin.Context) { response.BadRequest(c, err) return } - if req.Settings == nil || len(req.Settings) == 0 { + if len(req.Settings) == 0 { req.Settings = json.RawMessage(`{}`) } row := h.Pool.QueryRow(c.Request.Context(), ` @@ -124,7 +124,7 @@ func (h *BackupRemotesHandler) Update(c *gin.Context) { response.BadRequest(c, err) return } - if req.Settings == nil || len(req.Settings) == 0 { + if len(req.Settings) == 0 { req.Settings = json.RawMessage(`{}`) } // Wenn die Settings masked-Fields enthalten (***), übernehmen wir diff --git a/internal/handlers/cluster_repair.go b/internal/handlers/cluster_repair.go index 9b31ff1..3efa607 100644 --- a/internal/handlers/cluster_repair.go +++ b/internal/handlers/cluster_repair.go @@ -102,7 +102,7 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) { body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost}) res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body) if !res.OK { - response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err)) + response.Internal(c, fmt.Errorf("resync auf %s anstoßen: %s", peer.FQDN, res.Err)) return } slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost) @@ -178,7 +178,7 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber") } if st := repairUnitState(); st == "activating" || st == "active" { - return errors.New("Resync läuft bereits") + return errors.New("resync läuft bereits") } script := fmt.Sprintf(`#!/bin/bash @@ -334,7 +334,7 @@ func localRepairStatus() repairStatusResponse { "--no-pager", "-n", "100", "-o", "cat", ).CombinedOutput(); err == nil { lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) { + if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) { out.Log = lines } } diff --git a/internal/handlers/firewall_log.go b/internal/handlers/firewall_log.go index b76f5c2..a9b04b1 100644 --- a/internal/handlers/firewall_log.go +++ b/internal/handlers/firewall_log.go @@ -78,7 +78,7 @@ func (h *FirewallLogHandler) Live(c *gin.Context) { // Upgrade-Failures sind Browser-side; nichts loggen return } - defer conn.Close() + defer func() { _ = conn.Close() }() f := parseFilter(c) diff --git a/internal/handlers/haproxy_stats.go b/internal/handlers/haproxy_stats.go index 4e7c41a..25da394 100644 --- a/internal/handlers/haproxy_stats.go +++ b/internal/handlers/haproxy_stats.go @@ -58,14 +58,15 @@ type frontendStat struct { } func (h *HAProxyStatsHandler) Stats(c *gin.Context) { - conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second) + d := net.Dialer{Timeout: 2 * time.Second} + conn, err := d.DialContext(c.Request.Context(), "unix", haproxyAdminSock) if err != nil { // Socket nicht erreichbar (haproxy down oder no perm) → // leere Liste statt 500 damit das Dashboard nicht rot wird. response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) return } - defer conn.Close() + defer func() { _ = conn.Close() }() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) if _, err := conn.Write([]byte("show stat\n")); err != nil { response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) diff --git a/internal/handlers/license.go b/internal/handlers/license.go index 3be9063..1e2935d 100644 --- a/internal/handlers/license.go +++ b/internal/handlers/license.go @@ -139,7 +139,7 @@ func (h *LicenseHandler) ClearKey(c *gin.Context) { // result into the licenses table. On error, marks last_error in DB // (status stays as before — grace). func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) { - res, err := h.Client.Verify(key) + res, err := h.Client.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel if err != nil { _ = h.Repo.MarkError(ctx, key, err.Error()) slog.Warn("license: verify failed", "error", err) diff --git a/internal/handlers/ntp.go b/internal/handlers/ntp.go index 1bb3eb1..8d9fb29 100644 --- a/internal/handlers/ntp.go +++ b/internal/handlers/ntp.go @@ -100,21 +100,21 @@ func parseChronyTracking(out string) chronyStatus { } s.Synced = val != "00000000 ()" case "Stratum": - fmt.Sscanf(val, "%d", &s.Stratum) + _, _ = fmt.Sscanf(val, "%d", &s.Stratum) if s.Stratum > 0 && s.Stratum < 16 { s.Synced = true } case "System time": // "0.000012345 seconds fast of NTP time" var v float64 - fmt.Sscanf(val, "%f", &v) + _, _ = fmt.Sscanf(val, "%f", &v) s.OffsetMs = v * 1000 case "Frequency": // "-12.345 ppm slow" or "+12.345 ppm fast" - fmt.Sscanf(val, "%f", &s.FreqPPM) + _, _ = fmt.Sscanf(val, "%f", &s.FreqPPM) case "RMS offset": var v float64 - fmt.Sscanf(val, "%f", &v) + _, _ = fmt.Sscanf(val, "%f", &v) s.RMSOffsetMs = v * 1000 } } @@ -312,8 +312,8 @@ func parseChronymSources(out string) []ntpSource { Reach: fields[3], LastRx: fields[4], } - fmt.Sscanf(fields[1], "%d", &src.Stratum) - fmt.Sscanf(fields[2], "%d", &src.Poll) + _, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum) + _, _ = fmt.Sscanf(fields[2], "%d", &src.Poll) if len(fields) >= 6 { src.Sample = strings.Join(fields[5:], " ") } diff --git a/internal/handlers/oidc_test.go b/internal/handlers/oidc_test.go index ae81f20..4ad4160 100644 --- a/internal/handlers/oidc_test.go +++ b/internal/handlers/oidc_test.go @@ -79,7 +79,7 @@ func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code s if err != nil { t.Fatal(err) } - req := httptest.NewRequest(http.MethodGet, + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil) req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant c.Request = req diff --git a/internal/handlers/response/response_test.go b/internal/handlers/response/response_test.go index b8a81a5..da66a3f 100644 --- a/internal/handlers/response/response_test.go +++ b/internal/handlers/response/response_test.go @@ -1,6 +1,7 @@ package response import ( + "context" "encoding/json" "errors" "net/http" @@ -16,7 +17,7 @@ func run(handler gin.HandlerFunc) *httptest.ResponseRecorder { r := gin.New() r.GET("/x", handler) rec := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/x", nil) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", nil) r.ServeHTTP(rec, req) return rec } diff --git a/internal/handlers/system.go b/internal/handlers/system.go index 0d3a84a..94bbd1d 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -790,7 +790,7 @@ func (h *SystemHandler) UpgradeStatus(c *gin.Context) { ).CombinedOutput(); err == nil { lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") // Leere "no entries"-Antwort als leeres Log zurückgeben. - if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) { + if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) { out.Log = lines } } diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index 0d0b294..aabddd6 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -104,11 +104,13 @@ func TestRender_HSTSPerDomain(t *testing.T) { t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out) } } - if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") || - strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") && - strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 { - // HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden. - // (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.) + // HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert + // werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen. + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "Strict-Transport-Security") && + strings.Contains(line, "hdr(host) -i b.example.com") { + t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line) + } } } diff --git a/internal/kea/kea_test.go b/internal/kea/kea_test.go index 44b2270..de2c06d 100644 --- a/internal/kea/kea_test.go +++ b/internal/kea/kea_test.go @@ -81,7 +81,7 @@ VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil if _, statErr := os.Stat(keaBinary); statErr == nil { f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf") _, _ = f.WriteString(out) - f.Close() + _ = f.Close() if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil { t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined) } diff --git a/internal/license/license.go b/internal/license/license.go index a734a0d..73d0e4c 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -17,6 +17,7 @@ package license import ( + "context" "crypto/sha256" "encoding/json" "errors" @@ -136,11 +137,15 @@ func (c *Client) Verify(key string) (*Result, error) { activeDomains, ) - resp, err := c.HTTPClient.Get(endpoint) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("license request: %w", err) + } + resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("license server unreachable: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("license server returned status %d", resp.StatusCode) diff --git a/internal/services/acme/acme.go b/internal/services/acme/acme.go index b4ed21d..66d0d6e 100644 --- a/internal/services/acme/acme.go +++ b/internal/services/acme/acme.go @@ -52,9 +52,6 @@ type Service struct { AccountDir string DirURL string Email string - - // loaded lazily on first call - user *acmeUser } // New returns a Service with sensible defaults. Email comes from diff --git a/internal/services/alerts/alerts.go b/internal/services/alerts/alerts.go index 198f00f..d2292a9 100644 --- a/internal/services/alerts/alerts.go +++ b/internal/services/alerts/alerts.go @@ -121,7 +121,7 @@ FROM alert_channels ORDER BY id ASC`) } func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) { - if c.Settings == nil || len(c.Settings) == 0 { + if len(c.Settings) == 0 { c.Settings = json.RawMessage(`{}`) } row := s.Pool.QueryRow(ctx, ` @@ -138,7 +138,7 @@ RETURNING id, name, kind, target, settings, active, created_at, updated_at`, } func (s *Service) UpdateChannel(ctx context.Context, id int64, c Channel) (*Channel, error) { - if c.Settings == nil || len(c.Settings) == 0 { + if len(c.Settings) == 0 { c.Settings = json.RawMessage(`{}`) } row := s.Pool.QueryRow(ctx, ` @@ -269,7 +269,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string, if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) diff --git a/internal/services/apt/apt.go b/internal/services/apt/apt.go index 79b78f3..3cfb609 100644 --- a/internal/services/apt/apt.go +++ b/internal/services/apt/apt.go @@ -55,7 +55,7 @@ func PackageVersions(ctx context.Context, force bool) map[string]string { defer cancel() for _, pkg := range edgePackages { - installed, candidate := aptCachePolicy(policyCtx, pkg) + installed, candidate := aptCachePolicy(policyCtx, pkg) //nolint:contextcheck // detached by design — Cache-Read überlebt Request-Cancel out[pkg+"_installed"] = installed out[pkg+"_available"] = candidate } diff --git a/internal/services/backup/backup.go b/internal/services/backup/backup.go index 630c6c9..cd525ef 100644 --- a/internal/services/backup/backup.go +++ b/internal/services/backup/backup.go @@ -30,7 +30,6 @@ import ( "os/exec" "path/filepath" "sort" - "strings" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -232,10 +231,9 @@ func (s *Service) Run(ctx context.Context, kind Kind, version string) (*Result, s.recordFailure(ctx, res, hostname, kind) return res, err } - if err := f.Sync(); err != nil { - // Nicht fatal — fsync-failure kann passieren bei tmpfs in - // Dev, aber der File ist da. - } + // fsync best-effort — Nicht fatal: fsync-failure kann bei tmpfs in + // Dev passieren, der File ist trotzdem da. + _ = f.Sync() if err := f.Close(); err != nil { s.recordFailure(ctx, res, hostname, kind) return res, err @@ -644,10 +642,6 @@ func (b *bytes) Write(p []byte) (int, error) { return len(p), nil } -// strFold ist ein utility nur zum Defensiv-Check, dass kind ein -// erlaubter Wert ist (für die DB-Constraint). -func strFold(s string) string { return strings.ToLower(strings.TrimSpace(s)) } - // SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt // (DB-Query macht's), aber praktisch wenn der Caller eine eigene // Liste hat. diff --git a/internal/services/backup/remote/remote.go b/internal/services/backup/remote/remote.go index a1d26d8..d63b0d2 100644 --- a/internal/services/backup/remote/remote.go +++ b/internal/services/backup/remote/remote.go @@ -180,7 +180,7 @@ func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error if err != nil { return 0, err } - defer f.Close() + defer func() { _ = f.Close() }() stat, err := f.Stat() if err != nil { return 0, err @@ -245,12 +245,12 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e if err != nil { return 0, fmt.Errorf("ssh dial %s: %w", addr, err) } - defer conn.Close() + defer func() { _ = conn.Close() }() cl, err := sftp.NewClient(conn) if err != nil { return 0, fmt.Errorf("sftp client: %w", err) } - defer cl.Close() + defer func() { _ = cl.Close() }() // remote-dir anlegen (idempotent) _ = cl.MkdirAll(s.RemoteDir) @@ -259,7 +259,7 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e if err != nil { return 0, err } - defer src.Close() + defer func() { _ = src.Close() }() stat, err := src.Stat() if err != nil { return 0, err @@ -269,10 +269,16 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e if err != nil { return 0, fmt.Errorf("create remote: %w", err) } - defer dst.Close() if _, err := io.Copy(dst, src); err != nil { + _ = dst.Close() return 0, fmt.Errorf("copy: %w", err) } + // SFTP-Close flusht gepufferte Writes — ein Fehler hier bedeutet + // ein unvollständiges Remote-File und MUSS als Upload-Fehler + // gemeldet werden (nicht als Erfolg durchgehen). + if err := dst.Close(); err != nil { + return 0, fmt.Errorf("close remote: %w", err) + } return stat.Size(), nil } @@ -284,9 +290,9 @@ func (s *Service) Test(ctx context.Context, t Target) error { if err != nil { return err } - defer os.Remove(tmp.Name()) + defer func() { _ = os.Remove(tmp.Name()) }() _, _ = tmp.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339)) - tmp.Close() + _ = tmp.Close() r := s.uploadOne(ctx, t, tmp.Name()) if !r.OK { @@ -331,9 +337,9 @@ func (s *Service) Test(ctx context.Context, t Target) error { HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil { if cl, err := sftp.NewClient(conn); err == nil { _ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name())) - cl.Close() + _ = cl.Close() } - conn.Close() + _ = conn.Close() } } } diff --git a/internal/services/clusterjoin/join.go b/internal/services/clusterjoin/join.go index 2f3e6f1..8066fe2 100644 --- a/internal/services/clusterjoin/join.go +++ b/internal/services/clusterjoin/join.go @@ -5,6 +5,7 @@ package clusterjoin import ( "bytes" + "context" "crypto/tls" "crypto/x509" "encoding/json" @@ -177,7 +178,7 @@ func NormalizePrimaryURL(in string) (string, error) { func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) { body, _ := json.Marshal(map[string]string{"token": token, "csr": csr}) - req, err := http.NewRequest(http.MethodPost, + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body)) if err != nil { return "", "", err @@ -193,7 +194,7 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri if err != nil { return "", "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) @@ -287,7 +288,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role } client := &http.Client{Transport: tr, Timeout: 30 * time.Second} - httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, u.String(), bytes.NewReader(body)) if err != nil { return err } @@ -296,7 +297,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) diff --git a/internal/services/firewall/addressgroups.go b/internal/services/firewall/addressgroups.go index 0c81ba9..134a57e 100644 --- a/internal/services/firewall/addressgroups.go +++ b/internal/services/firewall/addressgroups.go @@ -97,7 +97,7 @@ func (r *AddressGroupsRepo) Create(ctx context.Context, g models.FirewallAddress if err != nil { return nil, err } - defer tx.Rollback(ctx) + defer func() { _ = tx.Rollback(ctx) }() row := tx.QueryRow(ctx, ` INSERT INTO firewall_address_groups (name, description) @@ -125,7 +125,7 @@ func (r *AddressGroupsRepo) Update(ctx context.Context, id int64, g models.Firew if err != nil { return nil, err } - defer tx.Rollback(ctx) + defer func() { _ = tx.Rollback(ctx) }() row := tx.QueryRow(ctx, ` UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW() diff --git a/internal/services/firewall/servicegroups.go b/internal/services/firewall/servicegroups.go index 75c0dbb..287997f 100644 --- a/internal/services/firewall/servicegroups.go +++ b/internal/services/firewall/servicegroups.go @@ -91,7 +91,7 @@ func (r *ServiceGroupsRepo) Create(ctx context.Context, g models.FirewallService if err != nil { return nil, err } - defer tx.Rollback(ctx) + defer func() { _ = tx.Rollback(ctx) }() row := tx.QueryRow(ctx, ` INSERT INTO firewall_service_groups (name, description) VALUES ($1, $2) @@ -115,7 +115,7 @@ func (r *ServiceGroupsRepo) Update(ctx context.Context, id int64, g models.Firew if err != nil { return nil, err } - defer tx.Rollback(ctx) + defer func() { _ = tx.Rollback(ctx) }() row := tx.QueryRow(ctx, ` UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW() diff --git a/internal/services/firewalllog/reader.go b/internal/services/firewalllog/reader.go index 61e24a7..eb5871c 100644 --- a/internal/services/firewalllog/reader.go +++ b/internal/services/firewalllog/reader.go @@ -102,7 +102,7 @@ func ReadTail(path string, f Filter) ([]Entry, error) { } return nil, err } - defer file.Close() + defer func() { _ = file.Close() }() // Ring-Buffer für die letzten f.Limit matching entries. buf := make([]Entry, 0, f.Limit) diff --git a/internal/services/firewalllog/tailer.go b/internal/services/firewalllog/tailer.go index f0d5ca9..07e5b12 100644 --- a/internal/services/firewalllog/tailer.go +++ b/internal/services/firewalllog/tailer.go @@ -64,7 +64,7 @@ func (t *Tailer) Start(ctx context.Context) error { if err != nil { return err } - defer w.Close() + defer func() { _ = w.Close() }() // Watch das Verzeichnis statt der Datei — wenn ulogd das File // erstmals anlegt (oder rotate-rename), bekommen wir Create-Events. @@ -106,7 +106,7 @@ func (t *Tailer) bootstrap() error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { @@ -134,7 +134,7 @@ func (t *Tailer) drainFile() { if err != nil { return } - defer f.Close() + defer func() { _ = f.Close() }() stat, err := f.Stat() if err != nil { diff --git a/internal/services/wireguard/import.go b/internal/services/wireguard/import.go index c1b9aae..0d24c34 100644 --- a/internal/services/wireguard/import.go +++ b/internal/services/wireguard/import.go @@ -292,7 +292,7 @@ func parseWGConf(path string) (*parsedConf, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() var ( out parsedConf