Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 12, 12:00 UTC
caddyhttp: New experimental handler for intercepting responses (#6232)
* feat: add generic response interceptors * fix: cs * rename intercept * add some docs * @francislavoie review (first round) * Update modules/caddyhttp/intercept/intercept.go Co-authored-by: Francis Lavoie <lavofr@gmail.com> * shorthands: ir to resp * mark exported symbols as experimental --------- Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Kévin Dunglas committed
May 13, 2024 at 19:38 UTC
fb63e2e40ca34122849e63da85952246bf6bc6f1
6 files changed
+617
caddyconfig/httpcaddyfile/directives.go
+1
@@ -74,6 +74,7 @@ var defaultDirectiveOrder = []string{
74
"request_header",
75
"encode",
76
"push",
77
+ "intercept",
78
"templates",
79
80
// special routing & dispatching directives
caddyconfig/httpcaddyfile/shorthands.go
+1
@@ -36,6 +36,7 @@ func NewShorthandReplacer() ShorthandReplacer {
36
{regexp.MustCompile(`{re\.([\w-\.]*)}`), "{http.regexp.$1}"},
37
{regexp.MustCompile(`{vars\.([\w-]*)}`), "{http.vars.$1}"},
38
{regexp.MustCompile(`{rp\.([\w-\.]*)}`), "{http.reverse_proxy.$1}"},
39
+ {regexp.MustCompile(`{resp\.([\w-\.]*)}`), "{http.intercept.$1}"},
40
{regexp.MustCompile(`{err\.([\w-\.]*)}`), "{http.error.$1}"},
41
{regexp.MustCompile(`{file_match\.([\w-]*)}`), "{http.matchers.file.$1}"},
42
}
caddytest/integration/caddyfile_adapt/intercept_response.caddyfiletest
new
+230
@@ -0,0 +1,230 @@
1
+localhost
2
+
3
+respond "To intercept"
4
+
5
+intercept {
6
+ @500 status 500
7
+ replace_status @500 400
8
+
9
+ @all status 2xx 3xx 4xx 5xx
10
+ replace_status @all {http.error.status_code}
11
+
12
+ replace_status {http.error.status_code}
13
+
14
+ @accel header X-Accel-Redirect *
15
+ handle_response @accel {
16
+ respond "Header X-Accel-Redirect!"
17
+ }
18
+
19
+ @another {
20
+ header X-Another *
21
+ }
22
+ handle_response @another {
23
+ respond "Header X-Another!"
24
+ }
25
+
26
+ @401 status 401
27
+ handle_response @401 {
28
+ respond "Status 401!"
29
+ }
30
+
31
+ handle_response {
32
+ respond "Any! This should be last in the JSON!"
33
+ }
34
+
35
+ @403 {
36
+ status 403
37
+ }
38
+ handle_response @403 {
39
+ respond "Status 403!"
40
+ }
41
+
42
+ @multi {
43
+ status 401 403
44
+ status 404
45
+ header Foo *
46
+ header Bar *
47
+ }
48
+ handle_response @multi {
49
+ respond "Headers Foo, Bar AND statuses 401, 403 and 404!"
50
+ }
51
+}
52
+----------
53
+{
54
+ "apps": {
55
+ "http": {
56
+ "servers": {
57
+ "srv0": {
58
+ "listen": [
59
+ ":443"
60
+ ],
61
+ "routes": [
62
+ {
63
+ "match": [
64
+ {
65
+ "host": [
66
+ "localhost"
67
+ ]
68
+ }
69
+ ],
70
+ "handle": [
71
+ {
72
+ "handler": "subroute",
73
+ "routes": [
74
+ {
75
+ "handle": [
76
+ {
77
+ "handle_response": [
78
+ {
79
+ "match": {
80
+ "status_code": [
81
+ 500
82
+ ]
83
+ },
84
+ "status_code": 400
85
+ },
86
+ {
87
+ "match": {
88
+ "status_code": [
89
+ 2,
90
+ 3,
91
+ 4,
92
+ 5
93
+ ]
94
+ },
95
+ "status_code": "{http.error.status_code}"
96
+ },
97
+ {
98
+ "match": {
99
+ "headers": {
100
+ "X-Accel-Redirect": [
101
+ "*"
102
+ ]
103
+ }
104
+ },
105
+ "routes": [
106
+ {
107
+ "handle": [
108
+ {
109
+ "body": "Header X-Accel-Redirect!",
110
+ "handler": "static_response"
111
+ }
112
+ ]
113
+ }
114
+ ]
115
+ },
116
+ {
117
+ "match": {
118
+ "headers": {
119
+ "X-Another": [
120
+ "*"
121
+ ]
122
+ }
123
+ },
124
+ "routes": [
125
+ {
126
+ "handle": [
127
+ {
128
+ "body": "Header X-Another!",
129
+ "handler": "static_response"
130
+ }
131
+ ]
132
+ }
133
+ ]
134
+ },
135
+ {
136
+ "match": {
137
+ "status_code": [
138
+ 401
139
+ ]
140
+ },
141
+ "routes": [
142
+ {
143
+ "handle": [
144
+ {
145
+ "body": "Status 401!",
146
+ "handler": "static_response"
147
+ }
148
+ ]
149
+ }
150
+ ]
151
+ },
152
+ {
153
+ "match": {
154
+ "status_code": [
155
+ 403
156
+ ]
157
+ },
158
+ "routes": [
159
+ {
160
+ "handle": [
161
+ {
162
+ "body": "Status 403!",
163
+ "handler": "static_response"
164
+ }
165
+ ]
166
+ }
167
+ ]
168
+ },
169
+ {
170
+ "match": {
171
+ "headers": {
172
+ "Bar": [
173
+ "*"
174
+ ],
175
+ "Foo": [
176
+ "*"
177
+ ]
178
+ },
179
+ "status_code": [
180
+ 401,
181
+ 403,
182
+ 404
183
+ ]
184
+ },
185
+ "routes": [
186
+ {
187
+ "handle": [
188
+ {
189
+ "body": "Headers Foo, Bar AND statuses 401, 403 and 404!",
190
+ "handler": "static_response"
191
+ }
192
+ ]
193
+ }
194
+ ]
195
+ },
196
+ {
197
+ "status_code": "{http.error.status_code}"
198
+ },
199
+ {
200
+ "routes": [
201
+ {
202
+ "handle": [
203
+ {
204
+ "body": "Any! This should be last in the JSON!",
205
+ "handler": "static_response"
206
+ }
207
+ ]
208
+ }
209
+ ]
210
+ }
211
+ ],
212
+ "handler": "intercept"
213
+ },
214
+ {
215
+ "body": "To intercept",
216
+ "handler": "static_response"
217
+ }
218
+ ]
219
+ }
220
+ ]
221
+ }
222
+ ],
223
+ "terminal": true
224
+ }
225
+ ]
226
+ }
227
+ }
228
+ }
229
+ }
230
+}
caddytest/integration/intercept_test.go
new
+34
@@ -0,0 +1,34 @@
1
+package integration
2
+
3
+import (
4
+ "testing"
5
+
6
+ "github.com/caddyserver/caddy/v2/caddytest"
7
+)
8
+
9
+func TestIntercept(t *testing.T) {
10
+ tester := caddytest.NewTester(t)
11
+ tester.InitServer(`{
12
+ skip_install_trust
13
+ admin localhost:2999
14
+ http_port 9080
15
+ https_port 9443
16
+ grace_period 1ns
17
+ }
18
+
19
+ localhost:9080 {
20
+ respond /intercept "I'm a teapot" 408
21
+ respond /no-intercept "I'm not a teapot"
22
+
23
+ intercept {
24
+ @teapot status 408
25
+ handle_response @teapot {
26
+ respond /intercept "I'm a combined coffee/tea pot that is temporarily out of coffee" 503
27
+ }
28
+ }
29
+ }
30
+ `, "caddyfile")
31
+
32
+ tester.AssertGetResponse("http://localhost:9080/intercept", 503, "I'm a combined coffee/tea pot that is temporarily out of coffee")
33
+ tester.AssertGetResponse("http://localhost:9080/no-intercept", 200, "I'm not a teapot")
34
+}
modules/caddyhttp/intercept/intercept.go
new
+350
@@ -0,0 +1,350 @@
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 intercept
16
+
17
+import (
18
+ "bytes"
19
+ "fmt"
20
+ "net/http"
21
+ "strconv"
22
+ "strings"
23
+ "sync"
24
+
25
+ "go.uber.org/zap"
26
+
27
+ "github.com/caddyserver/caddy/v2"
28
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
29
+ "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
30
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
31
+)
32
+
33
+func init() {
34
+ caddy.RegisterModule(Intercept{})
35
+ httpcaddyfile.RegisterHandlerDirective("intercept", parseCaddyfile)
36
+}
37
+
38
+// Intercept is a middleware that intercepts then replaces or modifies the original response.
39
+// It can, for instance, be used to implement X-Sendfile/X-Accel-Redirect-like features
40
+// when using modules like FrankenPHP or Caddy Snake.
41
+//
42
+// EXPERIMENTAL: Subject to change or removal.
43
+type Intercept struct {
44
+ // List of handlers and their associated matchers to evaluate
45
+ // after successful response generation.
46
+ // The first handler that matches the original response will
47
+ // be invoked. The original response body will not be
48
+ // written to the client;
49
+ // it is up to the handler to finish handling the response.
50
+ //
51
+ // Three new placeholders are available in this handler chain:
52
+ // - `{http.intercept.status_code}` The status code from the response
53
+ // - `{http.intercept.status_text}` The status text from the response
54
+ // - `{http.intercept.header.*}` The headers from the response
55
+ HandleResponse []caddyhttp.ResponseHandler `json:"handle_response,omitempty"`
56
+
57
+ // Holds the named response matchers from the Caddyfile while adapting
58
+ responseMatchers map[string]caddyhttp.ResponseMatcher
59
+
60
+ // Holds the handle_response Caddyfile tokens while adapting
61
+ handleResponseSegments []*caddyfile.Dispenser
62
+
63
+ logger *zap.Logger
64
+}
65
+
66
+// CaddyModule returns the Caddy module information.
67
+//
68
+// EXPERIMENTAL: Subject to change or removal.
69
+func (Intercept) CaddyModule() caddy.ModuleInfo {
70
+ return caddy.ModuleInfo{
71
+ ID: "http.handlers.intercept",
72
+ New: func() caddy.Module { return new(Intercept) },
73
+ }
74
+}
75
+
76
+// Provision ensures that i is set up properly before use.
77
+//
78
+// EXPERIMENTAL: Subject to change or removal.
79
+func (irh *Intercept) Provision(ctx caddy.Context) error {
80
+ // set up any response routes
81
+ for i, rh := range irh.HandleResponse {
82
+ err := rh.Provision(ctx)
83
+ if err != nil {
84
+ return fmt.Errorf("provisioning response handler %d: %w", i, err)
85
+ }
86
+ }
87
+
88
+ irh.logger = ctx.Logger()
89
+
90
+ return nil
91
+}
92
+
93
+var bufPool = sync.Pool{
94
+ New: func() any {
95
+ return new(bytes.Buffer)
96
+ },
97
+}
98
+
99
+// TODO: handle status code replacement
100
+//
101
+// EXPERIMENTAL: Subject to change or removal.
102
+type interceptedResponseHandler struct {
103
+ caddyhttp.ResponseRecorder
104
+ replacer *caddy.Replacer
105
+ handler caddyhttp.ResponseHandler
106
+ handlerIndex int
107
+ statusCode int
108
+}
109
+
110
+// EXPERIMENTAL: Subject to change or removal.
111
+func (irh interceptedResponseHandler) WriteHeader(statusCode int) {
112
+ if irh.statusCode != 0 && (statusCode < 100 || statusCode >= 200) {
113
+ irh.ResponseRecorder.WriteHeader(irh.statusCode)
114
+
115
+ return
116
+ }
117
+
118
+ irh.ResponseRecorder.WriteHeader(statusCode)
119
+}
120
+
121
+// EXPERIMENTAL: Subject to change or removal.
122
+func (ir Intercept) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
123
+ buf := bufPool.Get().(*bytes.Buffer)
124
+ buf.Reset()
125
+ defer bufPool.Put(buf)
126
+
127
+ repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
128
+ rec := interceptedResponseHandler{replacer: repl}
129
+ rec.ResponseRecorder = caddyhttp.NewResponseRecorder(w, buf, func(status int, header http.Header) bool {
130
+ // see if any response handler is configured for this original response
131
+ for i, rh := range ir.HandleResponse {
132
+ if rh.Match != nil && !rh.Match.Match(status, header) {
133
+ continue
134
+ }
135
+ rec.handler = rh
136
+ rec.handlerIndex = i
137
+
138
+ // if configured to only change the status code,
139
+ // do that then stream
140
+ if statusCodeStr := rh.StatusCode.String(); statusCodeStr != "" {
141
+ sc, err := strconv.Atoi(repl.ReplaceAll(statusCodeStr, ""))
142
+ if err != nil {
143
+ rec.statusCode = http.StatusInternalServerError
144
+ } else {
145
+ rec.statusCode = sc
146
+ }
147
+ }
148
+
149
+ return rec.statusCode == 0
150
+ }
151
+
152
+ return false
153
+ })
154
+
155
+ if err := next.ServeHTTP(rec, r); err != nil {
156
+ return err
157
+ }
158
+ if !rec.Buffered() {
159
+ return nil
160
+ }
161
+
162
+ // set up the replacer so that parts of the original response can be
163
+ // used for routing decisions
164
+ for field, value := range r.Header {
165
+ repl.Set("http.intercept.header."+field, strings.Join(value, ","))
166
+ }
167
+ repl.Set("http.intercept.status_code", rec.Status())
168
+
169
+ ir.logger.Debug("handling response", zap.Int("handler", rec.handlerIndex))
170
+
171
+ // pass the request through the response handler routes
172
+ return rec.handler.Routes.Compile(next).ServeHTTP(w, r)
173
+}
174
+
175
+// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax:
176
+//
177
+// intercept [<matcher>] {
178
+// # intercept original responses
179
+// @name {
180
+// status <code...>
181
+// header <field> [<value>]
182
+// }
183
+// replace_status [<matcher>] <status_code>
184
+// handle_response [<matcher>] {
185
+// <directives...>
186
+// }
187
+// }
188
+//
189
+// The FinalizeUnmarshalCaddyfile method should be called after this
190
+// to finalize parsing of "handle_response" blocks, if possible.
191
+//
192
+// EXPERIMENTAL: Subject to change or removal.
193
+func (i *Intercept) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
194
+ // collect the response matchers defined as subdirectives
195
+ // prefixed with "@" for use with "handle_response" blocks
196
+ i.responseMatchers = make(map[string]caddyhttp.ResponseMatcher)
197
+
198
+ d.Next() // consume the directive name
199
+ for d.NextBlock(0) {
200
+ // if the subdirective has an "@" prefix then we
201
+ // parse it as a response matcher for use with "handle_response"
202
+ if strings.HasPrefix(d.Val(), matcherPrefix) {
203
+ err := caddyhttp.ParseNamedResponseMatcher(d.NewFromNextSegment(), i.responseMatchers)
204
+ if err != nil {
205
+ return err
206
+ }
207
+ continue
208
+ }
209
+
210
+ switch d.Val() {
211
+ case "handle_response":
212
+ // delegate the parsing of handle_response to the caller,
213
+ // since we need the httpcaddyfile.Helper to parse subroutes.
214
+ // See h.FinalizeUnmarshalCaddyfile
215
+ i.handleResponseSegments = append(i.handleResponseSegments, d.NewFromNextSegment())
216
+
217
+ case "replace_status":
218
+ args := d.RemainingArgs()
219
+ if len(args) != 1 && len(args) != 2 {
220
+ return d.Errf("must have one or two arguments: an optional response matcher, and a status code")
221
+ }
222
+
223
+ responseHandler := caddyhttp.ResponseHandler{}
224
+
225
+ if len(args) == 2 {
226
+ if !strings.HasPrefix(args[0], matcherPrefix) {
227
+ return d.Errf("must use a named response matcher, starting with '@'")
228
+ }
229
+ foundMatcher, ok := i.responseMatchers[args[0]]
230
+ if !ok {
231
+ return d.Errf("no named response matcher defined with name '%s'", args[0][1:])
232
+ }
233
+ responseHandler.Match = &foundMatcher
234
+ responseHandler.StatusCode = caddyhttp.WeakString(args[1])
235
+ } else if len(args) == 1 {
236
+ responseHandler.StatusCode = caddyhttp.WeakString(args[0])
237
+ }
238
+
239
+ // make sure there's no block, cause it doesn't make sense
240
+ if nesting := d.Nesting(); d.NextBlock(nesting) {
241
+ return d.Errf("cannot define routes for 'replace_status', use 'handle_response' instead.")
242
+ }
243
+
244
+ i.HandleResponse = append(
245
+ i.HandleResponse,
246
+ responseHandler,
247
+ )
248
+
249
+ default:
250
+ return d.Errf("unrecognized subdirective %s", d.Val())
251
+ }
252
+ }
253
+
254
+ return nil
255
+}
256
+
257
+// FinalizeUnmarshalCaddyfile finalizes the Caddyfile parsing which
258
+// requires having an httpcaddyfile.Helper to function, to parse subroutes.
259
+//
260
+// EXPERIMENTAL: Subject to change or removal.
261
+func (i *Intercept) FinalizeUnmarshalCaddyfile(helper httpcaddyfile.Helper) error {
262
+ for _, d := range i.handleResponseSegments {
263
+ // consume the "handle_response" token
264
+ d.Next()
265
+ args := d.RemainingArgs()
266
+
267
+ // TODO: Remove this check at some point in the future
268
+ if len(args) == 2 {
269
+ return d.Errf("configuring 'handle_response' for status code replacement is no longer supported. Use 'replace_status' instead.")
270
+ }
271
+
272
+ if len(args) > 1 {
273
+ return d.Errf("too many arguments for 'handle_response': %s", args)
274
+ }
275
+
276
+ var matcher *caddyhttp.ResponseMatcher
277
+ if len(args) == 1 {
278
+ // the first arg should always be a matcher.
279
+ if !strings.HasPrefix(args[0], matcherPrefix) {
280
+ return d.Errf("must use a named response matcher, starting with '@'")
281
+ }
282
+
283
+ foundMatcher, ok := i.responseMatchers[args[0]]
284
+ if !ok {
285
+ return d.Errf("no named response matcher defined with name '%s'", args[0][1:])
286
+ }
287
+ matcher = &foundMatcher
288
+ }
289
+
290
+ // parse the block as routes
291
+ handler, err := httpcaddyfile.ParseSegmentAsSubroute(helper.WithDispenser(d.NewFromNextSegment()))
292
+ if err != nil {
293
+ return err
294
+ }
295
+ subroute, ok := handler.(*caddyhttp.Subroute)
296
+ if !ok {
297
+ return helper.Errf("segment was not parsed as a subroute")
298
+ }
299
+ i.HandleResponse = append(
300
+ i.HandleResponse,
301
+ caddyhttp.ResponseHandler{
302
+ Match: matcher,
303
+ Routes: subroute.Routes,
304
+ },
305
+ )
306
+ }
307
+
308
+ // move the handle_response entries without a matcher to the end.
309
+ // we can't use sort.SliceStable because it will reorder the rest of the
310
+ // entries which may be undesirable because we don't have a good
311
+ // heuristic to use for sorting.
312
+ withoutMatchers := []caddyhttp.ResponseHandler{}
313
+ withMatchers := []caddyhttp.ResponseHandler{}
314
+ for _, hr := range i.HandleResponse {
315
+ if hr.Match == nil {
316
+ withoutMatchers = append(withoutMatchers, hr)
317
+ } else {
318
+ withMatchers = append(withMatchers, hr)
319
+ }
320
+ }
321
+ i.HandleResponse = append(withMatchers, withoutMatchers...)
322
+
323
+ // clean up the bits we only needed for adapting
324
+ i.handleResponseSegments = nil
325
+ i.responseMatchers = nil
326
+
327
+ return nil
328
+}
329
+
330
+const matcherPrefix = "@"
331
+
332
+func parseCaddyfile(helper httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
333
+ var ir Intercept
334
+ if err := ir.UnmarshalCaddyfile(helper.Dispenser); err != nil {
335
+ return nil, err
336
+ }
337
+
338
+ if err := ir.FinalizeUnmarshalCaddyfile(helper); err != nil {
339
+ return nil, err
340
+ }
341
+
342
+ return ir, nil
343
+}
344
+
345
+// Interface guards
346
+var (
347
+ _ caddy.Provisioner = (*Intercept)(nil)
348
+ _ caddyfile.Unmarshaler = (*Intercept)(nil)
349
+ _ caddyhttp.MiddlewareHandler = (*Intercept)(nil)
350
+)
modules/caddyhttp/standard/imports.go
+1
@@ -10,6 +10,7 @@ import (
10
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd"
11
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver"
12
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
13
+ _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/intercept"
14
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/logging"
15
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/map"
16
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/proxyprotocol"