@marcel91200 / caddy Mirror · read-only / commits / fb63e2e4
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 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"