@marcel91200 / caddy Mirror · read-only / commits / c290397c
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 07:15 UTC

caddyhttp: log recovered handler panics at ERROR level (#7924)

Co-authored-by: Zen Dodd <mail@steadytao.com> Signed-off-by: Zen Dodd <mail@steadytao.com>

Ben Younes committed Aug 10, 2026 at 08:18 UTC c290397cdac2d04e421b638c88fd2e8f907d9f6d
3 files changed +321 -6
caddytest/integration/panic_test.go new
+218
@@ -0,0 +1,218 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package integration
16 +
17 +import (
18 + "bufio"
19 + "bytes"
20 + "encoding/json"
21 + "io"
22 + "net/http"
23 + "strings"
24 + "sync"
25 + "testing"
26 + "time"
27 +
28 + "github.com/caddyserver/caddy/v2"
29 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
30 +
31 + // register the standard modules (http app, file log writer, etc.)
32 + _ "github.com/caddyserver/caddy/v2/modules/standard"
33 +)
34 +
35 +const (
36 + // panicTestHandlerID is the module ID of the test-only handler that panics.
37 + panicTestHandlerID = "http.handlers.panic_test"
38 + // panicLogPrefix is the prefix Go's net/http server writes to
39 + // http.Server.ErrorLog when it recovers a handler panic. It mirrors the
40 + // unexported production constant caddyhttp.stdlibLogPrefixPanic.
41 + panicLogPrefix = "http: panic serving"
42 + // panicErrorLevel is the zap level string the JSON encoder emits for
43 + // entries logged at error level.
44 + panicErrorLevel = "error"
45 + // panicListenAddr is the HTTP listener for the panic route.
46 + panicListenAddr = ":9080"
47 + // panicRequestURL hits the panic route.
48 + panicRequestURL = "http://localhost:9080/"
49 + // panicLogPollInterval is how often the test re-reads the log file while
50 + // waiting for the panic entry to be flushed to disk.
51 + panicLogPollInterval = 50 * time.Millisecond
52 + // panicLogPollTimeout bounds how long the test waits for the entry.
53 + panicLogPollTimeout = 5 * time.Second
54 +)
55 +
56 +// panicHandler is a test-only HTTP handler module that panics in ServeHTTP
57 +// with a non-ErrAbortHandler value, so Go's net/http server recovers it and
58 +// writes an "http: panic serving" line to http.Server.ErrorLog.
59 +type panicHandler struct{}
60 +
61 +type panicLogWriter struct{}
62 +
63 +type panicLogBuffer struct {
64 + sync.Mutex
65 + bytes.Buffer
66 +}
67 +
68 +var panicLogs panicLogBuffer
69 +
70 +func (panicHandler) CaddyModule() caddy.ModuleInfo {
71 + return caddy.ModuleInfo{
72 + ID: panicTestHandlerID,
73 + New: func() caddy.Module { return new(panicHandler) },
74 + }
75 +}
76 +
77 +func (panicHandler) ServeHTTP(http.ResponseWriter, *http.Request, caddyhttp.Handler) error {
78 + panic("boom from panic_test handler")
79 +}
80 +
81 +func (panicLogWriter) CaddyModule() caddy.ModuleInfo {
82 + return caddy.ModuleInfo{
83 + ID: "caddy.logging.writers.panic_test",
84 + New: func() caddy.Module { return new(panicLogWriter) },
85 + }
86 +}
87 +
88 +func (panicLogWriter) String() string {
89 + return "panic test log"
90 +}
91 +
92 +func (panicLogWriter) WriterKey() string {
93 + return "panic_test"
94 +}
95 +
96 +func (panicLogWriter) OpenWriter() (io.WriteCloser, error) {
97 + return &panicLogs, nil
98 +}
99 +
100 +func (b *panicLogBuffer) Write(p []byte) (int, error) {
101 + b.Lock()
102 + defer b.Unlock()
103 + return b.Buffer.Write(p)
104 +}
105 +
106 +func (*panicLogBuffer) Close() error {
107 + return nil
108 +}
109 +
110 +func (b *panicLogBuffer) reset() {
111 + b.Lock()
112 + defer b.Unlock()
113 + b.Buffer.Reset()
114 +}
115 +
116 +func (b *panicLogBuffer) snapshot() []byte {
117 + b.Lock()
118 + defer b.Unlock()
119 + return bytes.Clone(b.Buffer.Bytes())
120 +}
121 +
122 +func init() {
123 + caddy.RegisterModule(panicHandler{})
124 + caddy.RegisterModule(panicLogWriter{})
125 +}
126 +
127 +// TestHandlerPanicLogsAtError boots a real Caddy HTTP server whose route panics
128 +// while serving a request, and asserts that the recovered panic surfaces in
129 +// Caddy's structured logs at ERROR level (so it stays visible at the default
130 +// log level). Booting exercises the App.Start() server-boot wiring that routes
131 +// http.Server.ErrorLog through Caddy's structured logger. Regression test for
132 +// https://github.com/caddyserver/caddy/issues/7923.
133 +func TestHandlerPanicLogsAtError(t *testing.T) {
134 + panicLogs.reset()
135 +
136 + // Boot a real Caddy instance. caddy.Run provisions and calls App.Start
137 + // synchronously, so the panic-log wiring runs before we send the request.
138 + config := `{
139 + "admin": {"disabled": true},
140 + "logging": {
141 + "logs": {
142 + "panic_test": {
143 + "writer": {"output": "panic_test"},
144 + "encoder": {"format": "json"},
145 + "level": "DEBUG",
146 + "include": ["http"]
147 + }
148 + }
149 + },
150 + "apps": {
151 + "http": {
152 + "grace_period": 1,
153 + "servers": {
154 + "srv0": {
155 + "listen": ["` + panicListenAddr + `"],
156 + "automatic_https": {"disable": true},
157 + "routes": [
158 + {"handle": [{"handler": "panic_test"}]}
159 + ]
160 + }
161 + }
162 + }
163 + }
164 + }`
165 + if err := caddy.Load([]byte(config), true); err != nil {
166 + t.Fatalf("failed to load caddy config: %v", err)
167 + }
168 + t.Cleanup(func() {
169 + if err := caddy.Stop(); err != nil {
170 + t.Errorf("failed to stop caddy: %v", err)
171 + }
172 + })
173 +
174 + // The panic aborts the connection, so the client sees an error; that's
175 + // expected. We only care that the server logged the panic.
176 + resp, err := http.Get(panicRequestURL)
177 + if err == nil {
178 + resp.Body.Close()
179 + }
180 +
181 + if !waitForPanicLog(t) {
182 + t.Fatalf("did not find an error-level %q entry", panicLogPrefix)
183 + }
184 +}
185 +
186 +// waitForPanicLog polls the log output until it contains a JSON entry logged
187 +// at error level whose message begins with the net/http panic prefix, or the
188 +// timeout elapses.
189 +func waitForPanicLog(t *testing.T) bool {
190 + t.Helper()
191 + deadline := time.Now().Add(panicLogPollTimeout)
192 + for time.Now().Before(deadline) {
193 + if scanForPanicEntry(t) {
194 + return true
195 + }
196 + time.Sleep(panicLogPollInterval)
197 + }
198 + return false
199 +}
200 +
201 +func scanForPanicEntry(t *testing.T) bool {
202 + t.Helper()
203 +
204 + scanner := bufio.NewScanner(bytes.NewReader(panicLogs.snapshot()))
205 + for scanner.Scan() {
206 + var entry struct {
207 + Level string `json:"level"`
208 + Msg string `json:"msg"`
209 + }
210 + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
211 + continue
212 + }
213 + if entry.Level == panicErrorLevel && strings.HasPrefix(entry.Msg, panicLogPrefix) {
214 + return true
215 + }
216 + }
217 + return false
218 +}
modules/caddyhttp/app.go
+38 -6
@@ -20,9 +20,11 @@ import (
20 "crypto/tls"
21 "errors"
22 "fmt"
23 + "log"
24 "net"
25 "net/http"
26 "strconv"
27 + "strings"
28 "sync"
29 "time"
30
@@ -463,11 +465,9 @@ func removeTLSALPN(srv *Server, target string) {
465 // Start runs the app. It finishes automatic HTTPS if enabled,
466 // including management of certificates.
467 func (app *App) Start() error {
466 - // get a logger compatible with http.Server
467 - serverLogger, err := zap.NewStdLogAt(app.logger.Named("stdlib"), zap.DebugLevel)
468 - if err != nil {
469 - return fmt.Errorf("failed to set up server logger: %v", err)
470 - }
468 + // get a logger compatible with http.Server; recovered handler panics are
469 + // routed to ERROR so they remain visible at the default log level (#7923)
470 + serverLogger := serverErrorLogger(app.logger.Named("stdlib"))
471
472 for srvName, srv := range app.Servers {
473 srv.server = &http.Server{
@@ -662,7 +662,7 @@ func (app *App) Start() error {
662
663 // finish automatic HTTPS by finally beginning
664 // certificate management
665 - err = app.automaticHTTPSPhase2()
665 + err := app.automaticHTTPSPhase2()
666 if err != nil {
667 return fmt.Errorf("finalizing automatic HTTPS: %v", err)
668 }
@@ -670,6 +670,38 @@ func (app *App) Start() error {
670 return nil
671 }
672
673 +// stdlibLogPrefixPanic is the prefix Go's net/http server uses when it writes a
674 +// recovered handler panic (and its stack trace) to http.Server.ErrorLog.
675 +// See the deferred recover in net/http.(*conn).serve.
676 +const stdlibLogPrefixPanic = "http: panic serving"
677 +
678 +// serverErrorLogger returns a *log.Logger suitable for http.Server.ErrorLog
679 +// that forwards the standard library's server messages to Caddy's structured
680 +// logger. Most of these messages are low-signal and logged at DEBUG, but
681 +// recovered handler panics indicate a real server-side error, so they are
682 +// logged at ERROR to stay visible at the default log level.
683 +func serverErrorLogger(logger *zap.Logger) *log.Logger {
684 + return log.New(stdlibLogRouter{logger: logger}, "", 0)
685 +}
686 +
687 +// stdlibLogRouter is an io.Writer that routes standard library http.Server
688 +// error-log messages to the appropriate level on the wrapped structured logger.
689 +type stdlibLogRouter struct {
690 + logger *zap.Logger
691 +}
692 +
693 +func (w stdlibLogRouter) Write(p []byte) (int, error) {
694 + // log.Logger always appends a trailing newline; trim it so the structured
695 + // message doesn't carry a dangling blank line.
696 + msg := strings.TrimSuffix(string(p), "\n")
697 + if strings.HasPrefix(msg, stdlibLogPrefixPanic) {
698 + w.logger.Error(msg)
699 + } else {
700 + w.logger.Debug(msg)
701 + }
702 + return len(p), nil
703 +}
704 +
705 // Stop gracefully shuts down the HTTP server.
706 func (app *App) Stop() error {
707 ctx := context.Background()
modules/caddyhttp/app_test.go new
+65
@@ -0,0 +1,65 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package caddyhttp
16 +
17 +import (
18 + "testing"
19 +
20 + "go.uber.org/zap"
21 + "go.uber.org/zap/zapcore"
22 + "go.uber.org/zap/zaptest/observer"
23 +)
24 +
25 +// TestServerErrorLoggerLevels verifies that recovered net/http handler panics
26 +// written to http.Server.ErrorLog surface at ERROR level (so they're visible
27 +// at the default log level), while other standard library server messages stay
28 +// at DEBUG. Regression test for #7923.
29 +func TestServerErrorLoggerLevels(t *testing.T) {
30 + for _, tc := range []struct {
31 + name string
32 + message string
33 + wantLevel zapcore.Level
34 + }{
35 + {
36 + name: "recovered handler panic logs at error",
37 + message: "http: panic serving 127.0.0.1:12345: boom\ngoroutine 1 [running]:\nmain.handler()",
38 + wantLevel: zapcore.ErrorLevel,
39 + },
40 + {
41 + name: "other server message logs at debug",
42 + message: "http: TLS handshake error from 127.0.0.1:12345: EOF",
43 + wantLevel: zapcore.DebugLevel,
44 + },
45 + } {
46 + t.Run(tc.name, func(t *testing.T) {
47 + core, logs := observer.New(zapcore.DebugLevel)
48 + serverLogger := serverErrorLogger(zap.New(core))
49 +
50 + serverLogger.Print(tc.message)
51 +
52 + entries := logs.All()
53 + if len(entries) != 1 {
54 + t.Fatalf("expected exactly 1 log entry, got %d", len(entries))
55 + }
56 + got := entries[0]
57 + if got.Level != tc.wantLevel {
58 + t.Errorf("expected level %s, got %s", tc.wantLevel, got.Level)
59 + }
60 + if got.Message != tc.message {
61 + t.Errorf("expected message %q, got %q", tc.message, got.Message)
62 + }
63 + })
64 + }
65 +}