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
+}