Skip to content

Commit

Permalink
push: Implement HTTP/2 server push (#3573)
Browse files Browse the repository at this point in the history
* 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-Push
  • Loading branch information
mholt authored Jul 20, 2020
1 parent 2ae8c11 commit 6cea1f2
Show file tree
Hide file tree
Showing 7 changed files with 506 additions and 5 deletions.
3 changes: 2 additions & 1 deletion caddyconfig/httpcaddyfile/directives.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ var directiveOrder = []string{
"encode",
"templates",

// special routing directives
// special routing & dispatching directives
"handle",
"handle_path",
"route",
"push",

// handlers that typically respond to requests
"respond",
Expand Down
9 changes: 5 additions & 4 deletions modules/caddyhttp/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ func (Handler) CaddyModule() caddy.ModuleInfo {
}

// Provision sets up h's configuration.
func (h *Handler) Provision(_ caddy.Context) error {
func (h *Handler) Provision(ctx caddy.Context) error {
if h.Request != nil {
err := h.Request.provision()
err := h.Request.Provision(ctx)
if err != nil {
return err
}
}
if h.Response != nil {
err := h.Response.provision()
err := h.Response.Provision(ctx)
if err != nil {
return err
}
Expand Down Expand Up @@ -125,7 +125,8 @@ type HeaderOps struct {
Replace map[string][]Replacement `json:"replace,omitempty"`
}

func (ops *HeaderOps) provision() error {
// Provision sets up the header operations.
func (ops *HeaderOps) Provision(_ caddy.Context) error {
for fieldName, replacements := range ops.Replace {
for i, r := range replacements {
if r.SearchRegexp != "" {
Expand Down
99 changes: 99 additions & 0 deletions modules/caddyhttp/push/caddyfile.go
Original file line number Diff line number Diff line change
@@ -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
}
236 changes: 236 additions & 0 deletions modules/caddyhttp/push/handler.go
Original file line number Diff line number Diff line change
@@ -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)
)
Loading

0 comments on commit 6cea1f2

Please sign in to comment.