Files
tailscale/tsweb/compserve/compserve.go
T
James Tucker 816b661f0d client/web, tsweb/compserve: serve index.html for the root path
GET / in the client/web assets handler normalized to an empty fs
path. The empty name is invalid, and file systems wrapped in fs.Sub
reject it with fs.ErrInvalid instead of fs.ErrNotExist. The
index.html fallback only triggers on fs.ErrNotExist, so it never ran
and the request failed with a 500 "internal error". This broke the
corp smoke test that curls http://100.100.100.100/.

Normalize the empty path to index.html before serving. Also make
compserve.ServeFile return fs.ErrNotExist for invalid paths, per its
documented contract, instead of leaking fs.ErrInvalid.

Both layers have regression tests verified to fail without the fix.

Updates tailscale/corp#20099
Updates #12170

Signed-off-by: James Tucker <james@tailscale.com>
2026-09-21 15:42:33 -07:00

313 lines
10 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package compserve serves static files with content negotiation for
// precompressed variants, and provides live compression for dynamic
// responses.
//
// An [Encoding] describes a content coding's wire token and where its
// precompressed variants live.
package compserve
import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
)
// Encoding describes a single content coding (e.g. zstd) that this package
// can negotiate and serve.
type Encoding struct {
// Token is the Content-Encoding token for this coding, e.g. "zstd".
// Comparison against Accept-Encoding is case-insensitive.
Token string
// Ext is the file name extension (including the leading dot) of
// precompressed variants as produced by util/precompress, e.g. ".zst".
Ext string
// Decompress, if non-nil, wraps f and yields its decompressed content,
// taking ownership of f: the returned ReadCloser's Close closes f, and
// if Decompress returns an error it must have closed f already.
//
// When Decompress is set, a precompressed variant can also be served to
// clients that do not accept Token by decompressing it on the fly, so a
// file system shipping only precompressed variants can still serve
// every client. If nil, the variant is only served passthrough to
// clients that accept Token.
Decompress func(f fs.File) (io.ReadCloser, error)
// Compress, if non-nil, wraps w in a streaming compressor used by
// [CompressWriter] for live (non-precompressed) response compression:
// everything written to the returned WriteCloser is compressed and
// forwarded to w, and its Close finishes the stream and releases any
// pooled resources. Implementations should use a fast compression
// mode: live compression runs per request and must not add meaningful
// latency. If nil, the encoding is not offered for live compression.
Compress func(w io.Writer) (io.WriteCloser, error)
}
// DefaultEncodings is the set of encodings used when Options does not
// specify any: zstd, then gzip for compatibility with file systems built
// before zstd-only asset generation. Ties prefer the earlier encoding.
var DefaultEncodings = []Encoding{Zstd, Gzip}
// Options configures ServeFile.
type Options struct {
// Encodings lists the precompressed variants available in the file
// system, in preference order. The zero value selects
// [DefaultEncodings].
Encodings []Encoding
}
// ServeFile serves the file at path from fsys to w, negotiating a
// precompressed variant when the client accepts one and the variant exists.
// Clients that do not accept any offered encoding are served the raw file;
// if the raw file is absent (e.g. a file system shipping only
// precompressed variants), a variant with a Decompress hook is served
// decompressed instead.
//
// ServeFile sets Vary: Accept-Encoding on every response and serves the
// content via http.ServeContent with the file's modification time (for
// embedded file systems, wrap fsys in [tailscale.com/tsweb/vcstime.FS] to
// supply one). Callers may set additional headers (e.g. Cache-Control)
// before calling. If ServeFile returns an error it has not written a
// response body, so callers may adjust headers and retry with a different
// path. If no representation of the file exists, the returned error wraps
// fs.ErrNotExist.
func ServeFile(w http.ResponseWriter, r *http.Request, fsys fs.FS, path string, opts Options) error {
encodings := opts.Encodings
if encodings == nil {
encodings = DefaultEncodings
}
if !fs.ValidPath(path) {
// An invalid name can never exist. Report not-found rather than
// the fs.ErrInvalid that validating file systems such as fs.Sub
// return, so callers can fall back to another path.
return &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
}
// The served representation (an encoded variant or identity) is
// selected from Accept-Encoding, so caches must treat the response as
// varying even when the negotiated representation is identity.
addVary(w.Header(), "Accept-Encoding")
// Prefer serving an accepted variant passthrough: no server CPU cost.
if enc := negotiate(r.Header.Get("Accept-Encoding"), encodings); enc != nil {
if f, err := fsys.Open(path + enc.Ext); err == nil {
rs, ok := f.(io.ReadSeeker)
if !ok {
// Fall through to the raw file; it may still be servable.
f.Close()
} else {
w.Header().Set("Content-Encoding", enc.Token)
return serveContent(w, r, path, fileModTime(f), rs)
}
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
}
// Serve the raw file if it exists: it is identical to what transcoding
// a variant would produce, without the CPU cost.
f, err := fsys.Open(path)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
if err == nil {
rs, ok := f.(io.ReadSeeker)
if !ok {
f.Close()
return fmt.Errorf("%s is not seekable", path)
}
return serveContent(w, r, path, fileModTime(f), rs)
}
// The raw file does not exist: transcode a precompressed variant to
// identity for clients that do not accept it.
for _, e := range encodings {
if e.Decompress == nil {
continue
}
f, err := fsys.Open(path + e.Ext)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
return err
}
modTime := fileModTime(f)
tf := &transcodedFile{fsys: fsys, path: path + e.Ext, dec: e.Decompress}
if err := tf.start(f); err != nil {
return err
}
return serveContent(w, r, path, modTime, tf)
}
return &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
}
// fileModTime reports f's modification time, or the zero time (which
// disables conditional requests in http.ServeContent) if f does not stat.
func fileModTime(f fs.File) time.Time {
fi, err := f.Stat()
if err != nil {
return time.Time{}
}
return fi.ModTime()
}
// serveContent serves rs via http.ServeContent, closing it if it is an
// io.Closer.
func serveContent(w http.ResponseWriter, r *http.Request, name string, modTime time.Time, rs io.ReadSeeker) error {
http.ServeContent(w, r, name, modTime, rs)
if c, ok := rs.(io.Closer); ok {
// The response body has been written; a close error is not
// actionable.
c.Close()
}
return nil
}
// negotiate returns the encoding from offered that the given
// Accept-Encoding header value accepts with the highest quality value, or
// nil if it accepts none of them. Ties prefer the earlier entry in offered.
func negotiate(ae string, offered []Encoding) *Encoding {
if ae == "" {
return nil
}
exact, wildcard, hasWildcard := parseAcceptEncoding(ae)
var best *Encoding
bestQ := 0.0
for i := range offered {
enc := &offered[i]
q, ok := exact[strings.ToLower(enc.Token)]
if !ok && hasWildcard {
q, ok = wildcard, true
}
// A quality of zero explicitly rejects the coding, and equal
// qualities prefer the earlier entry in offered.
if ok && q > bestQ {
best, bestQ = enc, q
}
}
return best
}
// parseAcceptEncoding parses an Accept-Encoding header value into the
// quality values of the explicitly named (lowercased) coding tokens and of
// the "*" wildcard, if present. Quality values default to 1 per RFC 9110,
// and a malformed quality value rejects the coding.
func parseAcceptEncoding(h string) (exact map[string]float64, wildcard float64, hasWildcard bool) {
exact = make(map[string]float64)
for _, part := range strings.Split(h, ",") {
token, params, _ := strings.Cut(part, ";")
token = strings.ToLower(strings.TrimSpace(token))
if token == "" {
continue
}
q := 1.0
for _, p := range strings.FieldsFunc(params, func(r rune) bool { return r == ';' }) {
name, value, _ := strings.Cut(strings.TrimSpace(p), "=")
if !strings.EqualFold(name, "q") {
continue
}
if v, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil {
q = v
} else {
q = 0
}
break
}
if token == "*" {
wildcard, hasWildcard = q, true
continue
}
exact[token] = q
}
return exact, wildcard, hasWildcard
}
// transcodedFile serves the decompressed content of a precompressed file to
// clients that do not accept its encoding. It implements io.ReadSeeker so
// that it can be used with http.ServeContent; seeking re-opens the
// underlying file and decompresses from the start, so it is only suitable
// for small files where the double decompression cost is acceptable.
type transcodedFile struct {
fsys fs.FS
path string // path of the compressed file in fsys
dec func(f fs.File) (io.ReadCloser, error) // decompressor for that file
rc io.ReadCloser // current decompressed stream
pos int64 // current position in the decompressed stream
}
// start replaces the decompressed stream with one reading f (whose
// ownership is taken) from its start.
func (t *transcodedFile) start(f fs.File) error {
rc, err := t.dec(f)
if err != nil {
return err
}
t.closeStream()
t.rc, t.pos = rc, 0
return nil
}
// restart replaces the decompressed stream with a fresh one reading the
// underlying file from its start.
func (t *transcodedFile) restart() error {
f, err := t.fsys.Open(t.path)
if err != nil {
return err
}
return t.start(f)
}
func (t *transcodedFile) closeStream() {
if t.rc != nil {
t.rc.Close()
t.rc = nil
}
}
func (t *transcodedFile) Read(p []byte) (int, error) {
if t.rc == nil {
return 0, fs.ErrClosed
}
n, err := t.rc.Read(p)
t.pos += int64(n)
return n, err
}
func (t *transcodedFile) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
if err := t.restart(); err != nil {
return 0, err
}
_, err := io.CopyN(io.Discard, t, offset)
return t.pos, err
case io.SeekCurrent:
if offset < 0 {
return 0, fmt.Errorf("negative offset for SeekCurrent: %w", fs.ErrInvalid)
}
_, err := io.CopyN(io.Discard, t, offset)
return t.pos, err
case io.SeekEnd:
if offset != 0 {
return 0, fmt.Errorf("non-zero offset for SeekEnd: %w", fs.ErrInvalid)
}
_, err := io.Copy(io.Discard, t)
return t.pos, err
}
return 0, fs.ErrInvalid
}
func (t *transcodedFile) Close() error {
t.closeStream()
return nil
}