From fb63e2e40ca34122849e63da85952246bf6bc6f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 13 May 2024 19:38:18 +0200 Subject: [PATCH] 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 * shorthands: ir to resp * mark exported symbols as experimental --------- Co-authored-by: Francis Lavoie --- caddyconfig/httpcaddyfile/directives.go | 1 + caddyconfig/httpcaddyfile/shorthands.go | 1 + .../intercept_response.caddyfiletest | 230 ++++++++++++ caddytest/integration/intercept_test.go | 34 ++ modules/caddyhttp/intercept/intercept.go | 350 ++++++++++++++++++ modules/caddyhttp/standard/imports.go | 1 + 6 files changed, 617 insertions(+) create mode 100644 caddytest/integration/caddyfile_adapt/intercept_response.caddyfiletest create mode 100644 caddytest/integration/intercept_test.go create mode 100644 modules/caddyhttp/intercept/intercept.go diff --git a/caddyconfig/httpcaddyfile/directives.go b/caddyconfig/httpcaddyfile/directives.go index 3e688ebc..6972bb67 100644 --- a/caddyconfig/httpcaddyfile/directives.go +++ b/caddyconfig/httpcaddyfile/directives.go @@ -74,6 +74,7 @@ var defaultDirectiveOrder = []string{ "request_header", "encode", "push", + "intercept", "templates", // special routing & dispatching directives diff --git a/caddyconfig/httpcaddyfile/shorthands.go b/caddyconfig/httpcaddyfile/shorthands.go index 5855d127..5d9ef31e 100644 --- a/caddyconfig/httpcaddyfile/shorthands.go +++ b/caddyconfig/httpcaddyfile/shorthands.go @@ -36,6 +36,7 @@ func NewShorthandReplacer() ShorthandReplacer { {regexp.MustCompile(`{re\.([\w-\.]*)}`), "{http.regexp.$1}"}, {regexp.MustCompile(`{vars\.([\w-]*)}`), "{http.vars.$1}"}, {regexp.MustCompile(`{rp\.([\w-\.]*)}`), "{http.reverse_proxy.$1}"}, + {regexp.MustCompile(`{resp\.([\w-\.]*)}`), "{http.intercept.$1}"}, {regexp.MustCompile(`{err\.([\w-\.]*)}`), "{http.error.$1}"}, {regexp.MustCompile(`{file_match\.([\w-]*)}`), "{http.matchers.file.$1}"}, } diff --git a/caddytest/integration/caddyfile_adapt/intercept_response.caddyfiletest b/caddytest/integration/caddyfile_adapt/intercept_response.caddyfiletest new file mode 100644 index 00000000..c92b76fe --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/intercept_response.caddyfiletest @@ -0,0 +1,230 @@ +localhost + +respond "To intercept" + +intercept { + @500 status 500 + replace_status @500 400 + + @all status 2xx 3xx 4xx 5xx + replace_status @all {http.error.status_code} + + replace_status {http.error.status_code} + + @accel header X-Accel-Redirect * + handle_response @accel { + respond "Header X-Accel-Redirect!" + } + + @another { + header X-Another * + } + handle_response @another { + respond "Header X-Another!" + } + + @401 status 401 + handle_response @401 { + respond "Status 401!" + } + + handle_response { + respond "Any! This should be last in the JSON!" + } + + @403 { + status 403 + } + handle_response @403 { + respond "Status 403!" + } + + @multi { + status 401 403 + status 404 + header Foo * + header Bar * + } + handle_response @multi { + respond "Headers Foo, Bar AND statuses 401, 403 and 404!" + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "localhost" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handle_response": [ + { + "match": { + "status_code": [ + 500 + ] + }, + "status_code": 400 + }, + { + "match": { + "status_code": [ + 2, + 3, + 4, + 5 + ] + }, + "status_code": "{http.error.status_code}" + }, + { + "match": { + "headers": { + "X-Accel-Redirect": [ + "*" + ] + } + }, + "routes": [ + { + "handle": [ + { + "body": "Header X-Accel-Redirect!", + "handler": "static_response" + } + ] + } + ] + }, + { + "match": { + "headers": { + "X-Another": [ + "*" + ] + } + }, + "routes": [ + { + "handle": [ + { + "body": "Header X-Another!", + "handler": "static_response" + } + ] + } + ] + }, + { + "match": { + "status_code": [ + 401 + ] + }, + "routes": [ + { + "handle": [ + { + "body": "Status 401!", + "handler": "static_response" + } + ] + } + ] + }, + { + "match": { + "status_code": [ + 403 + ] + }, + "routes": [ + { + "handle": [ + { + "body": "Status 403!", + "handler": "static_response" + } + ] + } + ] + }, + { + "match": { + "headers": { + "Bar": [ + "*" + ], + "Foo": [ + "*" + ] + }, + "status_code": [ + 401, + 403, + 404 + ] + }, + "routes": [ + { + "handle": [ + { + "body": "Headers Foo, Bar AND statuses 401, 403 and 404!", + "handler": "static_response" + } + ] + } + ] + }, + { + "status_code": "{http.error.status_code}" + }, + { + "routes": [ + { + "handle": [ + { + "body": "Any! This should be last in the JSON!", + "handler": "static_response" + } + ] + } + ] + } + ], + "handler": "intercept" + }, + { + "body": "To intercept", + "handler": "static_response" + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + } + } +} diff --git a/caddytest/integration/intercept_test.go b/caddytest/integration/intercept_test.go new file mode 100644 index 00000000..81db6a7d --- /dev/null +++ b/caddytest/integration/intercept_test.go @@ -0,0 +1,34 @@ +package integration + +import ( + "testing" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +func TestIntercept(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(`{ + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + + localhost:9080 { + respond /intercept "I'm a teapot" 408 + respond /no-intercept "I'm not a teapot" + + intercept { + @teapot status 408 + handle_response @teapot { + respond /intercept "I'm a combined coffee/tea pot that is temporarily out of coffee" 503 + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:9080/intercept", 503, "I'm a combined coffee/tea pot that is temporarily out of coffee") + tester.AssertGetResponse("http://localhost:9080/no-intercept", 200, "I'm not a teapot") +} diff --git a/modules/caddyhttp/intercept/intercept.go b/modules/caddyhttp/intercept/intercept.go new file mode 100644 index 00000000..47d7511f --- /dev/null +++ b/modules/caddyhttp/intercept/intercept.go @@ -0,0 +1,350 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intercept + +import ( + "bytes" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func init() { + caddy.RegisterModule(Intercept{}) + httpcaddyfile.RegisterHandlerDirective("intercept", parseCaddyfile) +} + +// Intercept is a middleware that intercepts then replaces or modifies the original response. +// It can, for instance, be used to implement X-Sendfile/X-Accel-Redirect-like features +// when using modules like FrankenPHP or Caddy Snake. +// +// EXPERIMENTAL: Subject to change or removal. +type Intercept struct { + // List of handlers and their associated matchers to evaluate + // after successful response generation. + // The first handler that matches the original response will + // be invoked. The original response body will not be + // written to the client; + // it is up to the handler to finish handling the response. + // + // Three new placeholders are available in this handler chain: + // - `{http.intercept.status_code}` The status code from the response + // - `{http.intercept.status_text}` The status text from the response + // - `{http.intercept.header.*}` The headers from the response + HandleResponse []caddyhttp.ResponseHandler `json:"handle_response,omitempty"` + + // Holds the named response matchers from the Caddyfile while adapting + responseMatchers map[string]caddyhttp.ResponseMatcher + + // Holds the handle_response Caddyfile tokens while adapting + handleResponseSegments []*caddyfile.Dispenser + + logger *zap.Logger +} + +// CaddyModule returns the Caddy module information. +// +// EXPERIMENTAL: Subject to change or removal. +func (Intercept) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.intercept", + New: func() caddy.Module { return new(Intercept) }, + } +} + +// Provision ensures that i is set up properly before use. +// +// EXPERIMENTAL: Subject to change or removal. +func (irh *Intercept) Provision(ctx caddy.Context) error { + // set up any response routes + for i, rh := range irh.HandleResponse { + err := rh.Provision(ctx) + if err != nil { + return fmt.Errorf("provisioning response handler %d: %w", i, err) + } + } + + irh.logger = ctx.Logger() + + return nil +} + +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +// TODO: handle status code replacement +// +// EXPERIMENTAL: Subject to change or removal. +type interceptedResponseHandler struct { + caddyhttp.ResponseRecorder + replacer *caddy.Replacer + handler caddyhttp.ResponseHandler + handlerIndex int + statusCode int +} + +// EXPERIMENTAL: Subject to change or removal. +func (irh interceptedResponseHandler) WriteHeader(statusCode int) { + if irh.statusCode != 0 && (statusCode < 100 || statusCode >= 200) { + irh.ResponseRecorder.WriteHeader(irh.statusCode) + + return + } + + irh.ResponseRecorder.WriteHeader(statusCode) +} + +// EXPERIMENTAL: Subject to change or removal. +func (ir Intercept) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufPool.Put(buf) + + repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + rec := interceptedResponseHandler{replacer: repl} + rec.ResponseRecorder = caddyhttp.NewResponseRecorder(w, buf, func(status int, header http.Header) bool { + // see if any response handler is configured for this original response + for i, rh := range ir.HandleResponse { + if rh.Match != nil && !rh.Match.Match(status, header) { + continue + } + rec.handler = rh + rec.handlerIndex = i + + // if configured to only change the status code, + // do that then stream + if statusCodeStr := rh.StatusCode.String(); statusCodeStr != "" { + sc, err := strconv.Atoi(repl.ReplaceAll(statusCodeStr, "")) + if err != nil { + rec.statusCode = http.StatusInternalServerError + } else { + rec.statusCode = sc + } + } + + return rec.statusCode == 0 + } + + return false + }) + + if err := next.ServeHTTP(rec, r); err != nil { + return err + } + if !rec.Buffered() { + return nil + } + + // set up the replacer so that parts of the original response can be + // used for routing decisions + for field, value := range r.Header { + repl.Set("http.intercept.header."+field, strings.Join(value, ",")) + } + repl.Set("http.intercept.status_code", rec.Status()) + + ir.logger.Debug("handling response", zap.Int("handler", rec.handlerIndex)) + + // pass the request through the response handler routes + return rec.handler.Routes.Compile(next).ServeHTTP(w, r) +} + +// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax: +// +// intercept [] { +// # intercept original responses +// @name { +// status +// header [] +// } +// replace_status [] +// handle_response [] { +// +// } +// } +// +// The FinalizeUnmarshalCaddyfile method should be called after this +// to finalize parsing of "handle_response" blocks, if possible. +// +// EXPERIMENTAL: Subject to change or removal. +func (i *Intercept) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + // collect the response matchers defined as subdirectives + // prefixed with "@" for use with "handle_response" blocks + i.responseMatchers = make(map[string]caddyhttp.ResponseMatcher) + + d.Next() // consume the directive name + for d.NextBlock(0) { + // if the subdirective has an "@" prefix then we + // parse it as a response matcher for use with "handle_response" + if strings.HasPrefix(d.Val(), matcherPrefix) { + err := caddyhttp.ParseNamedResponseMatcher(d.NewFromNextSegment(), i.responseMatchers) + if err != nil { + return err + } + continue + } + + switch d.Val() { + case "handle_response": + // delegate the parsing of handle_response to the caller, + // since we need the httpcaddyfile.Helper to parse subroutes. + // See h.FinalizeUnmarshalCaddyfile + i.handleResponseSegments = append(i.handleResponseSegments, d.NewFromNextSegment()) + + case "replace_status": + args := d.RemainingArgs() + if len(args) != 1 && len(args) != 2 { + return d.Errf("must have one or two arguments: an optional response matcher, and a status code") + } + + responseHandler := caddyhttp.ResponseHandler{} + + if len(args) == 2 { + if !strings.HasPrefix(args[0], matcherPrefix) { + return d.Errf("must use a named response matcher, starting with '@'") + } + foundMatcher, ok := i.responseMatchers[args[0]] + if !ok { + return d.Errf("no named response matcher defined with name '%s'", args[0][1:]) + } + responseHandler.Match = &foundMatcher + responseHandler.StatusCode = caddyhttp.WeakString(args[1]) + } else if len(args) == 1 { + responseHandler.StatusCode = caddyhttp.WeakString(args[0]) + } + + // make sure there's no block, cause it doesn't make sense + if nesting := d.Nesting(); d.NextBlock(nesting) { + return d.Errf("cannot define routes for 'replace_status', use 'handle_response' instead.") + } + + i.HandleResponse = append( + i.HandleResponse, + responseHandler, + ) + + default: + return d.Errf("unrecognized subdirective %s", d.Val()) + } + } + + return nil +} + +// FinalizeUnmarshalCaddyfile finalizes the Caddyfile parsing which +// requires having an httpcaddyfile.Helper to function, to parse subroutes. +// +// EXPERIMENTAL: Subject to change or removal. +func (i *Intercept) FinalizeUnmarshalCaddyfile(helper httpcaddyfile.Helper) error { + for _, d := range i.handleResponseSegments { + // consume the "handle_response" token + d.Next() + args := d.RemainingArgs() + + // TODO: Remove this check at some point in the future + if len(args) == 2 { + return d.Errf("configuring 'handle_response' for status code replacement is no longer supported. Use 'replace_status' instead.") + } + + if len(args) > 1 { + return d.Errf("too many arguments for 'handle_response': %s", args) + } + + var matcher *caddyhttp.ResponseMatcher + if len(args) == 1 { + // the first arg should always be a matcher. + if !strings.HasPrefix(args[0], matcherPrefix) { + return d.Errf("must use a named response matcher, starting with '@'") + } + + foundMatcher, ok := i.responseMatchers[args[0]] + if !ok { + return d.Errf("no named response matcher defined with name '%s'", args[0][1:]) + } + matcher = &foundMatcher + } + + // parse the block as routes + handler, err := httpcaddyfile.ParseSegmentAsSubroute(helper.WithDispenser(d.NewFromNextSegment())) + if err != nil { + return err + } + subroute, ok := handler.(*caddyhttp.Subroute) + if !ok { + return helper.Errf("segment was not parsed as a subroute") + } + i.HandleResponse = append( + i.HandleResponse, + caddyhttp.ResponseHandler{ + Match: matcher, + Routes: subroute.Routes, + }, + ) + } + + // move the handle_response entries without a matcher to the end. + // we can't use sort.SliceStable because it will reorder the rest of the + // entries which may be undesirable because we don't have a good + // heuristic to use for sorting. + withoutMatchers := []caddyhttp.ResponseHandler{} + withMatchers := []caddyhttp.ResponseHandler{} + for _, hr := range i.HandleResponse { + if hr.Match == nil { + withoutMatchers = append(withoutMatchers, hr) + } else { + withMatchers = append(withMatchers, hr) + } + } + i.HandleResponse = append(withMatchers, withoutMatchers...) + + // clean up the bits we only needed for adapting + i.handleResponseSegments = nil + i.responseMatchers = nil + + return nil +} + +const matcherPrefix = "@" + +func parseCaddyfile(helper httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + var ir Intercept + if err := ir.UnmarshalCaddyfile(helper.Dispenser); err != nil { + return nil, err + } + + if err := ir.FinalizeUnmarshalCaddyfile(helper); err != nil { + return nil, err + } + + return ir, nil +} + +// Interface guards +var ( + _ caddy.Provisioner = (*Intercept)(nil) + _ caddyfile.Unmarshaler = (*Intercept)(nil) + _ caddyhttp.MiddlewareHandler = (*Intercept)(nil) +) diff --git a/modules/caddyhttp/standard/imports.go b/modules/caddyhttp/standard/imports.go index 236e7be1..6617941c 100644 --- a/modules/caddyhttp/standard/imports.go +++ b/modules/caddyhttp/standard/imports.go @@ -10,6 +10,7 @@ import ( _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" + _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/intercept" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/logging" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/map" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/proxyprotocol"