diff --git a/caddyconfig/httpcaddyfile/directives.go b/caddyconfig/httpcaddyfile/directives.go index 03e6753ee5f..b0d78158883 100644 --- a/caddyconfig/httpcaddyfile/directives.go +++ b/caddyconfig/httpcaddyfile/directives.go @@ -43,6 +43,7 @@ var directiveOrder = []string{ "root", "header", + "copy_response_headers", "request_body", "redir", @@ -68,6 +69,7 @@ var directiveOrder = []string{ // handlers that typically respond to requests "abort", "error", + "copy_response", "respond", "metrics", "reverse_proxy", diff --git a/caddytest/integration/caddyfile_adapt/reverse_proxy_handle_response.txt b/caddytest/integration/caddyfile_adapt/reverse_proxy_handle_response.txt index 88ecbc210db..1b6162dc0e2 100644 --- a/caddytest/integration/caddyfile_adapt/reverse_proxy_handle_response.txt +++ b/caddytest/integration/caddyfile_adapt/reverse_proxy_handle_response.txt @@ -41,6 +41,20 @@ reverse_proxy 127.0.0.1:65535 { handle_response @multi { respond "Headers Foo, Bar AND statuses 401, 403 and 404!" } + + @200 status 200 + handle_response @200 { + copy_response_headers { + include Foo Bar + } + respond "Copied headers from the response" + } + + @201 status 201 + handle_response @201 { + header Foo "Copying the response" + copy_response 404 + } } ---------- { @@ -163,6 +177,57 @@ reverse_proxy 127.0.0.1:65535 { } ] }, + { + "match": { + "status_code": [ + 200 + ] + }, + "routes": [ + { + "handle": [ + { + "handler": "copy_response_headers", + "include": [ + "Foo", + "Bar" + ] + }, + { + "body": "Copied headers from the response", + "handler": "static_response" + } + ] + } + ] + }, + { + "match": { + "status_code": [ + 201 + ] + }, + "routes": [ + { + "handle": [ + { + "handler": "headers", + "response": { + "set": { + "Foo": [ + "Copying the response" + ] + } + } + }, + { + "handler": "copy_response", + "status_code": 404 + } + ] + } + ] + }, { "routes": [ { diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index f4b16369556..cab0a71cd59 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -33,6 +33,8 @@ import ( func init() { httpcaddyfile.RegisterHandlerDirective("reverse_proxy", parseCaddyfile) + httpcaddyfile.RegisterHandlerDirective("copy_response", parseCopyResponseCaddyfile) + httpcaddyfile.RegisterHandlerDirective("copy_response_headers", parseCopyResponseHeadersCaddyfile) } func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { @@ -1019,6 +1021,84 @@ func (h *HTTPTransport) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return nil } +func parseCopyResponseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + crh := new(CopyResponseHandler) + err := crh.UnmarshalCaddyfile(h.Dispenser) + if err != nil { + return nil, err + } + return crh, nil +} + +// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax: +// +// copy_response [] [] { +// status +// } +// +func (h *CopyResponseHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + args := d.RemainingArgs() + if len(args) == 1 { + if num, err := strconv.Atoi(args[0]); err == nil && num > 0 { + h.StatusCode = caddyhttp.WeakString(args[0]) + break + } + } + + for d.NextBlock(0) { + switch d.Val() { + case "status": + if !d.NextArg() { + return d.ArgErr() + } + h.StatusCode = caddyhttp.WeakString(d.Val()) + default: + return d.Errf("unrecognized subdirective '%s'", d.Val()) + } + } + } + return nil +} + +func parseCopyResponseHeadersCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + crh := new(CopyResponseHeadersHandler) + err := crh.UnmarshalCaddyfile(h.Dispenser) + if err != nil { + return nil, err + } + return crh, nil +} + +// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax: +// +// copy_response_headers [] { +// exclude +// } +// +func (h *CopyResponseHeadersHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + args := d.RemainingArgs() + if len(args) > 0 { + return d.ArgErr() + } + + for d.NextBlock(0) { + switch d.Val() { + case "include": + h.Include = append(h.Include, d.RemainingArgs()...) + + case "exclude": + h.Exclude = append(h.Exclude, d.RemainingArgs()...) + + default: + return d.Errf("unrecognized subdirective '%s'", d.Val()) + } + } + } + return nil +} + // UnmarshalCaddyfile deserializes Caddyfile tokens into h. // // dynamic srv [] { diff --git a/modules/caddyhttp/reverseproxy/copyresponse.go b/modules/caddyhttp/reverseproxy/copyresponse.go new file mode 100644 index 00000000000..174ffa78e45 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/copyresponse.go @@ -0,0 +1,190 @@ +// 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 reverseproxy + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func init() { + caddy.RegisterModule(CopyResponseHandler{}) + caddy.RegisterModule(CopyResponseHeadersHandler{}) +} + +// CopyResponseHandler is a special HTTP handler which may +// only be used within reverse_proxy's handle_response routes, +// to copy the proxy response. EXPERIMENTAL, subject to change. +type CopyResponseHandler struct { + // To write the upstream response's body but with a different + // status code, set this field to the desired status code. + StatusCode caddyhttp.WeakString `json:"status_code,omitempty"` + + ctx caddy.Context +} + +// CaddyModule returns the Caddy module information. +func (CopyResponseHandler) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.copy_response", + New: func() caddy.Module { return new(CopyResponseHandler) }, + } +} + +// Provision ensures that h is set up properly before use. +func (h *CopyResponseHandler) Provision(ctx caddy.Context) error { + h.ctx = ctx + return nil +} + +// ServeHTTP implements the Handler interface. +func (h CopyResponseHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request, _ caddyhttp.Handler) error { + repl := req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + hrc, ok := req.Context().Value(proxyHandleResponseContextCtxKey).(*handleResponseContext) + + // don't allow this to be used outside of handle_response routes + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + fmt.Errorf("cannot use 'copy_response' outside of reverse_proxy's handle_response routes")) + } + + // allow a custom status code to be written; otherwise the + // status code from the upstream resposne is written + if codeStr := h.StatusCode.String(); codeStr != "" { + intVal, err := strconv.Atoi(repl.ReplaceAll(codeStr, "")) + if err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + hrc.response.StatusCode = intVal + } + + // make sure the reverse_proxy handler doesn't try to call + // finalizeResponse again after we've already done it here. + hrc.isFinalized = true + + // write the response + return hrc.handler.finalizeResponse(rw, req, hrc.response, repl, hrc.start, hrc.logger, false) +} + +// CopyResponseHeadersHandler is a special HTTP handler which may +// only be used within reverse_proxy's handle_response routes, +// to copy headers from the proxy response. EXPERIMENTAL; +// subject to change. +type CopyResponseHeadersHandler struct { + // A list of header fields to copy from the response. + // Cannot be defined at the same time as Exclude. + Include []string `json:"include,omitempty"` + + // A list of header fields to skip copying from the response. + // Cannot be defined at the same time as Include. + Exclude []string `json:"exclude,omitempty"` + + includeMap map[string]struct{} + excludeMap map[string]struct{} + ctx caddy.Context +} + +// CaddyModule returns the Caddy module information. +func (CopyResponseHeadersHandler) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.copy_response_headers", + New: func() caddy.Module { return new(CopyResponseHeadersHandler) }, + } +} + +// Validate ensures the h's configuration is valid. +func (h *CopyResponseHeadersHandler) Validate() error { + if len(h.Exclude) > 0 && len(h.Include) > 0 { + return fmt.Errorf("cannot define both 'exclude' and 'include' lists at the same time") + } + + return nil +} + +// Provision ensures that h is set up properly before use. +func (h *CopyResponseHeadersHandler) Provision(ctx caddy.Context) error { + h.ctx = ctx + + // Optimize the include list by converting it to a map + if len(h.Include) > 0 { + h.includeMap = map[string]struct{}{} + } + for _, field := range h.Include { + h.includeMap[http.CanonicalHeaderKey(field)] = struct{}{} + } + + // Optimize the exclude list by converting it to a map + if len(h.Exclude) > 0 { + h.excludeMap = map[string]struct{}{} + } + for _, field := range h.Exclude { + h.excludeMap[http.CanonicalHeaderKey(field)] = struct{}{} + } + + return nil +} + +// ServeHTTP implements the Handler interface. +func (h CopyResponseHeadersHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error { + hrc, ok := req.Context().Value(proxyHandleResponseContextCtxKey).(*handleResponseContext) + + // don't allow this to be used outside of handle_response routes + if !ok { + return caddyhttp.Error(http.StatusInternalServerError, + fmt.Errorf("cannot use 'copy_response_headers' outside of reverse_proxy's handle_response routes")) + } + + for field, values := range hrc.response.Header { + // Check the include list first, skip + // the header if it's _not_ in this list. + if len(h.includeMap) > 0 { + if _, ok := h.includeMap[field]; !ok { + continue + } + } + + // Then, check the exclude list, skip + // the header if it _is_ in this list. + if len(h.excludeMap) > 0 { + if _, ok := h.excludeMap[field]; ok { + continue + } + } + + // Copy all the values for the header. + for _, value := range values { + rw.Header().Add(field, value) + } + } + + return next.ServeHTTP(rw, req) +} + +// Interface guards +var ( + _ caddyhttp.MiddlewareHandler = (*CopyResponseHandler)(nil) + _ caddyfile.Unmarshaler = (*CopyResponseHandler)(nil) + _ caddy.Provisioner = (*CopyResponseHandler)(nil) + + _ caddyhttp.MiddlewareHandler = (*CopyResponseHeadersHandler)(nil) + _ caddyfile.Unmarshaler = (*CopyResponseHeadersHandler)(nil) + _ caddy.Provisioner = (*CopyResponseHeadersHandler)(nil) + _ caddy.Validator = (*CopyResponseHeadersHandler)(nil) +) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 3355f0b5670..2131a91c5b9 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -790,12 +790,33 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl * h.logger.Debug("handling response", zap.Int("handler", i)) + // we make some data available via request context to child routes + // so that they may inherit some options and functions from the + // handler, and be able to copy the response. + hrc := &handleResponseContext{ + handler: h, + response: res, + start: start, + logger: logger, + } + ctx := req.Context() + ctx = context.WithValue(ctx, proxyHandleResponseContextCtxKey, hrc) + // pass the request through the response handler routes - routeErr := rh.Routes.Compile(next).ServeHTTP(rw, req) + routeErr := rh.Routes.Compile(next).ServeHTTP(rw, req.WithContext(ctx)) + + // if the response handler routes already finalized the response, + // we can return early. It should be finalized if the routes executed + // included a copy_response handler. If a fresh response was written + // by the routes instead, then we still need to finalize the response + // without copying the body. + if routeErr == nil && hrc.isFinalized { + return nil + } - // always close the response body afterwards since it's expected + // always close the response body afterwards, since it's expected // that the response handler routes will have written to the - // response writer with a new body + // response writer with a new body, if it wasn't already finalized. res.Body.Close() bodyClosed = true @@ -804,8 +825,25 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl * // the roundtrip was successful and to not retry return roundtripSucceeded{routeErr} } + + // we've already closed the body, so there's no use allowing + // another response handler to run as well + break } + return h.finalizeResponse(rw, req, res, repl, start, logger, bodyClosed) +} + +// finalizeResponse prepares and copies the response. +func (h Handler) finalizeResponse( + rw http.ResponseWriter, + req *http.Request, + res *http.Response, + repl *caddy.Replacer, + start time.Time, + logger *zap.Logger, + bodyClosed bool, +) error { // deal with 101 Switching Protocols responses: (WebSocket, h2c, etc) if res.StatusCode == http.StatusSwitchingProtocols { h.handleUpgradeResponse(logger, rw, req, res) @@ -818,6 +856,13 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl * res.Header.Del(h) } + // remove the content length if we're not going to be copying + // from the response, because otherwise there'll be a mismatch + // between bytes written and the advertised length + if bodyClosed { + res.Header.Del("Content-Length") + } + // apply any response header operations if h.Headers != nil && h.Headers.Response != nil { if h.Headers.Response.Require == nil || @@ -841,7 +886,7 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl * rw.WriteHeader(res.StatusCode) if !bodyClosed { - err = h.copyResponse(rw, res.Body, h.flushInterval(req, res)) + err := h.copyResponse(rw, res.Body, h.flushInterval(req, res)) res.Body.Close() // close now, instead of defer, to populate res.Trailer if err != nil { // we're streaming the response and we've already written headers, so @@ -863,7 +908,7 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl * } // total duration spent proxying, including writing response body - repl.Set("http.reverse_proxy.upstream.duration", duration) + repl.Set("http.reverse_proxy.upstream.duration", time.Since(start)) if len(res.Trailer) == announcedTrailers { copyHeader(rw.Header(), res.Trailer) @@ -1227,6 +1272,38 @@ var bufPool = sync.Pool{ }, } +// handleResponseContext carries some contextual information about the +// the current proxy handling. +type handleResponseContext struct { + // handler is the active proxy handler instance, so that + // routes like copy_response may inherit some config + // options and have access to handler methods. + handler *Handler + + // response is the actual response received from the proxy + // roundtrip, to potentially be copied if a copy_response + // handler is in the handle_response routes. + response *http.Response + + // start is the time just before the proxy roundtrip was + // performed, used for logging. + start time.Time + + // logger is the prepared logger which is used to write logs + // with the request, duration, and selected upstream attached. + logger *zap.Logger + + // isFinalized is whether the response has been finalized, + // i.e. copied and closed, to make sure that it doesn't + // happen twice. + isFinalized bool +} + +// proxyHandleResponseContextCtxKey is the context key for the active proxy handler +// so that handle_response routes can inherit some config options +// from the proxy handler. +const proxyHandleResponseContextCtxKey caddy.CtxKey = "reverse_proxy_handle_response_context" + // Interface guards var ( _ caddy.Provisioner = (*Handler)(nil)