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>
This commit is contained in:
Debian
2026-07-06 00:49:07 +02:00
parent cdbb62ee1a
commit 32ab2c7f47
41 changed files with 152 additions and 134 deletions

View File

@@ -1,7 +1,8 @@
# Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte). # Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte).
# Rollout: erst NON-BLOCKING laufen lassen (Bestand aufräumen), dann in der # Rollout ABGESCHLOSSEN: Bestand aufgeräumt (0 Findings), golangci-lint ist
# CI/Makefile auf harten Gate stellen. govulncheck ist bereits ein harter # jetzt HARTER Release-Gate — genau wie govulncheck (siehe Makefile:
# Release-Gate (siehe Makefile: vulncheck / release-check). # golangci / vulncheck / release-check). Neuer Fund ⇒ `make deb`/`publish`
# bricht ab.
version: "2" version: "2"
run: run:
@@ -78,6 +79,21 @@ linters:
# beschreibbar. Symlink-TOCTOU theoretisch, kein realer Vektor. # beschreibbar. Symlink-TOCTOU theoretisch, kein realer Vektor.
- G122 - 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 # HINWEIS: rowserrcheck/sqlclosecheck NICHT aktiviert — sie zielen auf
# database/sql. EdgeGuard nutzt durchgängig pgx/pgxpool; dort erzeugen sie # database/sql. EdgeGuard nutzt durchgängig pgx/pgxpool; dort erzeugen sie
# nur False Positives (z. B. wenn rows via Interface-Var zugewiesen wird, # nur False Positives (z. B. wenn rows via Interface-Var zugewiesen wird,

View File

@@ -148,7 +148,7 @@ cd management-ui && bun install && bun run build
**`go vet``golangci-lint run``govulncheck ./...``go build``go test -race`**. **`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). - **`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. - **`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. - Portabel als „Go-Quality-Baseline" für weitere Go-Projekte gedacht.

View File

@@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly GOFLAGS := -trimpath -mod=readonly
export CGO_ENABLED ?= 0 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 \ build-linux-amd64 build-linux-arm64 \
deb deb-amd64 deb-arm64 \ deb deb-amd64 deb-arm64 \
publish publish-amd64 publish-arm64 publish publish-amd64 publish-arm64
@@ -64,12 +64,19 @@ test:
test-race: test-race:
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./... CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
GOBIN := $(shell $(GO) env GOPATH)/bin
GOLANGCI_VERSION := v2.12.2
lint: lint:
$(GO) vet ./... $(GO) vet ./...
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run \ @$(MAKE) --no-print-directory golangci
|| echo "golangci-lint nicht installiert (go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest) — übersprungen"
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 # govulncheck — Go-Vuln-DB-Scan. HARTER Release-Gate: bricht ab, wenn der
# Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf # Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf
@@ -79,17 +86,16 @@ vulncheck:
@PATH="$(GOBIN):$$PATH" govulncheck ./... @PATH="$(GOBIN):$$PATH" govulncheck ./...
# Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish). # Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish).
# Reihenfolge: vet → golangci-lint (Rollout: NON-BLOCKING) → govulncheck (GATE) # Reihenfolge: vet → golangci-lint (GATE) → govulncheck (GATE) → build →
# → build → test -race. govulncheck bricht bei jedem Fund ab; der Linter ist # test -race. Alle vier brechen bei jedem Fund ab. Der Linter-Rollout ist
# in der Rollout-Phase noch non-blocking (Bestand aufräumen, dann auf Gate). # abgeschlossen (Bestand = 0), daher jetzt HARTER Gate statt non-blocking.
release-check: release-check:
$(GO) vet ./... $(GO) vet ./...
@command -v golangci-lint >/dev/null 2>&1 && (golangci-lint run || echo " ⚠ golangci-lint-Findings (non-blocking, Rollout-Phase)") \ @$(MAKE) --no-print-directory golangci
|| echo " golangci-lint nicht installiert — übersprungen (Rollout-Phase)"
@$(MAKE) --no-print-directory vulncheck @$(MAKE) --no-print-directory vulncheck
$(GO) build ./... $(GO) build ./...
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./... 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: tidy:
$(GO) mod tidy $(GO) mod tidy

View File

@@ -1 +1 @@
1.3.2 1.3.3

View File

@@ -687,30 +687,6 @@ func openDBBestEffort() (*pgxpoolPool, error) {
// main.go on every platform — keeps the import block lean. // main.go on every platform — keeps the import block lean.
type pgxpoolPool = pgxpool.Pool 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 // randomEphemeralSecret is the fallback for dev environments where
// /var/lib/edgeguard isn't writable. Tokens issued with this secret // /var/lib/edgeguard isn't writable. Tokens issued with this secret
// die on restart — production reads/writes the persistent file via // 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) pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel() defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool) 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) slog.Warn("cluster: push-to-primary failed", "error", err)
} else { } else {
slog.Debug("cluster: config_hash pushed to primary", "hash", hash) 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 == "" { if target == "" {
target = "https://" + p.FQDN 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) slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
} }
} }

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
@@ -319,9 +320,11 @@ host replication %s ::/0 scram-sha-256
if err != nil { if err != nil {
return fmt.Errorf("open: %w", err) return fmt.Errorf("open: %w", err)
} }
defer f.Close() if _, err = f.WriteString(entry); err != nil {
_, err = f.WriteString(entry) _ = f.Close()
return err return err
}
return f.Close()
} }
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication- // 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" 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 { if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err) 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 { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) 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" 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 { if err != nil {
return fmt.Errorf("GET %s: %w", url, err) 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 { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
} }

View File

@@ -93,7 +93,7 @@ func looksLikeIdentifier(s string) bool {
if s == "" || len(s) > 63 { if s == "" || len(s) > 63 {
return false return false
} }
if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) { if s[0] != '_' && (s[0] < 'a' || s[0] > 'z') {
return false return false
} }
for _, r := range s[1:] { for _, r := range s[1:] {

View File

@@ -567,7 +567,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc
synced = true synced = true
} }
case "Stratum": case "Stratum":
fmt.Sscanf(val, "%d", &stratum) _, _ = fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 { if stratum > 0 && stratum < 16 {
synced = true synced = true
} else if stratum == 0 || stratum >= 16 { } 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 { if a == nil || d == nil {
return 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 { if err != nil {
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert, // HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
// das ist der Dienst selbst nicht der Scheduler. // das ist der Dienst selbst nicht der Scheduler.
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second)) _ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil { if _, err := conn.Write([]byte("show stat\n")); err != nil {
return return
@@ -1019,7 +1020,7 @@ func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeySto
slog.Debug("scheduler: license verify skipped — no key") slog.Debug("scheduler: license verify skipped — no key")
return 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 { if err != nil {
_ = repo.MarkError(ctx, key, err.Error()) _ = repo.MarkError(ctx, key, err.Error())
slog.Warn("scheduler: license verify failed", "error", err) slog.Warn("scheduler: license verify failed", "error", err)

View File

@@ -143,7 +143,7 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
res.Duration = time.Since(start).Milliseconds() res.Duration = time.Since(start).Milliseconds()
return res return res
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) 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() res.Duration = time.Since(start).Milliseconds()
return res return res
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) 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() res.Duration = time.Since(start).Milliseconds()
return res return res
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { 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.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))

View File

@@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
} }
return nil, err return nil, err
} }
defer f.Close() defer func() { _ = f.Close() }()
c := &LocalConfig{} c := &LocalConfig{}
sc := bufio.NewScanner(f) sc := bufio.NewScanner(f)
for sc.Scan() { for sc.Scan() {

View File

@@ -49,14 +49,14 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error {
return fmt.Errorf("tempfile: %w", err) return fmt.Errorf("tempfile: %w", err)
} }
tmpPath := tmp.Name() 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 { if _, err := tmp.Write(data); err != nil {
tmp.Close() _ = tmp.Close()
return fmt.Errorf("write %s: %w", tmpPath, err) return fmt.Errorf("write %s: %w", tmpPath, err)
} }
if err := tmp.Sync(); err != nil { if err := tmp.Sync(); err != nil {
tmp.Close() _ = tmp.Close()
return fmt.Errorf("fsync %s: %w", tmpPath, err) return fmt.Errorf("fsync %s: %w", tmpPath, err)
} }
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {

View File

@@ -97,7 +97,7 @@ func Migrate(ctx context.Context, dsnOverride string) error {
if err != nil { if err != nil {
return fmt.Errorf("open db for migrate: %w", err) return fmt.Errorf("open db for migrate: %w", err)
} }
defer db.Close() defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations) goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil { if err := goose.SetDialect("postgres"); err != nil {
@@ -117,7 +117,7 @@ func MigrateDown(ctx context.Context, dsnOverride string) error {
if err != nil { if err != nil {
return err return err
} }
defer db.Close() defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations) goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil { if err := goose.SetDialect("postgres"); err != nil {
return err return err

View File

@@ -47,7 +47,7 @@ func TestTemplate_autoRuleIface(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, _ = f.WriteString(out) _, _ = f.WriteString(out)
f.Close() _ = f.Close()
var cmd *exec.Cmd var cmd *exec.Cmd
if os.Geteuid() == 0 { if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name()) cmd = exec.Command(nft, "-c", "-f", f.Name())

View File

@@ -125,7 +125,7 @@ func TestE2E_IPv6Render(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, _ = f.WriteString(out) _, _ = f.WriteString(out)
f.Close() _ = f.Close()
var cmd *exec.Cmd var cmd *exec.Cmd
if os.Geteuid() == 0 { if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name()) cmd = exec.Command(nft, "-c", "-f", f.Name())

View File

@@ -162,7 +162,7 @@ func TestTemplate_nftSyntax(t *testing.T) {
if _, err := f.WriteString(out); err != nil { if _, err := f.WriteString(out); err != nil {
t.Fatal(err) t.Fatal(err)
} }
f.Close() _ = f.Close()
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root. // `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail // Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
// (auf den Nodes rendert/prüft edgeguard ohnehin als root). // (auf den Nodes rendert/prüft edgeguard ohnehin als root).

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() 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) r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
func TestACME_MissingToken_Returns404(t *testing.T) { func TestACME_MissingToken_Returns404(t *testing.T) {
r, _ := setupACME(t) r, _ := setupACME(t)
rec := httptest.NewRecorder() 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) r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("status: %d", rec.Code) t.Errorf("status: %d", rec.Code)
@@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() 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) r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("expected 404 for directory, got %d", rec.Code) t.Errorf("expected 404 for directory, got %d", rec.Code)

View File

@@ -96,7 +96,7 @@ func (h *AuditHandler) Live(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
// Snapshot // Snapshot
if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil { if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil {

View File

@@ -504,7 +504,7 @@ func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email,
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw))) return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))

View File

@@ -93,7 +93,7 @@ func (h *BackupRemotesHandler) Create(c *gin.Context) {
response.BadRequest(c, err) response.BadRequest(c, err)
return return
} }
if req.Settings == nil || len(req.Settings) == 0 { if len(req.Settings) == 0 {
req.Settings = json.RawMessage(`{}`) req.Settings = json.RawMessage(`{}`)
} }
row := h.Pool.QueryRow(c.Request.Context(), ` row := h.Pool.QueryRow(c.Request.Context(), `
@@ -124,7 +124,7 @@ func (h *BackupRemotesHandler) Update(c *gin.Context) {
response.BadRequest(c, err) response.BadRequest(c, err)
return return
} }
if req.Settings == nil || len(req.Settings) == 0 { if len(req.Settings) == 0 {
req.Settings = json.RawMessage(`{}`) req.Settings = json.RawMessage(`{}`)
} }
// Wenn die Settings masked-Fields enthalten (***), übernehmen wir // Wenn die Settings masked-Fields enthalten (***), übernehmen wir

View File

@@ -102,7 +102,7 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost}) body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body) res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
if !res.OK { 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 return
} }
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost) 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") return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
} }
if st := repairUnitState(); st == "activating" || st == "active" { 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 script := fmt.Sprintf(`#!/bin/bash
@@ -334,7 +334,7 @@ func localRepairStatus() repairStatusResponse {
"--no-pager", "-n", "100", "-o", "cat", "--no-pager", "-n", "100", "-o", "cat",
).CombinedOutput(); err == nil { ).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") 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 out.Log = lines
} }
} }

View File

@@ -78,7 +78,7 @@ func (h *FirewallLogHandler) Live(c *gin.Context) {
// Upgrade-Failures sind Browser-side; nichts loggen // Upgrade-Failures sind Browser-side; nichts loggen
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
f := parseFilter(c) f := parseFilter(c)

View File

@@ -58,14 +58,15 @@ type frontendStat struct {
} }
func (h *HAProxyStatsHandler) Stats(c *gin.Context) { 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 { if err != nil {
// Socket nicht erreichbar (haproxy down oder no perm) → // Socket nicht erreichbar (haproxy down oder no perm) →
// leere Liste statt 500 damit das Dashboard nicht rot wird. // leere Liste statt 500 damit das Dashboard nicht rot wird.
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second)) _ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil { if _, err := conn.Write([]byte("show stat\n")); err != nil {
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})

View File

@@ -139,7 +139,7 @@ func (h *LicenseHandler) ClearKey(c *gin.Context) {
// result into the licenses table. On error, marks last_error in DB // result into the licenses table. On error, marks last_error in DB
// (status stays as before — grace). // (status stays as before — grace).
func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) { 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 { if err != nil {
_ = h.Repo.MarkError(ctx, key, err.Error()) _ = h.Repo.MarkError(ctx, key, err.Error())
slog.Warn("license: verify failed", "error", err) slog.Warn("license: verify failed", "error", err)

View File

@@ -100,21 +100,21 @@ func parseChronyTracking(out string) chronyStatus {
} }
s.Synced = val != "00000000 ()" s.Synced = val != "00000000 ()"
case "Stratum": case "Stratum":
fmt.Sscanf(val, "%d", &s.Stratum) _, _ = fmt.Sscanf(val, "%d", &s.Stratum)
if s.Stratum > 0 && s.Stratum < 16 { if s.Stratum > 0 && s.Stratum < 16 {
s.Synced = true s.Synced = true
} }
case "System time": case "System time":
// "0.000012345 seconds fast of NTP time" // "0.000012345 seconds fast of NTP time"
var v float64 var v float64
fmt.Sscanf(val, "%f", &v) _, _ = fmt.Sscanf(val, "%f", &v)
s.OffsetMs = v * 1000 s.OffsetMs = v * 1000
case "Frequency": case "Frequency":
// "-12.345 ppm slow" or "+12.345 ppm fast" // "-12.345 ppm slow" or "+12.345 ppm fast"
fmt.Sscanf(val, "%f", &s.FreqPPM) _, _ = fmt.Sscanf(val, "%f", &s.FreqPPM)
case "RMS offset": case "RMS offset":
var v float64 var v float64
fmt.Sscanf(val, "%f", &v) _, _ = fmt.Sscanf(val, "%f", &v)
s.RMSOffsetMs = v * 1000 s.RMSOffsetMs = v * 1000
} }
} }
@@ -312,8 +312,8 @@ func parseChronymSources(out string) []ntpSource {
Reach: fields[3], Reach: fields[3],
LastRx: fields[4], LastRx: fields[4],
} }
fmt.Sscanf(fields[1], "%d", &src.Stratum) _, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum)
fmt.Sscanf(fields[2], "%d", &src.Poll) _, _ = fmt.Sscanf(fields[2], "%d", &src.Poll)
if len(fields) >= 6 { if len(fields) >= 6 {
src.Sample = strings.Join(fields[5:], " ") src.Sample = strings.Join(fields[5:], " ")
} }

View File

@@ -79,7 +79,7 @@ func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code s
if err != nil { if err != nil {
t.Fatal(err) 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) "/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 req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
c.Request = req c.Request = req

View File

@@ -1,6 +1,7 @@
package response package response
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@@ -16,7 +17,7 @@ func run(handler gin.HandlerFunc) *httptest.ResponseRecorder {
r := gin.New() r := gin.New()
r.GET("/x", handler) r.GET("/x", handler)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/x", nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", nil)
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
return rec return rec
} }

View File

@@ -790,7 +790,7 @@ func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
).CombinedOutput(); err == nil { ).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// Leere "no entries"-Antwort als leeres Log zurückgeben. // 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 out.Log = lines
} }
} }

View File

@@ -104,11 +104,13 @@ func TestRender_HSTSPerDomain(t *testing.T) {
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out) 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 }") || // HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") && // werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen.
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 { for _, line := range strings.Split(out, "\n") {
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden. if strings.Contains(line, "Strict-Transport-Security") &&
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.) strings.Contains(line, "hdr(host) -i b.example.com") {
t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line)
}
} }
} }

View File

@@ -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 { if _, statErr := os.Stat(keaBinary); statErr == nil {
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf") f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
_, _ = f.WriteString(out) _, _ = f.WriteString(out)
f.Close() _ = f.Close()
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil { 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) t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
} }

View File

@@ -17,6 +17,7 @@
package license package license
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -136,11 +137,15 @@ func (c *Client) Verify(key string) (*Result, error) {
activeDomains, 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 { if err != nil {
return nil, fmt.Errorf("license server unreachable: %w", err) return nil, fmt.Errorf("license server unreachable: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode) return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)

View File

@@ -52,9 +52,6 @@ type Service struct {
AccountDir string AccountDir string
DirURL string DirURL string
Email string Email string
// loaded lazily on first call
user *acmeUser
} }
// New returns a Service with sensible defaults. Email comes from // New returns a Service with sensible defaults. Email comes from

View File

@@ -121,7 +121,7 @@ FROM alert_channels ORDER BY id ASC`)
} }
func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) { 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(`{}`) c.Settings = json.RawMessage(`{}`)
} }
row := s.Pool.QueryRow(ctx, ` 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) { 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(`{}`) c.Settings = json.RawMessage(`{}`)
} }
row := s.Pool.QueryRow(ctx, ` row := s.Pool.QueryRow(ctx, `
@@ -269,7 +269,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))

View File

@@ -55,7 +55,7 @@ func PackageVersions(ctx context.Context, force bool) map[string]string {
defer cancel() defer cancel()
for _, pkg := range edgePackages { 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+"_installed"] = installed
out[pkg+"_available"] = candidate out[pkg+"_available"] = candidate
} }

View File

@@ -30,7 +30,6 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "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) s.recordFailure(ctx, res, hostname, kind)
return res, err return res, err
} }
if err := f.Sync(); err != nil { // fsync best-effort — Nicht fatal: fsync-failure kann bei tmpfs in
// Nicht fatal — fsync-failure kann passieren bei tmpfs in // Dev passieren, der File ist trotzdem da.
// Dev, aber der File ist da. _ = f.Sync()
}
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
s.recordFailure(ctx, res, hostname, kind) s.recordFailure(ctx, res, hostname, kind)
return res, err return res, err
@@ -644,10 +642,6 @@ func (b *bytes) Write(p []byte) (int, error) {
return len(p), nil 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 // SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt
// (DB-Query macht's), aber praktisch wenn der Caller eine eigene // (DB-Query macht's), aber praktisch wenn der Caller eine eigene
// Liste hat. // Liste hat.

View File

@@ -180,7 +180,7 @@ func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer f.Close() defer func() { _ = f.Close() }()
stat, err := f.Stat() stat, err := f.Stat()
if err != nil { if err != nil {
return 0, err return 0, err
@@ -245,12 +245,12 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil { if err != nil {
return 0, fmt.Errorf("ssh dial %s: %w", addr, err) return 0, fmt.Errorf("ssh dial %s: %w", addr, err)
} }
defer conn.Close() defer func() { _ = conn.Close() }()
cl, err := sftp.NewClient(conn) cl, err := sftp.NewClient(conn)
if err != nil { if err != nil {
return 0, fmt.Errorf("sftp client: %w", err) return 0, fmt.Errorf("sftp client: %w", err)
} }
defer cl.Close() defer func() { _ = cl.Close() }()
// remote-dir anlegen (idempotent) // remote-dir anlegen (idempotent)
_ = cl.MkdirAll(s.RemoteDir) _ = cl.MkdirAll(s.RemoteDir)
@@ -259,7 +259,7 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer src.Close() defer func() { _ = src.Close() }()
stat, err := src.Stat() stat, err := src.Stat()
if err != nil { if err != nil {
return 0, err return 0, err
@@ -269,10 +269,16 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil { if err != nil {
return 0, fmt.Errorf("create remote: %w", err) return 0, fmt.Errorf("create remote: %w", err)
} }
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil { if _, err := io.Copy(dst, src); err != nil {
_ = dst.Close()
return 0, fmt.Errorf("copy: %w", err) 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 return stat.Size(), nil
} }
@@ -284,9 +290,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
if err != nil { if err != nil {
return err 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.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339))
tmp.Close() _ = tmp.Close()
r := s.uploadOne(ctx, t, tmp.Name()) r := s.uploadOne(ctx, t, tmp.Name())
if !r.OK { 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 { HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil {
if cl, err := sftp.NewClient(conn); err == nil { if cl, err := sftp.NewClient(conn); err == nil {
_ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name())) _ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name()))
cl.Close() _ = cl.Close()
} }
conn.Close() _ = conn.Close()
} }
} }
} }

View File

@@ -5,6 +5,7 @@ package clusterjoin
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json" "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) { func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr}) 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)) primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -193,7 +194,7 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) 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} 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 { if err != nil {
return err return err
} }
@@ -296,7 +297,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))

View File

@@ -97,7 +97,7 @@ func (r *AddressGroupsRepo) Create(ctx context.Context, g models.FirewallAddress
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
INSERT INTO firewall_address_groups (name, description) 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 { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW() UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW()

View File

@@ -91,7 +91,7 @@ func (r *ServiceGroupsRepo) Create(ctx context.Context, g models.FirewallService
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
INSERT INTO firewall_service_groups (name, description) VALUES ($1, $2) 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 { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW() UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW()

View File

@@ -102,7 +102,7 @@ func ReadTail(path string, f Filter) ([]Entry, error) {
} }
return nil, err return nil, err
} }
defer file.Close() defer func() { _ = file.Close() }()
// Ring-Buffer für die letzten f.Limit matching entries. // Ring-Buffer für die letzten f.Limit matching entries.
buf := make([]Entry, 0, f.Limit) buf := make([]Entry, 0, f.Limit)

View File

@@ -64,7 +64,7 @@ func (t *Tailer) Start(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
defer w.Close() defer func() { _ = w.Close() }()
// Watch das Verzeichnis statt der Datei — wenn ulogd das File // Watch das Verzeichnis statt der Datei — wenn ulogd das File
// erstmals anlegt (oder rotate-rename), bekommen wir Create-Events. // erstmals anlegt (oder rotate-rename), bekommen wir Create-Events.
@@ -106,7 +106,7 @@ func (t *Tailer) bootstrap() error {
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer func() { _ = f.Close() }()
sc := bufio.NewScanner(f) sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() { for sc.Scan() {
@@ -134,7 +134,7 @@ func (t *Tailer) drainFile() {
if err != nil { if err != nil {
return return
} }
defer f.Close() defer func() { _ = f.Close() }()
stat, err := f.Stat() stat, err := f.Stat()
if err != nil { if err != nil {

View File

@@ -292,7 +292,7 @@ func parseWGConf(path string) (*parsedConf, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close() defer func() { _ = f.Close() }()
var ( var (
out parsedConf out parsedConf