aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKévin Dunglas <[email protected]>2024-05-13 19:38:18 +0200
committerGitHub <[email protected]>2024-05-13 17:38:18 +0000
commitfb63e2e40ca34122849e63da85952246bf6bc6f1 (patch)
tree2fed9fab0fb46772694cb88edab8ebee3e639d19
parent583c585c81ac4bcf94fb2046b695f64a83b41cf7 (diff)
downloadcaddy-fb63e2e40ca34122849e63da85952246bf6bc6f1.tar.gz
caddy-fb63e2e40ca34122849e63da85952246bf6bc6f1.zip
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 <[email protected]> * shorthands: ir to resp * mark exported symbols as experimental --------- Co-authored-by: Francis Lavoie <[email protected]>
-rw-r--r--caddyconfig/httpcaddyfile/directives.go1
-rw-r--r--caddyconfig/httpcaddyfile/shorthands.go1
-rw-r--r--caddytest/integration/caddyfile_adapt/intercept_response.caddyfiletest230
-rw-r--r--caddytest/integration/intercept_test.go34
-rw-r--r--modules/caddyhttp/intercept/intercept.go350
-rw-r--r--modules/caddyhttp/standard/imports.go1
6 files changed, 617 insertions, 0 deletions
diff --git a/caddyconfig/httpcaddyfile/directives.go b/caddyconfig/httpcaddyfile/directives.go
index 3e688ebcf..6972bb674 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 5855d127c..5d9ef31eb 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 000000000..c92b76fe5
--- /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 000000000..81db6a7d6
--- /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 000000000..47d7511f7
--- /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 [<matcher>] {
+// # intercept original responses
+// @name {
+// status <code...>
+// header <field> [<value>]
+// }
+// replace_status [<matcher>] <status_code>
+// handle_response [<matcher>] {
+// <directives...>
+// }
+// }
+//
+// 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 236e7be1e..6617941c6 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"