mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-04 15:35:38 +00:00
tsweb: add transparent compression for StdHandler
Implements inline compression for both gzip and brotli via the brotli library. The library requires that Content-Type is set. The implementation here explicitly avoids wrapping the ResponseWriter in cases where Accept-Encoding is not set so as to maximally attempt to get out of the way of hijack and upgrade concerns. It also avoids any attempt at compression if Content-Encoding is already set so that handlers that already perform compression are unaffected. Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
parent
9a80b8fb10
commit
08a34edd91
@ -8,6 +8,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
|
||||
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/andybalholm/brotli from tailscale.com/tsweb
|
||||
LD github.com/anmitsu/go-shlex from tailscale.com/tempfork/gliderlabs/ssh
|
||||
L github.com/aws/aws-sdk-go-v2 from github.com/aws/aws-sdk-go-v2/internal/ini
|
||||
L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/middleware+
|
||||
|
104
tsweb/compress.go
Normal file
104
tsweb/compress.go
Normal file
@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package tsweb
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
type compressingHandler struct {
|
||||
h http.Handler
|
||||
}
|
||||
|
||||
func (h compressingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if !AcceptsEncoding(r, "br") && !AcceptsEncoding(r, "gzip") {
|
||||
h.h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cw := &compressingResponseWriter{
|
||||
ResponseWriter: w,
|
||||
r: r,
|
||||
}
|
||||
defer cw.Close()
|
||||
|
||||
h.h.ServeHTTP(cw, r)
|
||||
}
|
||||
|
||||
type compressingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
r *http.Request
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// WriteHeader implements http.ResponseWriter.
|
||||
func (w *compressingResponseWriter) WriteHeader(code int) {
|
||||
// If a handler has already set a Content-Encoding, such as for precompressed
|
||||
// assets, skip the compressing writer. This must be recorded before
|
||||
// WriteHeader call as "The header map is cleared when 2xx-5xx headers are
|
||||
// sent".
|
||||
if w.w == nil {
|
||||
if w.ResponseWriter.Header().Get("Content-Encoding") == "" {
|
||||
w.w = brotli.HTTPCompressor(w.ResponseWriter, w.r)
|
||||
} else {
|
||||
w.w = w.ResponseWriter
|
||||
}
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Write implements http.ResponseWriter.
|
||||
func (w *compressingResponseWriter) Write(b []byte) (int, error) {
|
||||
if w.w == nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.w.Write(b)
|
||||
}
|
||||
|
||||
// Close implements io.Closer.
|
||||
func (w *compressingResponseWriter) Close() error {
|
||||
if w.w == nil {
|
||||
return nil
|
||||
}
|
||||
if c, ok := w.w.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flusher is an interface that is implemented by gzip.Writer and other writers
|
||||
// that differs from http.Flusher in that it may return an error.
|
||||
type flusher interface {
|
||||
Flush() error
|
||||
}
|
||||
|
||||
// Flush implements http.Flusher.
|
||||
func (w *compressingResponseWriter) Flush() {
|
||||
// the writer may implement either of the flusher interfaces, so try both.
|
||||
if f, ok := w.w.(flusher); ok {
|
||||
_ = f.Flush()
|
||||
}
|
||||
if f, ok := w.w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Hijack implements http.Hijacker.
|
||||
func (w *compressingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if hj, ok := w.ResponseWriter.(http.Hijacker); ok {
|
||||
return hj.Hijack()
|
||||
}
|
||||
return nil, nil, errors.New("ResponseWriter is not a Hijacker")
|
||||
}
|
140
tsweb/compress_test.go
Normal file
140
tsweb/compress_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package tsweb
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
func TestCompressingHandler(t *testing.T) {
|
||||
h := compressingHandler{nil}
|
||||
var _ http.Handler = h
|
||||
|
||||
w := &compressingResponseWriter{}
|
||||
var (
|
||||
_ http.ResponseWriter = w
|
||||
_ http.Flusher = w
|
||||
_ http.Hijacker = w
|
||||
)
|
||||
|
||||
// testRequest constructs a response recorder and a compressing handler that
|
||||
// wraps the given handler h, it calls the handler with r, and returns the
|
||||
// response recorder. If r is nil, then a GET request is made to "/" with no
|
||||
// additional headers.
|
||||
testRequest := func(r *http.Request, h http.HandlerFunc) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
if r == nil {
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
}
|
||||
compressingHandler{h}.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
checkBody := func(r io.Reader, want string) {
|
||||
t.Helper()
|
||||
body, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(body) != want {
|
||||
t.Errorf("got body %q, want %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
checkHeader := func(h http.Header, key, want string) {
|
||||
t.Helper()
|
||||
if got := h.Get(key); got != want {
|
||||
t.Errorf("got header %q=%q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("transparently compresses content with brotli", func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "br")
|
||||
|
||||
w := testRequest(r, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("hello world"))
|
||||
})
|
||||
|
||||
checkHeader(w.Header(), "Content-Encoding", "br")
|
||||
checkBody(brotli.NewReader(w.Body), "hello world")
|
||||
})
|
||||
|
||||
t.Run("transparently compresses content with gzip", func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
w := testRequest(r, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("hello world"))
|
||||
})
|
||||
|
||||
checkHeader(w.Header(), "Content-Encoding", "gzip")
|
||||
br, err := gzip.NewReader(w.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkBody(br, "hello world")
|
||||
})
|
||||
|
||||
t.Run("does not compress content if client does not accept compressed content", func(t *testing.T) {
|
||||
w := testRequest(nil, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("hello world"))
|
||||
})
|
||||
|
||||
checkHeader(w.Header(), "Content-Encoding", "")
|
||||
checkBody(w.Body, "hello world")
|
||||
})
|
||||
|
||||
t.Run("does not recompress content if client accepts compressed content but content is already compressed", func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "br")
|
||||
|
||||
w := testRequest(r, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Encoding", "magic")
|
||||
w.Write([]byte("hello world"))
|
||||
})
|
||||
|
||||
checkHeader(w.Header(), "Content-Encoding", "magic")
|
||||
checkBody(w.Body, "hello world")
|
||||
})
|
||||
|
||||
t.Run("integration", func(t *testing.T) {
|
||||
s := httptest.NewServer(compressingHandler{http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("hello world"))
|
||||
})})
|
||||
defer s.Close()
|
||||
|
||||
r, err := http.NewRequest("GET", s.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Header.Set("Accept-Encoding", "gzip")
|
||||
res, err := s.Client().Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checkHeader(res.Header, "Content-Encoding", "gzip")
|
||||
br, err := gzip.NewReader(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkBody(br, "hello world")
|
||||
})
|
||||
|
||||
}
|
@ -239,7 +239,7 @@ func StdHandler(h ReturnHandler, opts HandlerOptions) http.Handler {
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = logger.Discard
|
||||
}
|
||||
return retHandler{h, opts}
|
||||
return compressingHandler{retHandler{h, opts}}
|
||||
}
|
||||
|
||||
// retHandler is an http.Handler that wraps a Handler and handles errors.
|
||||
|
Loading…
Reference in New Issue
Block a user