diff --git a/.golangci.yml b/.golangci.yml index 5c233dd..e31ffe7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,6 +6,7 @@ run: linters: default: none enable: + - contextcheck - govet - staticcheck - unused diff --git a/internal/api/middleware/audit.go b/internal/api/middleware/audit.go index 1ab7cb2..a91896d 100644 --- a/internal/api/middleware/audit.go +++ b/internal/api/middleware/audit.go @@ -132,6 +132,15 @@ func (a *AuditMiddleware) Middleware(next http.Handler) http.Handler { path := r.URL.Path status := wrapped.statusCode + // Derive a detached context that preserves request-scoped values + // (trace IDs, auth info carried via context keys) but is not cancelled + // when the HTTP server finalizes the request. Using r.Context() + // directly would cause the async audit write to observe ctx.Done() + // as soon as the response completes; using context.Background() would + // discard useful observability metadata. WithoutCancel gives us both + // (M-2 / D-3). + auditCtx := context.WithoutCancel(r.Context()) + // Record audit event asynchronously (best-effort, don't block response). // SECURITY: We intentionally use r.URL.Path (not r.URL.String() or r.RequestURI) // to prevent query parameters from being recorded in the immutable audit trail. @@ -147,7 +156,7 @@ func (a *AuditMiddleware) Middleware(next http.Handler) http.Handler { go func() { defer a.wg.Done() if err := a.recorder.RecordAPICall( - context.Background(), + auditCtx, method, path, actor, diff --git a/internal/api/middleware/middleware.go b/internal/api/middleware/middleware.go index 8d07481..4605635 100644 --- a/internal/api/middleware/middleware.go +++ b/internal/api/middleware/middleware.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" + "fmt" "log" "log/slog" "net/http" @@ -78,10 +79,17 @@ func NewLogging(logger *slog.Logger) func(http.Handler) http.Handler { // Recovery middleware recovers from panics and returns a 500 error. func Recovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() defer func() { if err := recover(); err != nil { - requestID := getRequestID(r.Context()) - log.Printf("[%s] PANIC: %v", requestID, err) + requestID := getRequestID(ctx) + // Use slog.ErrorContext so the panic log carries the same + // request-scoped trace/auth metadata as normal request logs + // (M-2 / D-3 — preserve ctx propagation on the panic path). + slog.ErrorContext(ctx, "panic recovered in HTTP handler", + "request_id", requestID, + "panic", fmt.Sprintf("%v", err), + ) http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError) } }() diff --git a/internal/connector/issuer/acme/acme.go b/internal/connector/issuer/acme/acme.go index b03d8e7..2ca37c2 100644 --- a/internal/connector/issuer/acme/acme.go +++ b/internal/connector/issuer/acme/acme.go @@ -547,7 +547,11 @@ func (c *Connector) solveAuthorizationsHTTP01(ctx context.Context, authzURLs []s return fmt.Errorf("failed to start challenge server: %w", err) } defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + // Derive the challenge-server shutdown context from the parent ctx so + // values (trace IDs, deadlines) propagate, but detach from its + // cancellation so Shutdown always gets its full budget even when the + // parent was cancelled (M-2 / D-3). + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) c.logger.Debug("challenge server stopped")