Browse Source
* push: Implement HTTP/2 server push (close #3551) * push: Abstract header ops by embedding into new struct type This will allow us to add more fields to customize headers in push-specific ways in the future. * push: Ensure Link resources are pushed before response is written * Change header name from X-Caddy-Push to Caddy-Pushmaster
Matt Holt
5 years ago
committed by
GitHub
7 changed files with 506 additions and 5 deletions
@ -0,0 +1,99 @@ |
|||
// 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 push |
|||
|
|||
import ( |
|||
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" |
|||
"github.com/caddyserver/caddy/v2/modules/caddyhttp" |
|||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" |
|||
) |
|||
|
|||
func init() { |
|||
httpcaddyfile.RegisterHandlerDirective("push", parseCaddyfile) |
|||
} |
|||
|
|||
// parseCaddyfile sets up the push handler. Syntax:
|
|||
//
|
|||
// push [<matcher>] [<resource>] {
|
|||
// [GET|HEAD] <resource>
|
|||
// headers {
|
|||
// [+]<field> [<value|regexp> [<replacement>]]
|
|||
// -<field>
|
|||
// }
|
|||
// }
|
|||
//
|
|||
// A single resource can be specified inline without opening a
|
|||
// block for the most common/simple case. Or, a block can be
|
|||
// opened and multiple resources can be specified, one per
|
|||
// line, optionally preceded by the method. The headers
|
|||
// subdirective can be used to customize the headers that
|
|||
// are set on each (synthetic) push request, using the same
|
|||
// syntax as the 'header' directive for request headers.
|
|||
// Placeholders are accepted in resource and header field
|
|||
// name and value and replacement tokens.
|
|||
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { |
|||
handler := new(Handler) |
|||
|
|||
for h.Next() { |
|||
if h.NextArg() { |
|||
handler.Resources = append(handler.Resources, Resource{Target: h.Val()}) |
|||
} |
|||
|
|||
// optional block
|
|||
for outerNesting := h.Nesting(); h.NextBlock(outerNesting); { |
|||
switch h.Val() { |
|||
case "headers": |
|||
if h.NextArg() { |
|||
return nil, h.ArgErr() |
|||
} |
|||
for innerNesting := h.Nesting(); h.NextBlock(innerNesting); { |
|||
// include current token, which we treat as an argument here
|
|||
args := []string{h.Val()} |
|||
args = append(args, h.RemainingArgs()...) |
|||
|
|||
if handler.Headers == nil { |
|||
handler.Headers = new(HeaderConfig) |
|||
} |
|||
switch len(args) { |
|||
case 1: |
|||
headers.CaddyfileHeaderOp(&handler.Headers.HeaderOps, args[0], "", "") |
|||
case 2: |
|||
headers.CaddyfileHeaderOp(&handler.Headers.HeaderOps, args[0], args[1], "") |
|||
case 3: |
|||
headers.CaddyfileHeaderOp(&handler.Headers.HeaderOps, args[0], args[1], args[2]) |
|||
default: |
|||
return nil, h.ArgErr() |
|||
} |
|||
} |
|||
|
|||
case "GET", "HEAD": |
|||
method := h.Val() |
|||
if !h.NextArg() { |
|||
return nil, h.ArgErr() |
|||
} |
|||
target := h.Val() |
|||
handler.Resources = append(handler.Resources, Resource{ |
|||
Method: method, |
|||
Target: target, |
|||
}) |
|||
|
|||
default: |
|||
handler.Resources = append(handler.Resources, Resource{Target: h.Val()}) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return handler, nil |
|||
} |
@ -0,0 +1,236 @@ |
|||
// 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 push |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net/http" |
|||
"strings" |
|||
|
|||
"github.com/caddyserver/caddy/v2" |
|||
"github.com/caddyserver/caddy/v2/modules/caddyhttp" |
|||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" |
|||
"go.uber.org/zap" |
|||
) |
|||
|
|||
func init() { |
|||
caddy.RegisterModule(Handler{}) |
|||
} |
|||
|
|||
// Handler is a middleware for manipulating the request body.
|
|||
type Handler struct { |
|||
Resources []Resource `json:"resources,omitempty"` |
|||
Headers *HeaderConfig `json:"headers,omitempty"` |
|||
|
|||
logger *zap.Logger |
|||
} |
|||
|
|||
// CaddyModule returns the Caddy module information.
|
|||
func (Handler) CaddyModule() caddy.ModuleInfo { |
|||
return caddy.ModuleInfo{ |
|||
ID: "http.handlers.push", |
|||
New: func() caddy.Module { return new(Handler) }, |
|||
} |
|||
} |
|||
|
|||
// Provision sets up h.
|
|||
func (h *Handler) Provision(ctx caddy.Context) error { |
|||
h.logger = ctx.Logger(h) |
|||
if h.Headers != nil { |
|||
err := h.Headers.Provision(ctx) |
|||
if err != nil { |
|||
return fmt.Errorf("provisioning header operations: %v", err) |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { |
|||
pusher, ok := w.(http.Pusher) |
|||
if !ok { |
|||
return next.ServeHTTP(w, r) |
|||
} |
|||
|
|||
// short-circuit recursive pushes
|
|||
if _, ok := r.Header[pushHeader]; ok { |
|||
return next.ServeHTTP(w, r) |
|||
} |
|||
|
|||
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) |
|||
|
|||
// create header for push requests
|
|||
hdr := h.initializePushHeaders(r, repl) |
|||
|
|||
// push first!
|
|||
for _, resource := range h.Resources { |
|||
h.logger.Debug("pushing resource", |
|||
zap.String("uri", r.RequestURI), |
|||
zap.String("push_method", resource.Method), |
|||
zap.String("push_target", resource.Target), |
|||
zap.Object("push_headers", caddyhttp.LoggableHTTPHeader(hdr))) |
|||
err := pusher.Push(repl.ReplaceAll(resource.Target, "."), &http.PushOptions{ |
|||
Method: resource.Method, |
|||
Header: hdr, |
|||
}) |
|||
if err != nil { |
|||
// usually this means either that push is not
|
|||
// supported or concurrent streams are full
|
|||
break |
|||
} |
|||
} |
|||
|
|||
// wrap the response writer so that we can initiate push of any resources
|
|||
// described in Link header fields before the response is written
|
|||
lp := linkPusher{ |
|||
ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w}, |
|||
handler: h, |
|||
pusher: pusher, |
|||
header: hdr, |
|||
request: r, |
|||
} |
|||
|
|||
// serve only after pushing!
|
|||
if err := next.ServeHTTP(lp, r); err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (h Handler) initializePushHeaders(r *http.Request, repl *caddy.Replacer) http.Header { |
|||
hdr := make(http.Header) |
|||
|
|||
// prevent recursive pushes
|
|||
hdr.Set(pushHeader, "1") |
|||
|
|||
// set initial header fields; since exactly how headers should
|
|||
// be implemented for server push is not well-understood, we
|
|||
// are being conservative for now like httpd is:
|
|||
// https://httpd.apache.org/docs/2.4/en/howto/http2.html#push
|
|||
// we only copy some well-known, safe headers that are likely
|
|||
// crucial when requesting certain kinds of content
|
|||
for _, fieldName := range safeHeaders { |
|||
if vals, ok := r.Header[fieldName]; ok { |
|||
hdr[fieldName] = vals |
|||
} |
|||
} |
|||
|
|||
// user can customize the push request headers
|
|||
if h.Headers != nil { |
|||
h.Headers.ApplyTo(hdr, repl) |
|||
} |
|||
|
|||
return hdr |
|||
} |
|||
|
|||
// servePreloadLinks parses Link headers from upstream and pushes
|
|||
// resources described by them. If a resource has the "nopush"
|
|||
// attribute or describes an external entity (meaning, the resource
|
|||
// URI includes a scheme), it will not be pushed.
|
|||
func (h Handler) servePreloadLinks(pusher http.Pusher, hdr http.Header, resources []string) { |
|||
for _, resource := range resources { |
|||
for _, resource := range parseLinkHeader(resource) { |
|||
if _, ok := resource.params["nopush"]; ok { |
|||
continue |
|||
} |
|||
if isRemoteResource(resource.uri) { |
|||
continue |
|||
} |
|||
err := pusher.Push(resource.uri, &http.PushOptions{ |
|||
Header: hdr, |
|||
}) |
|||
if err != nil { |
|||
return |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Resource represents a request for a resource to push.
|
|||
type Resource struct { |
|||
// Method is the request method, which must be GET or HEAD.
|
|||
// Default is GET.
|
|||
Method string `json:"method,omitempty"` |
|||
|
|||
// Target is the path to the resource being pushed.
|
|||
Target string `json:"target,omitempty"` |
|||
} |
|||
|
|||
// HeaderConfig configures headers for synthetic push requests.
|
|||
type HeaderConfig struct { |
|||
headers.HeaderOps |
|||
} |
|||
|
|||
// linkPusher is a http.ResponseWriter that intercepts
|
|||
// the WriteHeader() call to ensure that any resources
|
|||
// described by Link response headers get pushed before
|
|||
// the response is allowed to be written.
|
|||
type linkPusher struct { |
|||
*caddyhttp.ResponseWriterWrapper |
|||
handler Handler |
|||
pusher http.Pusher |
|||
header http.Header |
|||
request *http.Request |
|||
} |
|||
|
|||
func (lp linkPusher) WriteHeader(statusCode int) { |
|||
if links, ok := lp.ResponseWriter.Header()["Link"]; ok { |
|||
// only initiate these pushes if it hasn't been done yet
|
|||
if val := caddyhttp.GetVar(lp.request.Context(), pushedLink); val == nil { |
|||
lp.handler.logger.Debug("pushing Link resources", zap.Strings("linked", links)) |
|||
caddyhttp.SetVar(lp.request.Context(), pushedLink, true) |
|||
lp.handler.servePreloadLinks(lp.pusher, lp.header, links) |
|||
} |
|||
} |
|||
lp.ResponseWriter.WriteHeader(statusCode) |
|||
} |
|||
|
|||
// isRemoteResource returns true if resource starts with
|
|||
// a scheme or is a protocol-relative URI.
|
|||
func isRemoteResource(resource string) bool { |
|||
return strings.HasPrefix(resource, "//") || |
|||
strings.HasPrefix(resource, "http://") || |
|||
strings.HasPrefix(resource, "https://") |
|||
} |
|||
|
|||
// safeHeaders is a list of header fields that are
|
|||
// safe to copy to push requests implicitly. It is
|
|||
// assumed that requests for certain kinds of content
|
|||
// would fail without these fields present.
|
|||
var safeHeaders = []string{ |
|||
"Accept-Encoding", |
|||
"Accept-Language", |
|||
"Accept", |
|||
"Cache-Control", |
|||
"User-Agent", |
|||
} |
|||
|
|||
// pushHeader is a header field that gets added to push requests
|
|||
// in order to avoid recursive/infinite pushes.
|
|||
const pushHeader = "Caddy-Push" |
|||
|
|||
// pushedLink is the key for the variable on the request
|
|||
// context that we use to remember whether we have already
|
|||
// pushed resources from Link headers yet; otherwise, if
|
|||
// multiple push handlers are invoked, it would repeat the
|
|||
// pushing of Link headers.
|
|||
const pushedLink = "http.handlers.push.pushed_link" |
|||
|
|||
// Interface guards
|
|||
var ( |
|||
_ caddy.Provisioner = (*Handler)(nil) |
|||
_ caddyhttp.MiddlewareHandler = (*Handler)(nil) |
|||
_ caddyhttp.HTTPInterfaces = (*linkPusher)(nil) |
|||
) |
@ -0,0 +1,78 @@ |
|||
// 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 push |
|||
|
|||
import ( |
|||
"strings" |
|||
) |
|||
|
|||
// linkResource contains the results of a parsed Link header.
|
|||
type linkResource struct { |
|||
uri string |
|||
params map[string]string |
|||
} |
|||
|
|||
// parseLinkHeader is responsible for parsing Link header
|
|||
// and returning list of found resources.
|
|||
//
|
|||
// Accepted formats are:
|
|||
//
|
|||
// Link: <resource>; as=script
|
|||
// Link: <resource>; as=script,<resource>; as=style
|
|||
// Link: <resource>;<resource2>
|
|||
//
|
|||
// where <resource> begins with a forward slash (/).
|
|||
func parseLinkHeader(header string) []linkResource { |
|||
resources := []linkResource{} |
|||
|
|||
if header == "" { |
|||
return resources |
|||
} |
|||
|
|||
for _, link := range strings.Split(header, comma) { |
|||
l := linkResource{params: make(map[string]string)} |
|||
|
|||
li, ri := strings.Index(link, "<"), strings.Index(link, ">") |
|||
if li == -1 || ri == -1 { |
|||
continue |
|||
} |
|||
|
|||
l.uri = strings.TrimSpace(link[li+1 : ri]) |
|||
|
|||
for _, param := range strings.Split(strings.TrimSpace(link[ri+1:]), semicolon) { |
|||
parts := strings.SplitN(strings.TrimSpace(param), equal, 2) |
|||
key := strings.TrimSpace(parts[0]) |
|||
if key == "" { |
|||
continue |
|||
} |
|||
if len(parts) == 1 { |
|||
l.params[key] = key |
|||
} |
|||
if len(parts) == 2 { |
|||
l.params[key] = strings.TrimSpace(parts[1]) |
|||
} |
|||
} |
|||
|
|||
resources = append(resources, l) |
|||
} |
|||
|
|||
return resources |
|||
} |
|||
|
|||
const ( |
|||
comma = "," |
|||
semicolon = ";" |
|||
equal = "=" |
|||
) |
@ -0,0 +1,85 @@ |
|||
// 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 push |
|||
|
|||
import ( |
|||
"reflect" |
|||
"testing" |
|||
) |
|||
|
|||
func TestParseLinkHeader(t *testing.T) { |
|||
testCases := []struct { |
|||
header string |
|||
expectedResources []linkResource |
|||
}{ |
|||
{ |
|||
header: "</resource>; as=script", |
|||
expectedResources: []linkResource{{uri: "/resource", params: map[string]string{"as": "script"}}}, |
|||
}, |
|||
{ |
|||
header: "</resource>", |
|||
expectedResources: []linkResource{{uri: "/resource", params: map[string]string{}}}, |
|||
}, |
|||
{ |
|||
header: "</resource>; nopush", |
|||
expectedResources: []linkResource{{uri: "/resource", params: map[string]string{"nopush": "nopush"}}}, |
|||
}, |
|||
{ |
|||
header: "</resource>;nopush;rel=next", |
|||
expectedResources: []linkResource{{uri: "/resource", params: map[string]string{"nopush": "nopush", "rel": "next"}}}, |
|||
}, |
|||
{ |
|||
header: "</resource>;nopush;rel=next,</resource2>;nopush", |
|||
expectedResources: []linkResource{ |
|||
{uri: "/resource", params: map[string]string{"nopush": "nopush", "rel": "next"}}, |
|||
{uri: "/resource2", params: map[string]string{"nopush": "nopush"}}, |
|||
}, |
|||
}, |
|||
{ |
|||
header: "</resource>,</resource2>", |
|||
expectedResources: []linkResource{ |
|||
{uri: "/resource", params: map[string]string{}}, |
|||
{uri: "/resource2", params: map[string]string{}}, |
|||
}, |
|||
}, |
|||
{ |
|||
header: "malformed", |
|||
expectedResources: []linkResource{}, |
|||
}, |
|||
{ |
|||
header: "<malformed", |
|||
expectedResources: []linkResource{}, |
|||
}, |
|||
{ |
|||
header: ",", |
|||
expectedResources: []linkResource{}, |
|||
}, |
|||
{ |
|||
header: ";", |
|||
expectedResources: []linkResource{}, |
|||
}, |
|||
{ |
|||
header: "</resource> ; ", |
|||
expectedResources: []linkResource{{uri: "/resource", params: map[string]string{}}}, |
|||
}, |
|||
} |
|||
|
|||
for i, test := range testCases { |
|||
actualResources := parseLinkHeader(test.header) |
|||
if !reflect.DeepEqual(actualResources, test.expectedResources) { |
|||
t.Errorf("Test %d (header: %s) - expected resources %v, got %v", |
|||
i, test.header, test.expectedResources, actualResources) |
|||
} |
|||
} |
|||
} |
Loading…
Reference in new issue