From 8dc0802f59d43a34231de34241bba06a9e5a2fd5 Mon Sep 17 00:00:00 2001 From: James Tucker Date: Fri, 17 May 2024 13:47:27 -0700 Subject: [PATCH] client/web: add zstd, remove brotli for precompressed assets This will enable us to reduce the size of these embedded assets. As of February 2026 zstd is now part of the web baseline and supported by all major evergreens. Updates tailscale/corp#20099 Signed-off-by: James Tucker --- client/web/assets.go | 77 ++++- client/web/assets_test.go | 394 +++++++++++++++++++++++++ cmd/build-webclient/build-webclient.go | 4 +- cmd/derper/depaware.txt | 1 + cmd/k8s-operator/depaware.txt | 3 +- cmd/stund/depaware.txt | 1 + cmd/tailscale/depaware.txt | 9 + cmd/tailscaled/depaware.txt | 3 +- cmd/tsidp/depaware.txt | 3 +- flake.nix | 2 +- go.mod | 3 +- go.mod.sri | 2 +- go.sum | 6 +- shell.nix | 2 +- tsnet/depaware.txt | 3 +- tsweb/tsweb.go | 25 +- tsweb/tswebutil/tswebutil.go | 36 +++ tsweb/tswebutil/tswebutil_test.go | 37 +++ util/precompress/precompress.go | 25 +- 19 files changed, 585 insertions(+), 51 deletions(-) create mode 100644 client/web/assets_test.go create mode 100644 tsweb/tswebutil/tswebutil.go create mode 100644 tsweb/tswebutil/tswebutil_test.go diff --git a/client/web/assets.go b/client/web/assets.go index b9e422629..32a8c0525 100644 --- a/client/web/assets.go +++ b/client/web/assets.go @@ -4,6 +4,7 @@ package web import ( + "fmt" "io" "io/fs" "log" @@ -16,7 +17,9 @@ "strings" "time" + "github.com/klauspost/compress/zstd" prebuilt "github.com/tailscale/web-client-prebuilt" + "tailscale.com/tsweb/tswebutil" ) var start = time.Now() @@ -63,10 +66,76 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) { }), nil } -func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) { - if f, err := fs.Open(path + ".gz"); err == nil { - w.Header().Set("Content-Encoding", "gzip") - return f, nil +// zstFile wraps a zstd-compressed fs.File and provides transparent +// decompression. It implements io.ReadSeeker so that it can be used with +// http.ServeContent. Note that Seek is implemented by decompressing from the +// start, so http.ServeContent's size detection (SeekEnd then SeekStart) will +// decompress the content twice. This is acceptable for the small web client +// assets served here, but would not be appropriate for large files. +type zstFile struct { + f fs.File + *zstd.Decoder +} + +func newZSTFile(f fs.File) (*zstFile, error) { + zr, err := zstd.NewReader(f) + if err != nil { + f.Close() + return nil, err + } + return &zstFile{f: f, Decoder: zr}, nil +} + +func (z *zstFile) Seek(offset int64, whence int) (int64, error) { + reset := func() error { + if seeker, ok := z.f.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return err + } + } else { + return fmt.Errorf("not seekable: %w", os.ErrInvalid) + } + return z.Decoder.Reset(z.f) + } + + switch whence { + case io.SeekStart: + if err := reset(); err != nil { + return 0, err + } + return io.CopyN(io.Discard, z, offset) + case io.SeekCurrent: + if offset >= 0 { + return io.CopyN(io.Discard, z, offset) + } + return 0, fmt.Errorf("unsupported negative seek: %w", os.ErrInvalid) + case io.SeekEnd: + if offset != 0 { + return 0, fmt.Errorf("unsupported non-zero offset for SeekEnd: %w", os.ErrInvalid) + } + return io.Copy(io.Discard, z) + } + return 0, os.ErrInvalid +} + +func (z *zstFile) Close() error { + z.Decoder.Close() + return z.f.Close() +} + +func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (io.ReadCloser, error) { + if f, err := fs.Open(path + ".zst"); err == nil { + if tswebutil.AcceptsEncoding(r, "zstd") { + w.Header().Set("Content-Encoding", "zstd") + return f, nil + } + return newZSTFile(f) + } + if tswebutil.AcceptsEncoding(r, "gzip") { + if f, err := fs.Open(path + ".gz"); err == nil { + w.Header().Set("Content-Encoding", "gzip") + return f, nil + } } return fs.Open(path) // fallback } diff --git a/client/web/assets_test.go b/client/web/assets_test.go new file mode 100644 index 000000000..3f14a5dd1 --- /dev/null +++ b/client/web/assets_test.go @@ -0,0 +1,394 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package web + +import ( + "bytes" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/klauspost/compress/zstd" +) + +func compressZstd(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w, err := zstd.NewWriter(&buf, zstd.WithWindowSize(8<<20)) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestOpenPrecompressedFile_ZstdPassthrough(t *testing.T) { + original := []byte("hello world") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.js.zst": &fstest.MapFile{Data: compressed}, + } + + r := httptest.NewRequest("GET", "/test.js", nil) + r.Header.Set("Accept-Encoding", "zstd, gzip") + w := httptest.NewRecorder() + + f, err := openPrecompressedFile(w, r, "test.js", tfs) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if got := w.Header().Get("Content-Encoding"); got != "zstd" { + t.Errorf("Content-Encoding = %q, want %q", got, "zstd") + } + got, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + // Should return the raw compressed bytes when client accepts zstd. + if !bytes.Equal(got, compressed) { + t.Errorf("got decompressed data, want raw compressed passthrough") + } +} + +func TestOpenPrecompressedFile_ZstdDecompress(t *testing.T) { + original := []byte("hello world") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.js.zst": &fstest.MapFile{Data: compressed}, + } + + // Client does not accept zstd. + r := httptest.NewRequest("GET", "/test.js", nil) + r.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + f, err := openPrecompressedFile(w, r, "test.js", tfs) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want empty (transparent decompression)", got) + } + got, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("got %q, want %q", got, original) + } +} + +func TestOpenPrecompressedFile_GzipFallback(t *testing.T) { + gzData := []byte("fake-gzip-data") + tfs := fstest.MapFS{ + "test.js.gz": &fstest.MapFile{Data: gzData}, + } + + r := httptest.NewRequest("GET", "/test.js", nil) + r.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + f, err := openPrecompressedFile(w, r, "test.js", tfs) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if got := w.Header().Get("Content-Encoding"); got != "gzip" { + t.Errorf("Content-Encoding = %q, want %q", got, "gzip") + } +} + +func TestOpenPrecompressedFile_GzipNotAccepted(t *testing.T) { + tfs := fstest.MapFS{ + "test.js": &fstest.MapFile{Data: []byte("raw js")}, + "test.js.gz": &fstest.MapFile{Data: []byte("fake-gzip-data")}, + } + + // Client accepts neither zstd nor gzip. + r := httptest.NewRequest("GET", "/test.js", nil) + w := httptest.NewRecorder() + + f, err := openPrecompressedFile(w, r, "test.js", tfs) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want empty (no compression accepted)", got) + } + got, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(got) != "raw js" { + t.Errorf("got %q, want %q", got, "raw js") + } +} + +func TestOpenPrecompressedFile_PlainFallback(t *testing.T) { + tfs := fstest.MapFS{ + "test.js": &fstest.MapFile{Data: []byte("raw js")}, + } + + r := httptest.NewRequest("GET", "/test.js", nil) + r.Header.Set("Accept-Encoding", "zstd, gzip") + w := httptest.NewRecorder() + + f, err := openPrecompressedFile(w, r, "test.js", tfs) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want empty", got) + } + got, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(got) != "raw js" { + t.Errorf("got %q, want %q", got, "raw js") + } +} + +func TestZstFile_Seek(t *testing.T) { + original := []byte("hello world, this is a test of zstd seeking") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.zst": &fstest.MapFile{Data: compressed}, + } + + f, err := tfs.Open("test.zst") + if err != nil { + t.Fatal(err) + } + zf, err := newZSTFile(f) + if err != nil { + t.Fatal(err) + } + defer zf.Close() + + // SeekEnd with offset 0 should return the total decompressed size. + n, err := zf.Seek(0, io.SeekEnd) + if err != nil { + t.Fatalf("Seek(0, SeekEnd) error: %v", err) + } + if n != int64(len(original)) { + t.Errorf("Seek(0, SeekEnd) = %d, want %d", n, len(original)) + } + + // SeekStart with offset 0 should reset to the beginning. + n, err = zf.Seek(0, io.SeekStart) + if err != nil { + t.Fatalf("Seek(0, SeekStart) error: %v", err) + } + if n != 0 { + t.Errorf("Seek(0, SeekStart) = %d, want 0", n) + } + + // Read all content after reset. + got, err := io.ReadAll(zf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("after Seek(0, SeekStart) + ReadAll: got %q, want %q", got, original) + } +} + +func TestZstFile_SeekCurrent(t *testing.T) { + original := []byte("hello world") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.zst": &fstest.MapFile{Data: compressed}, + } + + f, err := tfs.Open("test.zst") + if err != nil { + t.Fatal(err) + } + zf, err := newZSTFile(f) + if err != nil { + t.Fatal(err) + } + defer zf.Close() + + // Skip forward 6 bytes. + n, err := zf.Seek(6, io.SeekCurrent) + if err != nil { + t.Fatalf("Seek(6, SeekCurrent) error: %v", err) + } + if n != 6 { + t.Errorf("Seek(6, SeekCurrent) = %d, want 6", n) + } + + // Read remaining. + got, err := io.ReadAll(zf) + if err != nil { + t.Fatal(err) + } + if string(got) != "world" { + t.Errorf("after Seek(6, SeekCurrent) + ReadAll: got %q, want %q", got, "world") + } +} + +func TestZstFile_SeekNegativeCurrentErrors(t *testing.T) { + original := []byte("hello world") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.zst": &fstest.MapFile{Data: compressed}, + } + + f, err := tfs.Open("test.zst") + if err != nil { + t.Fatal(err) + } + zf, err := newZSTFile(f) + if err != nil { + t.Fatal(err) + } + defer zf.Close() + + _, err = zf.Seek(-1, io.SeekCurrent) + if err == nil { + t.Error("Seek(-1, SeekCurrent) should return error") + } +} + +func TestZstFile_SeekEndNonZeroErrors(t *testing.T) { + original := []byte("hello") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.zst": &fstest.MapFile{Data: compressed}, + } + + f, err := tfs.Open("test.zst") + if err != nil { + t.Fatal(err) + } + zf, err := newZSTFile(f) + if err != nil { + t.Fatal(err) + } + defer zf.Close() + + _, err = zf.Seek(-1, io.SeekEnd) + if err == nil { + t.Error("Seek(-1, SeekEnd) should return error") + } +} + +func TestZstFile_ServeContent(t *testing.T) { + // Integration test: verify that zstFile works correctly with + // http.ServeContent, which uses Seek to determine content length. + original := []byte("hello world, served via http.ServeContent") + compressed := compressZstd(t, original) + + tfs := fstest.MapFS{ + "test.js.zst": &fstest.MapFile{Data: compressed}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, err := tfs.Open("test.js.zst") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + zf, err := newZSTFile(f) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer zf.Close() + http.ServeContent(w, r, "test.js", time.Time{}, zf) + }) + + r := httptest.NewRequest("GET", "/test.js", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if !bytes.Equal(w.Body.Bytes(), original) { + t.Errorf("body = %q, want %q", w.Body.String(), original) + } +} + +func TestNewZSTFile_CloseOnSuccess(t *testing.T) { + // Verify that newZSTFile produces a valid zstFile that, when closed, + // closes the underlying file. + original := []byte("hello") + compressed := compressZstd(t, original) + + closed := false + f := &fakeFile{ + data: compressed, + closeFn: func() error { closed = true; return nil }, + } + zf, err := newZSTFile(f) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(zf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("got %q, want %q", got, original) + } + zf.Close() + if !closed { + t.Error("underlying file was not closed") + } +} + +// fakeFile implements fs.File with controllable behavior for testing. +type fakeFile struct { + data []byte + offset int + closeFn func() error +} + +func (f *fakeFile) Read(p []byte) (int, error) { + if f.offset >= len(f.data) { + return 0, io.EOF + } + n := copy(p, f.data[f.offset:]) + f.offset += n + return n, nil +} + +func (f *fakeFile) Close() error { + if f.closeFn != nil { + return f.closeFn() + } + return nil +} + +func (f *fakeFile) Stat() (fs.FileInfo, error) { + return nil, fs.ErrInvalid +} diff --git a/cmd/build-webclient/build-webclient.go b/cmd/build-webclient/build-webclient.go index 949d9ef34..1baa34466 100644 --- a/cmd/build-webclient/build-webclient.go +++ b/cmd/build-webclient/build-webclient.go @@ -73,8 +73,8 @@ func build(toolDir, appDir string) error { if err := os.Remove(f); err != nil { log.Printf("Failed to cleanup %q: %v", f, err) } - // Removing intermediate ".br" version, we use ".gz" asset. - if err := os.Remove(f + ".br"); err != nil { + // Removing ".gz" version, we use the ".zst" asset. + if err := os.Remove(f + ".gz"); err != nil { log.Printf("Failed to cleanup %q: %v", f+".gz", err) } } diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index d04c66eba..5b931dbc3 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -125,6 +125,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/tstime/rate from tailscale.com/derp/derpserver tailscale.com/tsweb from tailscale.com/cmd/derper+ tailscale.com/tsweb/promvarz from tailscale.com/cmd/derper + tailscale.com/tsweb/tswebutil from tailscale.com/tsweb tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/types/appctype from tailscale.com/client/local tailscale.com/types/dnstype from tailscale.com/tailcfg+ diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index d801c0285..7a3de9bac 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -147,7 +147,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+ 💣 github.com/klauspost/compress/internal/le from github.com/klauspost/compress/huff0+ github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd - github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe + github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe+ github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd github.com/mailru/easyjson/buffer from github.com/mailru/easyjson/jwriter 💣 github.com/mailru/easyjson/jlexer from github.com/go-openapi/swag @@ -908,6 +908,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/rate from tailscale.com/wgengine/filter tailscale.com/tsweb from tailscale.com/util/eventbus + tailscale.com/tsweb/tswebutil from tailscale.com/client/web+ tailscale.com/tsweb/varz from tailscale.com/util/usermetric+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/bools from tailscale.com/tsnet+ diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt index 7b945dd77..91bb56c96 100644 --- a/cmd/stund/depaware.txt +++ b/cmd/stund/depaware.txt @@ -62,6 +62,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar tailscale.com/tailcfg from tailscale.com/version+ tailscale.com/tsweb from tailscale.com/cmd/stund+ tailscale.com/tsweb/promvarz from tailscale.com/cmd/stund + tailscale.com/tsweb/tswebutil from tailscale.com/tsweb tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/types/dnstype from tailscale.com/tailcfg tailscale.com/types/ipproto from tailscale.com/tailcfg diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index d83ac2710..cd2ac3d9c 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -126,6 +126,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli + github.com/klauspost/compress from github.com/klauspost/compress/zstd + github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0 + github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd + github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+ + 💣 github.com/klauspost/compress/internal/le from github.com/klauspost/compress/huff0+ + github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd + github.com/klauspost/compress/zstd from tailscale.com/client/web + github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd 💣 github.com/mattn/go-colorable from tailscale.com/cmd/tailscale/cli 💣 github.com/mattn/go-isatty from tailscale.com/cmd/tailscale/cli+ L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ @@ -239,6 +247,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/tstime/mono from tailscale.com/tstime/rate tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli tailscale.com/tsweb from tailscale.com/util/eventbus + tailscale.com/tsweb/tswebutil from tailscale.com/client/web+ tailscale.com/tsweb/varz from tailscale.com/util/usermetric+ tailscale.com/types/appctype from tailscale.com/client/local+ tailscale.com/types/dnstype from tailscale.com/tailcfg+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 48a7d0949..e1d5e78f8 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -153,7 +153,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/zstd+ 💣 github.com/klauspost/compress/internal/le from github.com/klauspost/compress/huff0+ github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd - github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe + github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe+ github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd github.com/kortschak/wol from tailscale.com/feature/wakeonlan LD github.com/kr/fs from github.com/pkg/sftp @@ -400,6 +400,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/rate from tailscale.com/wgengine/filter tailscale.com/tsweb from tailscale.com/util/eventbus + tailscale.com/tsweb/tswebutil from tailscale.com/client/web+ tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/bools from tailscale.com/wgengine/netlog diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 03f7e1f09..12252f698 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -120,7 +120,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+ 💣 github.com/klauspost/compress/internal/le from github.com/klauspost/compress/huff0+ github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd - github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe + github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe+ github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ @@ -309,6 +309,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/rate from tailscale.com/wgengine/filter tailscale.com/tsweb from tailscale.com/util/eventbus + tailscale.com/tsweb/tswebutil from tailscale.com/client/web+ tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/bools from tailscale.com/tsnet+ diff --git a/flake.nix b/flake.nix index 0dbf74e78..efb923f88 100644 --- a/flake.nix +++ b/flake.nix @@ -151,4 +151,4 @@ }); }; } -# nix-direnv cache busting line: sha256-Lr+5B0LEFk66WahPczRcfzH8rSL5Cc2qvNJuW6B0Llc= +# nix-direnv cache busting line: sha256-nbh8U6vPFal6/m/c4p7rX6LU6uuxAXAdzv9oUhD4bVg= diff --git a/go.mod b/go.mod index caa58b608..2640a0115 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 github.com/akutz/memconn v0.1.0 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa - github.com/andybalholm/brotli v1.1.0 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.41.0 @@ -98,7 +97,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a - github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 + github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e diff --git a/go.mod.sri b/go.mod.sri index 91887e63b..3fb004827 100644 --- a/go.mod.sri +++ b/go.mod.sri @@ -1 +1 @@ -sha256-Lr+5B0LEFk66WahPczRcfzH8rSL5Cc2qvNJuW6B0Llc= +sha256-nbh8U6vPFal6/m/c4p7rX6LU6uuxAXAdzv9oUhD4bVg= diff --git a/go.sum b/go.sum index 1f8195e47..f46994477 100644 --- a/go.sum +++ b/go.sum @@ -125,8 +125,6 @@ github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pO github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= @@ -1148,8 +1146,8 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a h1:TApskGPim53XY5WRt5hX4DnO8V6CmVoimSklryIoGMM= github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a/go.mod h1:+6WyG6kub5/5uPsMdYQuSti8i6F5WuKpFWLQnZt/Mms= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k= +github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= diff --git a/shell.nix b/shell.nix index a822b705a..6a3f0c10b 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-Lr+5B0LEFk66WahPczRcfzH8rSL5Cc2qvNJuW6B0Llc= +# nix-direnv cache busting line: sha256-nbh8U6vPFal6/m/c4p7rX6LU6uuxAXAdzv9oUhD4bVg= diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 8c81aa4d7..7890c618c 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -120,7 +120,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+ 💣 github.com/klauspost/compress/internal/le from github.com/klauspost/compress/huff0+ github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd - github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe + github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe+ github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ @@ -304,6 +304,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/rate from tailscale.com/wgengine/filter LDW tailscale.com/tsweb from tailscale.com/util/eventbus + LDW tailscale.com/tsweb/tswebutil from tailscale.com/client/web+ tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/bools from tailscale.com/tsnet+ diff --git a/tsweb/tsweb.go b/tsweb/tsweb.go index f464e7af2..0000c136b 100644 --- a/tsweb/tsweb.go +++ b/tsweb/tsweb.go @@ -26,10 +26,10 @@ "sync" "time" - "go4.org/mem" "tailscale.com/envknob" "tailscale.com/metrics" "tailscale.com/net/tsaddr" + "tailscale.com/tsweb/tswebutil" "tailscale.com/tsweb/varz" "tailscale.com/types/logger" "tailscale.com/util/ctxkey" @@ -93,26 +93,11 @@ func allowDebugAccessWithKey(r *http.Request) bool { } // AcceptsEncoding reports whether r accepts the named encoding -// ("gzip", "br", etc). +// ("gzip", "zstd", etc). +// +// Deprecated: use [tswebutil.AcceptsEncoding] instead. func AcceptsEncoding(r *http.Request, enc string) bool { - h := r.Header.Get("Accept-Encoding") - if h == "" { - return false - } - if !strings.Contains(h, enc) && !mem.ContainsFold(mem.S(h), mem.S(enc)) { - return false - } - remain := h - for len(remain) > 0 { - var part string - part, remain, _ = strings.Cut(remain, ",") - part = strings.TrimSpace(part) - part, _, _ = strings.Cut(part, ";") - if part == enc { - return true - } - } - return false + return tswebutil.AcceptsEncoding(r, enc) } // Protected wraps a provided debug handler, h, returning a Handler diff --git a/tsweb/tswebutil/tswebutil.go b/tsweb/tswebutil/tswebutil.go new file mode 100644 index 000000000..c854eb473 --- /dev/null +++ b/tsweb/tswebutil/tswebutil.go @@ -0,0 +1,36 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package tswebutil contains helper code used in various Tailscale webservers, +// without the tsweb kitchen sink. +package tswebutil + +import ( + "net/http" + "strings" + + "go4.org/mem" +) + +// AcceptsEncoding reports whether r accepts the named encoding +// ("gzip", "zstd", etc). +func AcceptsEncoding(r *http.Request, enc string) bool { + h := r.Header.Get("Accept-Encoding") + if h == "" { + return false + } + if !strings.Contains(h, enc) && !mem.ContainsFold(mem.S(h), mem.S(enc)) { + return false + } + remain := h + for len(remain) > 0 { + var part string + part, remain, _ = strings.Cut(remain, ",") + part = strings.TrimSpace(part) + part, _, _ = strings.Cut(part, ";") + if part == enc { + return true + } + } + return false +} diff --git a/tsweb/tswebutil/tswebutil_test.go b/tsweb/tswebutil/tswebutil_test.go new file mode 100644 index 000000000..2b59868f1 --- /dev/null +++ b/tsweb/tswebutil/tswebutil_test.go @@ -0,0 +1,37 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package tswebutil + +import ( + "net/http" + "testing" +) + +func TestAcceptsEncoding(t *testing.T) { + tests := []struct { + in, enc string + want bool + }{ + {"", "gzip", false}, + {"gzip", "gzip", true}, + {"foo,gzip", "gzip", true}, + {"foo, gzip", "gzip", true}, + {"foo, gzip ", "gzip", true}, + {"gzip, foo ", "gzip", true}, + {"gzip, foo ", "br", false}, + {"gzip, foo ", "fo", false}, + {"gzip;q=1.2, foo ", "gzip", true}, + {" gzip;q=1.2, foo ", "gzip", true}, + } + for i, tt := range tests { + h := make(http.Header) + if tt.in != "" { + h.Set("Accept-Encoding", tt.in) + } + got := AcceptsEncoding(&http.Request{Header: h}, tt.enc) + if got != tt.want { + t.Errorf("%d. got %v; want %v", i, got, tt.want) + } + } +} diff --git a/util/precompress/precompress.go b/util/precompress/precompress.go index 80aed3682..514f26168 100644 --- a/util/precompress/precompress.go +++ b/util/precompress/precompress.go @@ -16,13 +16,13 @@ "path" "path/filepath" - "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" "golang.org/x/sync/errgroup" - "tailscale.com/tsweb" + "tailscale.com/tsweb/tswebutil" ) -// PrecompressDir compresses static assets in dirPath using Gzip and Brotli, so -// that they can be later served with OpenPrecompressedFile. +// PrecompressDir compresses static assets in dirPath using Gzip and Zstandard, +// so that they can be later served with OpenPrecompressedFile. func PrecompressDir(dirPath string, options Options) error { var eg errgroup.Group err := fs.WalkDir(os.DirFS(dirPath), ".", func(p string, d fs.DirEntry, err error) error { @@ -63,13 +63,13 @@ type Options struct { // OpenPrecompressedFile opens a file from fs, preferring compressed versions // generated by PrecompressDir if possible. func OpenPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) { - if tsweb.AcceptsEncoding(r, "br") { - if f, err := fs.Open(path + ".br"); err == nil { - w.Header().Set("Content-Encoding", "br") + if tswebutil.AcceptsEncoding(r, "zstd") { + if f, err := fs.Open(path + ".zst"); err == nil { + w.Header().Set("Content-Encoding", "zstd") return f, nil } } - if tsweb.AcceptsEncoding(r, "gzip") { + if tswebutil.AcceptsEncoding(r, "gzip") { if f, err := fs.Open(path + ".gz"); err == nil { w.Header().Set("Content-Encoding", "gzip") return f, nil @@ -104,13 +104,14 @@ func Precompress(path string, options Options) error { if err != nil { return err } - brotliLevel := brotli.BestCompression + zstdLevel := zstd.WithEncoderLevel(zstd.SpeedBestCompression) if options.FastCompression { - brotliLevel = brotli.BestSpeed + zstdLevel = zstd.WithEncoderLevel(zstd.SpeedFastest) } return writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) { - return brotli.NewWriterLevel(w, brotliLevel), nil - }, path+".br", fi.Mode()) + // Per RFC 8878, encoders should avoid window sizes larger than 8MB, which is the max that Chrome accepts. + return zstd.NewWriter(w, zstdLevel, zstd.WithWindowSize(8<<20)) + }, path+".zst", fi.Mode()) } func writeCompressed(contents []byte, compressedWriterCreator func(io.Writer) (io.WriteCloser, error), outputPath string, outputMode fs.FileMode) error {