This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package compserve
import (
"bufio"
"io"
"net"
"net/http"
"strings"
"tailscale.com/util/httpm"
)
// CompressWriter wraps w so that its response body is transparently
// compressed, incrementally as the handler writes it, with the first
// encoding from encodings (default: [DefaultEncodings]) that offers live
// compression and that r accepts.
//
// The caller must call Close exactly once after the wrapped handler
// returns, to finish any compressed stream:
//
// cw := compserve.CompressWriter(w, r)
// defer cw.Close()
// handler.ServeHTTP(cw, r)
func CompressWriter(w http.ResponseWriter, r *http.Request, encodings ...Encoding) *compressWriter {
if len(encodings) == 0 {
encodings = DefaultEncodings
}
var encs []Encoding
for _, e := range encodings {
if e.Compress != nil {
encs = append(encs, e)
}
}
// Compression applies to plain responses of standard body-carrying
// requests: not HEAD (no body), not CONNECT (tunnel), not protocol
// upgrades, not range requests (range semantics apply to the
// uncompressed representation).
safe := (r.Method == httpm.GET || r.Method == httpm.POST) &&
r.Header.Get("Upgrade") == "" &&
r.Header.Get("Range") == ""
return &compressWriter{
ResponseWriter: w,
encs: encs,
ae: r.Header.Get("Accept-Encoding"),
safe: safe,
}
}
// compressWriter is a ResponseWriter wrapper that compresses the response
// body of dynamic handlers. See [CompressWriter].
//
// The body is compressed as it is written, with no buffering. The compress
// decision is made once from the response headers, sniffing the first
// write if the handler sets no Content-Type.
//
// Compressed responses are chunked: the compressed size is not known up
// front, so a handler-set Content-Length is removed. zstd framing may add
// a few bytes to incompressible data.
//
// Responses pass through uncompressed when they already have a
// Content-Encoding, on non-GET/POST requests (HEAD, CONNECT, upgrades,
// range requests), non-200 statuses, or incompressible content types (see
// compressibleContentType).
//
// Every response gets Vary: Accept-Encoding. Strong ETags are weakened:
// validators are representation-specific.
type compressWriter struct {
http.ResponseWriter
encs []Encoding
ae string // Accept-Encoding header of the request
safe bool // request is safe to compress the response of
code int // stashed status code; 0 until the first WriteHeader or Write
decided bool // whether the compress-or-passthrough decision was made
compress bool // decision outcome: stream through the compressor
hijacked bool // connection hijacked; the wrapper is out of the path
cw io.WriteCloser
}
// Unwrap exposes the wrapped ResponseWriter to http.ResponseController for
// deadline support; Flush and Hijack stay handled by the wrapper.
func (c *compressWriter) Unwrap() http.ResponseWriter {
return c.ResponseWriter
}
// Hijack switches the connection out from under the wrapper: after a
// successful hijack the connection is raw, no compression applies, and
// wrapper writes fail. Handlers reach it via http.NewResponseController or
// a direct type assertion, so hijacked paths carry no io overhead from
// this wrapper. ResponseController.Hijack unwraps through further
// middleware below this wrapper.
func (c *compressWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if !c.decided {
// No response was produced; the handler will speak for itself on
// the hijacked connection. Do not attach a compressor or send
// response headers.
c.decided = true
} else if c.compress {
// Compressed bytes were already streamed; finish the frame and
// return the encoder to its pool before the connection leaves our
// control.
err := c.cw.Close()
c.compress, c.cw = false, nil
if err != nil {
return nil, nil, err
}
}
c.hijacked = true
return http.NewResponseController(c.ResponseWriter).Hijack()
}
// WriteHeader stashes the status code, deciding early if the response is
// already disqualified from compression. See [CompressWriter].
func (c *compressWriter) WriteHeader(code int) {
if c.hijacked {
return // the handler owns the connection
}
if c.code != 0 {
return // ignore superfluous calls, like net/http does
}
c.code = code
if !c.mayCompress() {
c.decide(nil)
}
}
// mayCompress reports whether the stashed response could still be
// compressed based on the request and the handler's headers alone.
func (c *compressWriter) mayCompress() bool {
if len(c.encs) == 0 || c.code != http.StatusOK || !c.safe {
return false
}
// Never double-encode (e.g. precompressed variants served by
// [ServeFile] through this wrapper).
return c.Header().Get("Content-Encoding") == ""
}
// Write passes the body through the streaming compressor or the wrapped
// writer, deciding on the first write. See [CompressWriter].
func (c *compressWriter) Write(p []byte) (int, error) {
if c.hijacked {
return 0, http.ErrHijacked
}
if !c.decided {
c.decide(p)
}
if c.compress {
return c.cw.Write(p)
}
return c.ResponseWriter.Write(p)
}
// Flush flushes buffered compressed data and the underlying writer. See
// [CompressWriter].
func (c *compressWriter) Flush() {
if !c.decided {
c.decide(nil)
}
if c.compress {
type flusher interface{ Flush() }
if f, ok := c.cw.(flusher); ok {
f.Flush()
}
}
if f, ok := c.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Close finishes any compressed stream and must be called exactly once
// after the wrapped handler returns. See [CompressWriter].
func (c *compressWriter) Close() error {
if c.hijacked {
return nil // the connection left our control in Hijack
}
if !c.decided {
c.decide(nil)
}
if c.compress {
return c.cw.Close()
}
return nil
}
// decide makes the compress-or-passthrough decision from the request, the
// response headers, and (when no Content-Type is set) the first body
// bytes. It forwards the response headers.
func (c *compressWriter) decide(first []byte) {
c.decided = true
if c.code == 0 {
c.code = http.StatusOK
}
ct := c.Header().Get("Content-Type")
sniffed := false
if ct == "" && first != nil {
// Mirror net/http's sniffing of the first bytes.
ct = http.DetectContentType(first)
sniffed = true
}
if !c.mayCompress() || ct == "" || !compressibleContentType(ct) {
c.commitPassthrough()
return
}
enc := negotiate(c.ae, c.encs)
if enc == nil {
c.commitPassthrough()
return
}
cw, err := enc.Compress(c.ResponseWriter)
if err != nil {
c.commitPassthrough()
return
}
h := c.Header()
if sniffed {
// net/http would otherwise sniff the compressed bytes and label
// the response application/octet-stream.
h.Set("Content-Type", ct)
}
// The handler's Content-Length describes the uncompressed
// representation; the compressed length is unknown, so the response is
// chunked.
h.Del("Content-Length")
h.Set("Content-Encoding", enc.Token)
// Strong validators must be representation-specific.
if etag := h.Get("ETag"); etag != "" && !strings.HasPrefix(etag, "W/") {
h.Set("ETag", "W/"+etag)
}
addVary(h, "Accept-Encoding")
c.cw = cw
c.compress = true
c.ResponseWriter.WriteHeader(c.code)
}
// commitPassthrough forwards the stashed status to the wrapped writer;
// subsequent writes stream through directly.
func (c *compressWriter) commitPassthrough() {
if c.code == 0 {
c.code = http.StatusOK
}
addVary(c.Header(), "Accept-Encoding")
c.ResponseWriter.WriteHeader(c.code)
}
// compressibleContentType reports whether the given content type is worth
// live compression. Most types are assumed compressible: only content that
// is already compressed by construction is excluded (media codecs, archive
// formats), except raw media types that are not, listed below.
// +json/+xml suffix types are always compressed.
func compressibleContentType(ct string) bool {
ct = strings.ToLower(ct)
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
if strings.HasSuffix(ct, "+json") || strings.HasSuffix(ct, "+xml") {
return true
}
switch {
case strings.HasPrefix(ct, "audio/"):
return rawAudioContentType(ct)
case strings.HasPrefix(ct, "video/"):
return rawVideoContentType(ct)
}
switch ct {
case "application/zip",
"application/gzip",
"application/x-gzip",
"application/x-bzip2",
"application/x-xz",
"application/zstd",
"application/x-zstd",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-compress",
"application/x-snappy-framed",
"application/x-lz4",
// Archive formats that are zip or other compressed containers.
"application/java-archive", // jar: zip
"application/vnd.android.package-archive", // apk: zip
"application/x-apple-diskimage": // dmg: compressed
return false
}
return true
}
// rawAudioContentType reports whether ct is an uncompressed audio format
// worth compressing despite audio/ being excluded by default: audio content
// is almost always compressed by its codec, but raw PCM is not.
func rawAudioContentType(ct string) bool {
switch ct {
case "audio/wav", "audio/wave", "audio/x-wav", "audio/vnd.wave", // WAV: PCM
"audio/pcm",
"audio/l16", "audio/l24": // raw linear PCM, RFC 2586
return true
}
return false
}
// rawVideoContentType reports whether ct is an uncompressed video format
// worth compressing despite video/ being excluded by default: video content
// is almost always compressed by its codec, but raw frame data is not.
func rawVideoContentType(ct string) bool {
switch ct {
case "video/raw", "video/x-raw", "video/x-raw-yuv",
"video/x-y4m": // YUV4MPEG2
return true
}
return false
}
// addVary adds token to the response's Vary header, preserving any values
// already set by the handler.
func addVary(h http.Header, token string) {
for _, vary := range h.Values("Vary") {
for _, t := range strings.Split(vary, ",") {
if strings.EqualFold(strings.TrimSpace(t), token) {
return
}
}
}
h.Add("Vary", token)
}
+312
View File
@@ -0,0 +1,312 @@
// 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
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package compserve
import (
"compress/gzip"
"io"
"io/fs"
)
// Gzip serves gzip-precompressed variants ("*.gz"). It exists for
// transitional compatibility with file systems built before zstd-only
// asset generation, such as web-client-prebuilt modules published before
// cmd/build-webclient stopped writing gzip; file systems built since
// contain no .gz variants and never select it.
var Gzip = Encoding{
Token: "gzip",
Ext: ".gz",
Decompress: decompressGzip,
}
// decompressGzip wraps f in a streaming gzip decompressor, taking
// ownership of f.
func decompressGzip(f fs.File) (io.ReadCloser, error) {
zr, err := gzip.NewReader(f)
if err != nil {
f.Close()
return nil, err
}
return &gzipFile{f: f, Reader: zr}, nil
}
// gzipFile is a gzip.Reader reading from f; Close closes both.
type gzipFile struct {
f fs.File
*gzip.Reader
}
func (g *gzipFile) Close() error {
g.Reader.Close()
return g.f.Close()
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package compserve
import (
"io"
"io/fs"
"github.com/klauspost/compress/zstd"
"tailscale.com/util/zstdframe"
)
// Zstd serves zstd-precompressed variants ("*.zst", as generated by
// util/precompress). [Encoding.Decompress] lets ServeFile transcode the
// variant for clients that do not accept zstd.
var Zstd = Encoding{
Token: "zstd",
Ext: ".zst",
Decompress: decompressZstd,
Compress: compressZstdLive,
}
// compressZstdLive wraps w in a streaming zstd compressor using the fastest
// compression level and a small window suitable for low-latency live
// compression. Close on the returned writer finishes the zstd frame and
// returns the encoder to the shared pool.
func compressZstdLive(w io.Writer) (io.WriteCloser, error) {
enc, put := zstdframe.GetStreamingEncoder(
zstdframe.FastestCompression,
zstdframe.MaxWindowSize(64<<10),
)
// Encoder.Reset (streaming) cannot fail; only Decoder.Reset can.
enc.Reset(w)
return &pooledZstdWriter{Encoder: enc, put: put}, nil
}
// pooledZstdWriter is a zstd.Encoder writing to the underlying writer;
// Close finishes the frame and returns the encoder to the shared pool.
type pooledZstdWriter struct {
put func()
closed bool
*zstd.Encoder
}
func (p *pooledZstdWriter) Close() error {
if p.closed {
return nil
}
p.closed = true
err := p.Encoder.Close()
p.put()
return err
}
// decompressZstd wraps f in a streaming zstd decompressor backed by the
// shared decoder pool in util/zstdframe (concurrency=1, so streaming decode
// neither spawns goroutines nor retains unbounded memory). It takes
// ownership of f.
func decompressZstd(f fs.File) (io.ReadCloser, error) {
dec, put := zstdframe.GetDecoder()
if err := dec.Reset(f); err != nil {
put()
f.Close()
return nil, err
}
return &pooledZstdReader{f: f, put: put, Decoder: dec}, nil
}
// pooledZstdReader is a zstd.Decoder reading from f; Close returns the
// decoder to the shared pool and closes f.
type pooledZstdReader struct {
f fs.File
put func()
*zstd.Decoder
}
func (p *pooledZstdReader) Close() error {
p.put()
return p.f.Close()
}
+9 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsweb
@@ -41,6 +41,11 @@ type DebugHandler struct {
// tailscale.com/tsweb/promvarz package to enable this feature.
var PrometheusHandler feature.Hook[func(*DebugHandler)]
// hookRuntimeMetrics registers the Go runtime/metrics debug page on a
// DebugHandler. It is set by runtimemetrics.go, which is excluded from
// js/wasm builds to keep them small.
var hookRuntimeMetrics feature.Hook[func(*DebugHandler)]
// Debugger returns the DebugHandler registered on mux at /debug/,
// creating it if necessary.
func Debugger(mux *http.ServeMux) *DebugHandler {
@@ -57,6 +62,9 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
ret.KVFunc("Uptime", func() any { return varz.Uptime() })
ret.KV("Version", version.Long())
ret.Handle("vars", "Metrics (Go)", expvar.Handler())
if f, ok := hookRuntimeMetrics.GetOk(); ok {
f(ret)
}
if PrometheusHandler.IsSet() {
PrometheusHandler.Get()(ret)
} else {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsweb
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !wasm
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js && wasm
+3 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsweb
@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"tailscale.com/tstime"
"tailscale.com/util/ctxkey"
"tailscale.com/util/rands"
)
@@ -47,7 +48,7 @@ func GenerateRequestID() RequestID {
// Now we return "REQ-2" version, where the "2" doubles as the year 2YYY
// in a leading date.
now := time.Now().UTC()
return RequestID("REQ-" + now.Format("20060102150405") + rands.HexString(16))
return RequestID("REQ-" + now.Format(tstime.NumericDateTime) + rands.HexString(16))
}
// SetRequestID is an HTTP middleware that injects a RequestID in the
+286
View File
@@ -0,0 +1,286 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !wasm
package tsweb
import (
"bufio"
"encoding/json"
"fmt"
"html"
"io"
"math"
"net/http"
"net/url"
"runtime/metrics"
"slices"
"strconv"
"strings"
"sync"
)
// runtimeMetricsDescs returns the descriptions of all supported Go
// runtime/metrics, sorted by name.
var runtimeMetricsDescs = sync.OnceValue(func() []metrics.Description {
descs := metrics.All()
slices.SortFunc(descs, func(a, b metrics.Description) int {
return strings.Compare(a.Name, b.Name)
})
return descs
})
// runtimeMetricsSlug is the path under /debug/ where
// runtimeMetricsHandler is registered, both as an exact path and as a
// subtree (with a trailing slash).
const runtimeMetricsSlug = "runtime-metrics"
// runtimeMetricsPath is the full path of the runtime/metrics debug page.
const runtimeMetricsPath = "/debug/" + runtimeMetricsSlug
func init() {
hookRuntimeMetrics.Set(addRuntimeMetricsHandlers)
}
// addRuntimeMetricsHandlers registers runtimeMetricsHandler on d, both
// at its base path and as a subtree for the per-metric path form.
func addRuntimeMetricsHandlers(d *DebugHandler) {
d.Handle(runtimeMetricsSlug, "Metrics (Go runtime/metrics)", http.HandlerFunc(runtimeMetricsHandler))
d.HandleSilent(runtimeMetricsSlug+"/", http.HandlerFunc(runtimeMetricsHandler))
}
// runtimeMetricsHandler serves Go runtime/metrics.
//
// At its base path without a "name" query parameter it serves an index
// of all supported metric names and their descriptions, without reading
// any values. The index is HTML for browsers (requests whose Accept
// header includes text/html) and plain text otherwise; the "format"
// query parameter ("html" or "text") overrides that choice.
//
// With one or more "name" query parameters it reads the named metrics
// and returns them as a JSON object keyed by metric name. A name
// ending in "*" matches all metrics with that prefix, so "name=*"
// returns everything and "name=/gc/*" returns the GC metrics.
//
// A metric name may also be appended to the base path, as in
// /debug/runtime-metrics/gc/heap/allocs:bytes, in which case the
// response is that metric's bare JSON value without the object wrapper.
//
// Histograms are objects with "counts" and "buckets" arrays; infinite
// bucket boundaries are encoded as the strings "-Inf" and "+Inf".
func runtimeMetricsHandler(w http.ResponseWriter, r *http.Request) {
if rest, ok := strings.CutPrefix(r.URL.Path, runtimeMetricsPath+"/"); ok {
serveRuntimeMetricValue(w, "/"+rest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if names := r.Form["name"]; len(names) > 0 {
serveRuntimeMetricsJSON(w, names)
return
}
format := r.FormValue("format")
if format == "" {
if strings.Contains(r.Header.Get("Accept"), "text/html") {
format = "html"
} else {
format = "text"
}
}
switch format {
case "html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
case "text":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
default:
http.Error(w, "unknown format; want html or text", http.StatusBadRequest)
return
}
bw := bufio.NewWriter(w)
defer bw.Flush()
if format == "html" {
writeRuntimeMetricsIndexHTML(bw)
} else {
writeRuntimeMetricsIndexText(bw)
}
}
// serveRuntimeMetricsJSON reads the metrics matching names (exact
// names, or prefixes ending in "*") and writes them as a JSON object.
func serveRuntimeMetricsJSON(w http.ResponseWriter, names []string) {
descs := runtimeMetricsDescs()
var samples []metrics.Sample
seen := map[string]bool{}
add := func(name string) {
if !seen[name] {
seen[name] = true
samples = append(samples, metrics.Sample{Name: name})
}
}
for _, name := range names {
if prefix, ok := strings.CutSuffix(name, "*"); ok {
for _, d := range descs {
if strings.HasPrefix(d.Name, prefix) {
add(d.Name)
}
}
continue
}
if !runtimeMetricExists(name) {
http.Error(w, fmt.Sprintf("unknown runtime metric %q", name), http.StatusNotFound)
return
}
add(name)
}
metrics.Read(samples)
obj := make(map[string]any, len(samples))
for _, s := range samples {
obj[s.Name] = runtimeMetricValueJSON(s.Value)
}
writeJSON(w, obj)
}
// serveRuntimeMetricValue reads the single metric name and writes its
// bare JSON value.
func serveRuntimeMetricValue(w http.ResponseWriter, name string) {
if !runtimeMetricExists(name) {
http.Error(w, fmt.Sprintf("unknown runtime metric %q", name), http.StatusNotFound)
return
}
samples := []metrics.Sample{{Name: name}}
metrics.Read(samples)
writeJSON(w, runtimeMetricValueJSON(samples[0].Value))
}
func runtimeMetricExists(name string) bool {
return slices.ContainsFunc(runtimeMetricsDescs(), func(d metrics.Description) bool { return d.Name == name })
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", "\t")
e.Encode(v)
}
// runtimeMetricValueJSON converts v to a value that encoding/json can
// encode. Infinite and NaN floats, which JSON cannot represent, become
// strings.
func runtimeMetricValueJSON(v metrics.Value) any {
switch v.Kind() {
case metrics.KindUint64:
return v.Uint64()
case metrics.KindFloat64:
return jsonFloat(v.Float64())
case metrics.KindFloat64Histogram:
h := v.Float64Histogram()
buckets := make([]any, len(h.Buckets))
for i, b := range h.Buckets {
buckets[i] = jsonFloat(b)
}
return map[string]any{
"counts": h.Counts,
"buckets": buckets,
}
default:
return nil
}
}
func jsonFloat(f float64) any {
if math.IsInf(f, 0) || math.IsNaN(f) {
return strconv.FormatFloat(f, 'g', -1, 64)
}
return f
}
func runtimeMetricKind(k metrics.ValueKind) string {
switch k {
case metrics.KindUint64:
return "uint64"
case metrics.KindFloat64:
return "float64"
case metrics.KindFloat64Histogram:
return "histogram"
default:
return fmt.Sprintf("kind(%d)", k)
}
}
func writeRuntimeMetricsIndexText(w io.Writer) {
fmt.Fprintf(w, "# Go runtime/metrics.\n")
fmt.Fprintf(w, "# Fetch one bare JSON value with %s/NAME, e.g. %s/gc/heap/allocs:bytes.\n", runtimeMetricsPath, runtimeMetricsPath)
fmt.Fprintf(w, "# Fetch a JSON object of values with %s?name=NAME (repeatable).\n", runtimeMetricsPath)
fmt.Fprintf(w, "# A trailing * on a name matches by prefix; name=* returns all values.\n\n")
for _, d := range runtimeMetricsDescs() {
fmt.Fprintf(w, "%s\n", d.Name)
for line := range strings.Lines(d.Description) {
fmt.Fprintf(w, " # %s\n", strings.TrimRight(line, "\n"))
}
fmt.Fprintf(w, " # %s", runtimeMetricKind(d.Kind))
if d.Cumulative {
fmt.Fprintf(w, ", cumulative")
}
fmt.Fprintf(w, "\n\n")
}
}
func writeRuntimeMetricsIndexHTML(w io.Writer) {
// valueURL returns the URL serving name's value. Exact metric names
// are appended to the path and yield a bare JSON value. Wildcards
// use the name query parameter and yield a JSON object.
valueURL := func(name string) string {
if strings.HasSuffix(name, "*") {
return runtimeMetricsPath + "?name=" + url.QueryEscape(name)
}
return runtimeMetricsPath + name
}
fmt.Fprintf(w, `<html><head><title>runtime/metrics</title>
<style>
body { font-family: monospace; }
table { border-collapse: collapse; }
td, th { border: 1px solid #ccc; padding: 2px 6px; vertical-align: top; text-align: left; }
.desc { color: #555; }
tr.wild td { background: #eee; }
</style></head><body>
<h1>Go runtime/metrics</h1>
<p>See <a href="https://pkg.go.dev/runtime/metrics">runtime/metrics</a>.
Click a name to read its bare value as JSON. Values are not read for this index.
Append <code>?format=text</code> for a plain text index.
Fetch a JSON object of several values with repeated <code>?name=</code> parameters; a trailing
<code>*</code> matches by prefix (<a href="%s">all values</a>, <a href="%s">GC values</a>).</p>
<table>
<tr><th>Name</th><th>Kind</th><th>Description</th></tr>
`, valueURL("*"), valueURL("/gc/*"))
var lastDir string
for _, d := range runtimeMetricsDescs() {
// Before each run of metrics sharing a directory, emit a row
// linking each ancestor directory to its wildcard query, so
// "/gc/heap/allocs:bytes" is preceded by links to "/gc/*" and
// "/gc/heap/*".
if dir := d.Name[:strings.LastIndex(d.Name, "/")+1]; dir != lastDir {
lastDir = dir
io.WriteString(w, `<tr class="wild"><td colspan="3">`)
for i := 1; i < len(dir); i++ {
if dir[i] != '/' {
continue
}
pat := dir[:i+1] + "*"
fmt.Fprintf(w, ` <a href="%s">%s</a>`, valueURL(pat), html.EscapeString(pat))
}
io.WriteString(w, "</td></tr>\n")
}
kind := runtimeMetricKind(d.Kind)
if d.Cumulative {
kind += ", cumulative"
}
fmt.Fprintf(w, `<tr><td><a href="%s">%s</a></td><td>%s</td><td class="desc">%s</td></tr>`+"\n",
valueURL(d.Name), html.EscapeString(d.Name), kind, html.EscapeString(d.Description))
}
io.WriteString(w, "</table></body></html>\n")
}
+75 -6
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package tsweb contains code used in various Tailscale webservers.
@@ -13,6 +13,8 @@ import (
"expvar"
"fmt"
"io"
"log"
"maps"
"net"
"net/http"
"net/netip"
@@ -53,6 +55,50 @@ func IsProd443(addr string) bool {
return port == "443" || port == "https"
}
// debugTrustedCIDRs is the envknob for TS_DEBUG_TRUSTED_CIDRS, a
// comma-separated list of CIDR ranges (e.g. "10.0.0.0/8,172.16.0.0/12")
// whose source IPs are allowed to access debug endpoints without Tailscale
// authentication. This will supersede both IsTailscaleIP() and
// TS_ALLOW_DEBUG_IP.
var debugTrustedCIDRs = envknob.RegisterString("TS_DEBUG_TRUSTED_CIDRS")
// trustedCIDRs returns the parsed CIDR prefixes from TS_DEBUG_TRUSTED_CIDRS.
var trustedCIDRs = sync.OnceValue(func() []netip.Prefix {
return parseTrustedCIDRs(debugTrustedCIDRs())
})
// parseTrustedCIDRs parses a comma-separated list of CIDR prefixes.
// It fatals on invalid entries, consistent with other envknob parsing.
func parseTrustedCIDRs(raw string) []netip.Prefix {
if raw == "" {
return nil
}
var prefixes []netip.Prefix
for s := range strings.SplitSeq(raw, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
pfx, err := netip.ParsePrefix(s)
if err != nil {
log.Fatalf("invalid CIDR in TS_DEBUG_TRUSTED_CIDRS: %q: %v", s, err)
}
prefixes = append(prefixes, pfx)
}
return prefixes
}
// cidrsContain checks if the source IP is associated with one of the
// provided cidrs.
func cidrsContain(cidrs []netip.Prefix, ip netip.Addr) bool {
for _, pfx := range cidrs {
if pfx.Contains(ip) {
return true
}
}
return false
}
// AllowDebugAccess reports whether r should be permitted to access
// various debug endpoints.
func AllowDebugAccess(r *http.Request) bool {
@@ -74,6 +120,9 @@ func AllowDebugAccess(r *http.Request) bool {
if tsaddr.IsTailscaleIP(ip) || ip.IsLoopback() || ipStr == envknob.String("TS_ALLOW_DEBUG_IP") {
return true
}
if cidrsContain(trustedCIDRs(), ip) {
return true
}
return false
}
@@ -687,6 +736,13 @@ func (lg loggingResponseWriter) Flush() {
f.Flush()
}
// FlushError is the error-returning form of Flush that
// [http.ResponseController.Flush] prefers, so handlers can see write
// errors (such as an expired write deadline) that Flush would swallow.
func (lg *loggingResponseWriter) FlushError() error {
return http.NewResponseController(lg.ResponseWriter).Flush()
}
func (lg *loggingResponseWriter) Unwrap() http.ResponseWriter {
return lg.ResponseWriter
}
@@ -734,8 +790,14 @@ func (h errorHandler) handleError(w http.ResponseWriter, r *http.Request, lw *lo
// Extract a presentable, loggable error.
var hOK bool
var hErr HTTPError
if errors.As(err, &hErr) {
hErr, hAsOK := errors.AsType[HTTPError](err)
if !hAsOK {
if hs, ok := errors.AsType[HTTPStatuser](err); ok {
hErr = hs.HTTPStatus()
hAsOK = true
}
}
if hAsOK {
hOK = true
if hErr.Code == 0 {
lw.logf("[unexpected] HTTPError %v did not contain an HTTP status code, sending internal server error", hErr)
@@ -854,9 +916,7 @@ func WriteHTTPError(w http.ResponseWriter, r *http.Request, e HTTPError) {
h.Set("X-Content-Type-Options", "nosniff")
// Custom headers from the error.
for k, vs := range e.Header {
h[k] = vs
}
maps.Copy(h, e.Header)
// Write the msg back to the user.
w.WriteHeader(e.Code)
@@ -872,6 +932,15 @@ func WriteHTTPError(w http.ResponseWriter, r *http.Request, e HTTPError) {
}
}
// HTTPStatuser is an optional interface implemented by errors that
// carry an intended HTTP response. Handlers translating errors to
// HTTP should honour the returned HTTPError rather than defaulting to
// 500.
type HTTPStatuser interface {
error
HTTPStatus() HTTPError
}
// HTTPError is an error with embedded HTTP response information.
//
// It is the error type to be (optionally) used by Handler.ServeHTTPReturn.
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android
package varz
import (
"bytes"
"expvar"
"os"
"runtime/debug"
"strings"
)
func init() {
var capable any = hostGOAMD64Level()
expvar.Publish("gauge_goamd64_capable", expvar.Func(func() any { return capable }))
var compiled any = compiledGOAMD64Level()
expvar.Publish("gauge_goamd64_compiled", expvar.Func(func() any { return compiled }))
}
// goamd64LevelFlags maps each x86-64 microarchitecture level to the
// /proc/cpuinfo flag names it requires beyond the previous level.
// It matches the per-level feature checks in Go's runtime/asm_amd64.s.
// Note that /proc/cpuinfo spells SSE3 as "pni" and LZCNT as "abm",
// and the kernel clears the AVX and AVX-512 flags when the OS lacks
// XSAVE support, so OSXSAVE needs no separate check.
var goamd64LevelFlags = [...][]string{
2: {"cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"},
3: {"abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe"},
4: {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"},
}
// hostGOAMD64Level returns the maximum GOAMD64 microarchitecture
// level (1-4) that the host CPU supports, regardless of what level
// this binary was compiled for. It returns 0 on error.
func hostGOAMD64Level() int {
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return 0
}
for line := range bytes.Lines(cpuinfo) {
name, rest, ok := bytes.Cut(line, []byte(":"))
if ok && string(bytes.TrimSpace(name)) == "flags" {
return goamd64Level(strings.Fields(string(rest)))
}
}
return 0
}
// goamd64Level returns the maximum GOAMD64 level (1-4) supported by
// a CPU with the given /proc/cpuinfo flags.
func goamd64Level(flags []string) int {
has := make(map[string]bool, len(flags))
for _, f := range flags {
has[f] = true
}
level := 1
for next := 2; next < len(goamd64LevelFlags); next++ {
for _, f := range goamd64LevelFlags[next] {
if !has[f] {
return level
}
}
level = next
}
return level
}
// compiledGOAMD64Level returns the GOAMD64 microarchitecture level
// (1-4) that this binary was compiled for, as recorded in its build
// info. It returns 0 if unknown.
func compiledGOAMD64Level() int {
bi, ok := debug.ReadBuildInfo()
if !ok {
return 0
}
for _, s := range bi.Settings {
if s.Key == "GOAMD64" {
if v, ok := strings.CutPrefix(s.Value, "v"); ok && len(v) == 1 && v[0] >= '1' && v[0] <= '4' {
return int(v[0] - '0')
}
return 0
}
}
return 0
}
+508
View File
@@ -0,0 +1,508 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package varz
import (
"io"
"math"
"runtime/metrics"
"strconv"
"strings"
"sync"
)
// runtimeMetricSpec describes one Go runtime/metrics metric that
// [Handler] exports in Prometheus format.
type runtimeMetricSpec struct {
// name is the runtime/metrics name, such as "/gc/heap/allocs:bytes".
name string
// typ is the Prometheus type: "gauge", "counter", or "histogram".
// Counters get a "_total" suffix, as in the Prometheus naming
// conventions.
typ string
// buckets says how a histogram's runtime buckets map to Prometheus
// buckets. It is required for histograms and ignored otherwise.
buckets histBuckets
// hidden means the metric is read (because it backs one of the
// legacy memstats_* metrics) but is not exported under its own
// go_runtime_ name.
hidden bool
}
// histBuckets says how the runtime's lower-bound-inclusive buckets
// [lo, hi) of a histogram become Prometheus's upper-bound-inclusive
// le buckets.
type histBuckets uint8
const (
// histTime is for the runtime's time histograms, which have
// hundreds of buckets. They are reduced as described at
// [runtimeHistMinBucket], and a runtime bucket [lo, hi) becomes
// le=hi, which is only wrong for a value exactly equal to hi.
histTime histBuckets = iota + 1
// histPowersOfTwo is for histograms whose observed values are all
// exactly equal to bucket lower bounds, such as goroutine stack
// sizes. A runtime bucket [lo, hi) becomes le=lo, so the labels
// name the values they count, and the runtime's final catch-all
// bucket is folded into +Inf.
histPowersOfTwo
// histInts is for histograms of small integer values, such as
// counts of GC cycles. A runtime bucket [lo, hi) becomes le=hi-1,
// which is exact for integers.
histInts
)
// runtimeMetricSpecs is the set of Go runtime/metrics metrics that
// [Handler] reads. Everything here is cheap for the runtime to read: no
// metric in this list stops the world or walks the heap or the
// goroutine list.
//
// The list is deliberately short. The hidden entries only back the
// legacy memstats_* metrics. The rest are either specific to the
// Tailscale fork of Go or have no runtime.MemStats equivalent and earn
// their place on a dashboard.
//
// Names that the running toolchain does not know (such as the
// /tailscale/ metrics when not built with the fork) are dropped on
// first use, so it is fine for this list to be a superset of what
// exists.
//
// Go's Cumulative metrics are declared as counters here and the rest
// as gauges; TestRuntimeMetricSpecs checks that against the
// runtime's own descriptions.
var runtimeMetricSpecs = []runtimeMetricSpec{
// These back the memstats_* metrics; see memstatsIndexes.
{name: "/memory/classes/heap/objects:bytes", typ: "gauge", hidden: true},
{name: "/gc/heap/allocs:bytes", typ: "counter", hidden: true},
{name: "/memory/classes/total:bytes", typ: "gauge", hidden: true},
{name: "/gc/heap/allocs:objects", typ: "counter", hidden: true},
{name: "/gc/heap/frees:objects", typ: "counter", hidden: true},
{name: "/gc/heap/tiny/allocs:objects", typ: "counter", hidden: true},
{name: "/gc/cycles/total:gc-cycles", typ: "counter", hidden: true},
{name: "/cpu/classes/gc/mark/idle:cpu-seconds", typ: "counter", hidden: true},
// GC CPU time as counters, so that rate() over a window works.
// memstats_gc_cpu_fraction, derived from these two (less the
// hidden idle mark time above), is a lifetime average and cannot
// show a recent change.
{name: "/cpu/classes/gc/total:cpu-seconds", typ: "counter"},
{name: "/cpu/classes/total:cpu-seconds", typ: "counter"},
// Live heap after the last GC. Unlike memstats_heap_alloc it
// excludes garbage not yet swept, so it is the number to compare
// against GOMEMLIMIT or a container memory limit.
{name: "/gc/heap/live:bytes", typ: "gauge"},
// How long runnable goroutines wait to be scheduled, and how long
// GC stop-the-world pauses take. Neither is visible any other way.
{name: "/sched/latencies:seconds", typ: "histogram", buckets: histTime},
{name: "/sched/pauses/total/gc:seconds", typ: "histogram", buckets: histTime},
// OS threads owned by the runtime, for spotting thread leaks from
// blocking syscalls or cgo.
{name: "/sched/threads/total:threads", typ: "gauge"},
// Time spent blocked on mutexes. cmd/derper used to export this
// itself as go_sync_mutex_wait_seconds.
{name: "/sync/mutex/wait/total:seconds", typ: "counter"},
// The /tailscale/ metrics exist only in the Tailscale fork of Go
// (github.com/tailscale/go), used when building with the
// tailscale_go build tag.
{name: "/tailscale/sched/goroutines-by-stack-size:bytes", typ: "histogram", buckets: histPowersOfTwo},
{name: "/tailscale/sched/stacks/copied:bytes", typ: "counter"},
{name: "/tailscale/sched/stacks/growths:events", typ: "counter"},
{name: "/tailscale/sched/stacks/shrinks:events", typ: "counter"},
{name: "/tailscale/sched/timers/tracked:timers", typ: "gauge"},
{name: "/tailscale/sched/timers/zombies/chan:timers", typ: "gauge"},
{name: "/tailscale/sched/timers/zombies/func:timers", typ: "gauge"},
{name: "/tailscale/sched/timers/zombies/func/created:timers", typ: "counter"},
{name: "/tailscale/sched/timers/zombies/func/removed:timers", typ: "counter"},
{name: "/tailscale/sched/timers/zombies/func/lifetime:gc-cycles", typ: "histogram", buckets: histInts},
{name: "/tailscale/sched/timers/zombies:timers", typ: "gauge"},
}
// runtimeMetricPrefix is the namespace prefix of exported runtime
// metrics. It is deliberately not "go_", which is the namespace used
// by the Prometheus Go client's collector; binaries that also use that
// client (see tailscale.com/tsweb/promvarz) would otherwise export
// some series twice, and Prometheus rejects a scrape containing
// duplicate series.
const runtimeMetricPrefix = "go_runtime_"
// runtimeMetricPromName converts a runtime/metrics name such as
// "/gc/heap/allocs:bytes" to a Prometheus metric name such as
// "go_runtime_gc_heap_allocs_bytes_total". The conversion follows the
// same rules as the Prometheus Go client's collector, other than the
// namespace: the path becomes the name with slashes and dashes
// replaced by underscores, the unit is appended, and counters get a
// "_total" suffix.
func runtimeMetricPromName(name, typ string) string {
path, unit, _ := strings.Cut(strings.TrimPrefix(name, "/"), ":")
r := strings.NewReplacer("/", "_", "-", "_")
unit = strings.ReplaceAll(unit, "/", "_per_")
unit = strings.ReplaceAll(unit, "*", "_")
s := runtimeMetricPrefix + r.Replace(path) + "_" + r.Replace(unit)
if typ == "counter" {
s += "_total"
}
return s
}
// Time histograms from the runtime have hundreds of buckets, four per
// power of two from 256ns (finer below that) to about a day. That is
// far too many series for Prometheus, so they are reduced to one
// bucket per factor of runtimeHistBucketFactor starting at
// runtimeHistMinBucket, and everything above runtimeHistMaxBucket is
// folded into the +Inf bucket. Because the runtime's bucket boundaries
// are at powers of two times small integers, a factor of 4 starting at
// 256ns lands exactly on runtime boundaries, so the reduction only
// merges runtime buckets and never splits one.
const (
runtimeHistMinBucket = 256e-9 // seconds
runtimeHistMaxBucket = 1.0 // seconds
runtimeHistBucketFactor = 4
)
// runtimeHistLayout describes how a runtime histogram's buckets are
// merged into the exported Prometheus buckets. It is computed once per
// metric from the runtime's bucket boundaries, which never change.
type runtimeHistLayout struct {
// lines[i] is the exported line prefix for output bucket i, up to
// and including the space before the value:
// `name_bucket{le="0.001"} `. The last entry is the +Inf bucket.
lines [][]byte
// ends[i] is the number of leading runtime bucket counts summed
// into output bucket i, which is cumulative like Prometheus
// buckets. The last entry, for +Inf, is the total number of
// runtime buckets.
ends []int
}
// newRuntimeHistLayout computes the export layout for a runtime
// histogram with the given bucket boundaries (as in
// [metrics.Float64Histogram.Buckets], so the runtime bucket i counts
// values in [buckets[i], buckets[i+1])).
func newRuntimeHistLayout(promName string, buckets []float64, kind histBuckets) *runtimeHistLayout {
numCounts := len(buckets) - 1
lay := new(runtimeHistLayout)
addBucket := func(le float64, end int) {
line := append([]byte(promName), `_bucket{le="`...)
if math.IsInf(le, 1) {
line = append(line, "+Inf"...)
} else {
line = strconv.AppendFloat(line, le, 'g', -1, 64)
}
line = append(line, `"} `...)
lay.lines = append(lay.lines, line)
lay.ends = append(lay.ends, end)
}
switch kind {
case histPowersOfTwo:
// All but the final catch-all runtime bucket, each labeled
// with its lower bound.
for i := 0; i < numCounts-1; i++ {
addBucket(buckets[i], i+1)
}
case histInts:
// All but the final catch-all runtime bucket, each labeled
// with the largest integer it contains.
for i := 1; i < numCounts; i++ {
addBucket(buckets[i]-1, i)
}
case histTime:
last := 0.0
for i := 1; i < numCounts; i++ {
hi := buckets[i]
if math.IsInf(hi, 0) || hi < runtimeHistMinBucket {
continue
}
if last > runtimeHistMaxBucket {
break
}
if last == 0 || hi >= last*runtimeHistBucketFactor {
addBucket(hi, i)
last = hi
}
}
}
addBucket(math.Inf(1), numCounts)
return lay
}
// runtimeMetricState is a metric being exported, alongside its
// [metrics.Sample] in [runtimeMetricsExporter.samples].
type runtimeMetricState struct {
spec runtimeMetricSpec
// typeLine is the "# TYPE name type\n" line.
typeLine []byte
// valuePrefix is "name " for scalar metrics, or nil for histograms.
valuePrefix []byte
// countPrefix is "name_count " for histograms, or nil otherwise.
countPrefix []byte
// hist is the bucket layout for histograms, or nil otherwise.
hist *runtimeHistLayout
}
// runtimeMetricsExporter reads a fixed set of Go runtime/metrics and
// writes them in Prometheus format. There is one, [runtimeMetrics],
// and it keeps its sample slice across reads so that the runtime can
// reuse histogram buffers and a steady-state scrape does not allocate.
type runtimeMetricsExporter struct {
mu sync.Mutex
inited bool
samples []metrics.Sample // reused across reads; parallel to states
states []*runtimeMetricState // parallel to samples
// Indexes into samples of the metrics backing the legacy
// memstats_* names, or -1 if the runtime lacks them.
memstatsIdx memstatsIndexes
}
// memstatsIndexes holds indexes into [runtimeMetricsExporter.samples]
// for the runtime/metrics equivalents of the runtime.MemStats fields
// that varz has exported as memstats_* since before runtime/metrics
// existed. Those names are kept for existing dashboards, but they are
// now read from runtime/metrics rather than runtime.ReadMemStats,
// which stops the world.
type memstatsIndexes struct {
heapAlloc int // /memory/classes/heap/objects:bytes
totalAlloc int // /gc/heap/allocs:bytes
sys int // /memory/classes/total:bytes
mallocs int // /gc/heap/allocs:objects
frees int // /gc/heap/frees:objects
tinyAllocs int // /gc/heap/tiny/allocs:objects
numGC int // /gc/cycles/total:gc-cycles
gcCPU int // /cpu/classes/gc/total:cpu-seconds
gcIdleCPU int // /cpu/classes/gc/mark/idle:cpu-seconds
totalCPU int // /cpu/classes/total:cpu-seconds
}
// runtimeMetrics is the process-wide exporter used by [Handler].
var runtimeMetrics = new(runtimeMetricsExporter)
// runtimeMetricsBufPool holds *[]byte output buffers so that a scrape
// can be formatted under the exporter's lock and written to the
// (possibly slow) client after releasing it.
var runtimeMetricsBufPool = sync.Pool{New: func() any { return new([]byte) }}
// init reads every metric in [runtimeMetricSpecs] once, drops the ones
// this toolchain does not know, and precomputes the per-metric output
// prefixes and histogram layouts.
//
// e.mu must be held.
func (e *runtimeMetricsExporter) init() {
e.inited = true
samples := make([]metrics.Sample, len(runtimeMetricSpecs))
for i, spec := range runtimeMetricSpecs {
samples[i].Name = spec.name
}
metrics.Read(samples)
byName := map[string]int{}
for i, spec := range runtimeMetricSpecs {
s := samples[i]
var ok bool
switch s.Value.Kind() {
case metrics.KindUint64, metrics.KindFloat64:
ok = spec.typ != "histogram"
case metrics.KindFloat64Histogram:
ok = spec.typ == "histogram"
}
if !ok {
// Unknown to this toolchain, or its kind changed
// out from under our table.
continue
}
st := &runtimeMetricState{spec: spec}
if !spec.hidden {
name := runtimeMetricPromName(spec.name, spec.typ)
st.typeLine = []byte("# TYPE " + name + " " + spec.typ + "\n")
if spec.typ == "histogram" {
st.hist = newRuntimeHistLayout(name, s.Value.Float64Histogram().Buckets, spec.buckets)
st.countPrefix = []byte(name + "_count ")
} else {
st.valuePrefix = []byte(name + " ")
}
}
byName[spec.name] = len(e.samples)
e.samples = append(e.samples, s)
e.states = append(e.states, st)
}
idx := func(name string) int {
if i, ok := byName[name]; ok {
return i
}
return -1
}
e.memstatsIdx = memstatsIndexes{
heapAlloc: idx("/memory/classes/heap/objects:bytes"),
totalAlloc: idx("/gc/heap/allocs:bytes"),
sys: idx("/memory/classes/total:bytes"),
mallocs: idx("/gc/heap/allocs:objects"),
frees: idx("/gc/heap/frees:objects"),
tinyAllocs: idx("/gc/heap/tiny/allocs:objects"),
numGC: idx("/gc/cycles/total:gc-cycles"),
gcCPU: idx("/cpu/classes/gc/total:cpu-seconds"),
gcIdleCPU: idx("/cpu/classes/gc/mark/idle:cpu-seconds"),
totalCPU: idx("/cpu/classes/total:cpu-seconds"),
}
}
// writeTo reads the current values of the exported metrics and writes
// them to w in Prometheus text format, preceded by the legacy
// memstats_* metrics.
func (e *runtimeMetricsExporter) writeTo(w io.Writer) error {
bp := runtimeMetricsBufPool.Get().(*[]byte)
defer runtimeMetricsBufPool.Put(bp)
e.mu.Lock()
if !e.inited {
e.init()
} else {
metrics.Read(e.samples)
}
*bp = e.appendMemstats((*bp)[:0])
for i, st := range e.states {
if !st.spec.hidden {
*bp = st.appendTo(*bp, &e.samples[i].Value)
}
}
e.mu.Unlock()
_, err := w.Write(*bp)
return err
}
// appendTo appends the Prometheus lines for st with value v to b.
func (st *runtimeMetricState) appendTo(b []byte, v *metrics.Value) []byte {
b = append(b, st.typeLine...)
switch v.Kind() {
case metrics.KindUint64:
b = append(b, st.valuePrefix...)
b = strconv.AppendUint(b, v.Uint64(), 10)
b = append(b, '\n')
case metrics.KindFloat64:
b = append(b, st.valuePrefix...)
b = strconv.AppendFloat(b, v.Float64(), 'g', -1, 64)
b = append(b, '\n')
case metrics.KindFloat64Histogram:
counts := v.Float64Histogram().Counts
var sum uint64
var next int
for i, end := range st.hist.ends {
for ; next < end && next < len(counts); next++ {
sum += counts[next]
}
b = append(b, st.hist.lines[i]...)
b = strconv.AppendUint(b, sum, 10)
b = append(b, '\n')
}
b = append(b, st.countPrefix...)
b = strconv.AppendUint(b, sum, 10)
b = append(b, '\n')
}
return b
}
// appendMemstats appends the legacy memstats_* metrics to b, sourced
// from the runtime/metrics samples named by e.memstatsIdx.
//
// e.mu must be held and e.samples must have been read.
func (e *runtimeMetricsExporter) appendMemstats(b []byte) []byte {
idx := &e.memstatsIdx
u := func(i int) (uint64, bool) {
if i < 0 {
return 0, false
}
v := &e.samples[i].Value
if v.Kind() != metrics.KindUint64 {
return 0, false
}
return v.Uint64(), true
}
f := func(i int) (float64, bool) {
if i < 0 {
return 0, false
}
v := &e.samples[i].Value
if v.Kind() != metrics.KindFloat64 {
return 0, false
}
return v.Float64(), true
}
appendUint := func(typ, name string, v uint64, ok bool, help string) {
if ok {
b = appendMemstat(b, typ, name, help)
b = strconv.AppendUint(b, v, 10)
b = append(b, '\n')
}
}
appendIdx := func(typ, name string, i int, help string) {
v, ok := u(i)
appendUint(typ, name, v, ok, help)
}
appendIdx("gauge", "heap_alloc", idx.heapAlloc, "current bytes of allocated heap objects (up/down smoothly)")
appendIdx("counter", "total_alloc", idx.totalAlloc, "cumulative bytes allocated for heap objects")
appendIdx("gauge", "sys", idx.sys, "total bytes of memory obtained from the OS")
// runtime.ReadMemStats counts tiny allocations in both Mallocs
// and Frees, but /gc/heap/allocs:objects and /gc/heap/frees:objects
// exclude them, so add them back.
mallocs, ok1 := u(idx.mallocs)
frees, ok2 := u(idx.frees)
tiny, ok3 := u(idx.tinyAllocs)
appendUint("counter", "mallocs", mallocs+tiny, ok1 && ok3, "cumulative count of heap objects allocated")
appendUint("counter", "frees", frees+tiny, ok2 && ok3, "cumulative count of heap objects freed")
appendIdx("counter", "num_gc", idx.numGC, "number of completed GC cycles")
gc, ok1 := f(idx.gcCPU)
idle, ok2 := f(idx.gcIdleCPU)
total, ok3 := f(idx.totalCPU)
if ok1 && ok2 && ok3 {
// This is how the runtime computes MemStats.GCCPUFraction:
// GC time less idle-priority marking, which is "free", over
// total time. The runtime updates its CPU class stats at
// the end of each GC cycle, so total is 0 until the first
// one, as GCCPUFraction was too.
var frac float64
if total > 0 {
frac = (gc - idle) / total
}
b = appendMemstat(b, "gauge", "gc_cpu_fraction", "fraction of CPU time used by GC")
b = strconv.AppendFloat(b, frac, 'f', -1, 64)
b = append(b, '\n')
}
return b
}
// appendMemstat appends the HELP and TYPE lines for the memstats_*
// metric name to b, followed by the metric name and a space, leaving
// the caller to append the value and newline.
func appendMemstat(b []byte, typ, name, help string) []byte {
b = append(b, "# HELP memstats_"...)
b = append(b, name...)
b = append(b, ' ')
b = append(b, help...)
b = append(b, "\n# TYPE memstats_"...)
b = append(b, name...)
b = append(b, ' ')
b = append(b, typ...)
b = append(b, "\nmemstats_"...)
b = append(b, name...)
b = append(b, ' ')
return b
}
+57 -64
View File
@@ -1,11 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package varz contains code to export metrics in Prometheus format.
package varz
import (
"bufio"
"cmp"
"expvar"
"fmt"
@@ -23,10 +22,8 @@ import (
"unicode"
"unicode/utf8"
"golang.org/x/exp/constraints"
"tailscale.com/metrics"
"tailscale.com/syncs"
"tailscale.com/types/logger"
"tailscale.com/version"
)
@@ -42,8 +39,34 @@ func init() {
expvar.Publish("go_version", StaticStringVar(runtime.Version()))
expvar.Publish("counter_uptime_sec", expvar.Func(func() any { return int64(Uptime().Seconds()) }))
expvar.Publish("gauge_goroutines", expvar.Func(func() any { return runtime.NumGoroutine() }))
if v := nodeBootTime(); v != 0 {
var vi any = v // box once
// The name matches what Prometheus's node exporter uses
// for the same value.
expvar.Publish("node_boot_time_seconds", expvar.Func(func() any { return vi }))
}
}
// nodeBootTime returns the machine's boot time in Unix seconds,
// as reported by the "btime" line of Linux's /proc/stat.
// It returns 0 if unavailable, such as on non-Linux systems.
var nodeBootTime = sync.OnceValue(func() int64 {
stat, err := os.ReadFile("/proc/stat")
if err != nil {
return 0
}
for line := range strings.Lines(string(stat)) {
if rest, ok := strings.CutPrefix(line, "btime "); ok {
sec, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64)
if err != nil {
return 0
}
return sec
}
}
return 0
})
const (
gaugePrefix = "gauge_"
counterPrefix = "counter_"
@@ -93,8 +116,8 @@ func prometheusMetric(prefix string, key string) (string, string, string) {
typ = "histogram"
key = strings.TrimPrefix(key, histogramPrefix)
}
if strings.HasPrefix(key, labelMapPrefix) {
key = strings.TrimPrefix(key, labelMapPrefix)
if after, ok := strings.CutPrefix(key, labelMapPrefix); ok {
key = after
if a, b, ok := strings.Cut(key, "_"); ok {
label, key = a, b
}
@@ -154,7 +177,7 @@ func writePromExpVar(w io.Writer, prefix string, kv expvar.KeyValue) {
case PrometheusMetricsReflectRooter:
root := v.PrometheusMetricsReflectRoot()
rv := reflect.ValueOf(root)
if rv.Type().Kind() == reflect.Ptr {
if rv.Type().Kind() == reflect.Pointer {
if rv.IsNil() {
return
}
@@ -189,11 +212,14 @@ func writePromExpVar(w io.Writer, prefix string, kv expvar.KeyValue) {
if typ == "" {
var funcRet string
if f, ok := kv.Value.(expvar.Func); ok {
v := f()
if ms, ok := v.(runtime.MemStats); ok && name == "memstats" {
writeMemstats(w, &ms)
if key == "memstats" {
// The expvar package's own memstats var calls
// runtime.ReadMemStats, which stops the world and
// allocates. [Handler] exports the memstats_* metrics
// from runtime/metrics instead, so don't even call it.
return
}
v := f()
if vs, ok := v.(string); ok && strings.HasSuffix(name, "version") {
if name == "version" {
fmt.Fprintf(w, "%s{version=%q,binary=%q} 1\n", name, vs, binaryName())
@@ -245,11 +271,21 @@ func writePromExpVar(w io.Writer, prefix string, kv expvar.KeyValue) {
if label != "" && typ != "" {
fmt.Fprintf(w, "# TYPE %s %s\n", name, typ)
v.Do(func(kv expvar.KeyValue) {
fmt.Fprintf(w, "%s{%s=%q} %v\n", name, label, kv.Key, kv.Value)
switch kv.Value.(type) {
case *expvar.Int, *expvar.Float:
fmt.Fprintf(w, "%s{%s=%q} %v\n", name, label, kv.Key, kv.Value)
default:
fmt.Fprintf(w, "# skipping %q expvar map key %q with unknown value type %T\n", name, kv.Key, kv.Value)
}
})
} else {
v.Do(func(kv expvar.KeyValue) {
fmt.Fprintf(w, "%s_%s %v\n", name, kv.Key, kv.Value)
switch kv.Value.(type) {
case *expvar.Int, *expvar.Float:
fmt.Fprintf(w, "%s_%s %v\n", name, kv.Key, kv.Value)
default:
fmt.Fprintf(w, "# skipping %q expvar map key %q with unknown value type %T\n", name, kv.Key, kv.Value)
}
})
}
}
@@ -292,13 +328,19 @@ type sortedKVs struct {
// is not exported.
//
// This will evolve over time, or perhaps be replaced.
//
// It also exports the Go runtime/metrics in [runtimeMetricSpecs] and
// the legacy memstats_* metrics, both read from runtime/metrics.
func Handler(w http.ResponseWriter, r *http.Request) {
ExpvarDoHandler(expvarDo)(w, r)
runtimeMetrics.writeTo(w)
}
// ExpvarDoHandler handler returns a Handler like above, but takes an optional
// expvar.Do func allow the usage of alternative containers of metrics, other
// than the global expvar.Map.
//
// Unlike [Handler], it does not export the Go runtime/metrics.
func ExpvarDoHandler(expvarDoFunc func(f func(expvar.KeyValue))) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain;version=0.0.4;charset=utf-8")
@@ -342,54 +384,6 @@ type PrometheusMetricsReflectRooter interface {
var expvarDo = expvar.Do // pulled out for tests
func writeMemstat[V constraints.Integer | constraints.Float](bw *bufio.Writer, typ, name string, v V, help string) {
if help != "" {
bw.WriteString("# HELP memstats_")
bw.WriteString(name)
bw.WriteString(" ")
bw.WriteString(help)
bw.WriteByte('\n')
}
bw.WriteString("# TYPE memstats_")
bw.WriteString(name)
bw.WriteString(" ")
bw.WriteString(typ)
bw.WriteByte('\n')
bw.WriteString("memstats_")
bw.WriteString(name)
bw.WriteByte(' ')
rt := reflect.TypeOf(v)
switch {
case rt == reflect.TypeFor[int]() ||
rt == reflect.TypeFor[uint]() ||
rt == reflect.TypeFor[int8]() ||
rt == reflect.TypeFor[uint8]() ||
rt == reflect.TypeFor[int16]() ||
rt == reflect.TypeFor[uint16]() ||
rt == reflect.TypeFor[int32]() ||
rt == reflect.TypeFor[uint32]() ||
rt == reflect.TypeFor[int64]() ||
rt == reflect.TypeFor[uint64]() ||
rt == reflect.TypeFor[uintptr]():
bw.Write(strconv.AppendInt(bw.AvailableBuffer(), int64(v), 10))
case rt == reflect.TypeFor[float32]() || rt == reflect.TypeFor[float64]():
bw.Write(strconv.AppendFloat(bw.AvailableBuffer(), float64(v), 'f', -1, 64))
}
bw.WriteByte('\n')
}
func writeMemstats(w io.Writer, ms *runtime.MemStats) {
fmt.Fprintf(w, "%v", logger.ArgWriter(func(bw *bufio.Writer) {
writeMemstat(bw, "gauge", "heap_alloc", ms.HeapAlloc, "current bytes of allocated heap objects (up/down smoothly)")
writeMemstat(bw, "counter", "total_alloc", ms.TotalAlloc, "cumulative bytes allocated for heap objects")
writeMemstat(bw, "gauge", "sys", ms.Sys, "total bytes of memory obtained from the OS")
writeMemstat(bw, "counter", "mallocs", ms.Mallocs, "cumulative count of heap objects allocated")
writeMemstat(bw, "counter", "frees", ms.Frees, "cumulative count of heap objects freed")
writeMemstat(bw, "counter", "num_gc", ms.NumGC, "number of completed GC cycles")
writeMemstat(bw, "gauge", "gc_cpu_fraction", ms.GCCPUFraction, "fraction of CPU time used by GC")
}))
}
// sortedStructField is metadata about a struct field used both for sorting once
// (by structTypeSortedFields) and at serving time (by
// foreachExportedStructField).
@@ -409,8 +403,7 @@ func structTypeSortedFields(t reflect.Type) []sortedStructField {
return v.([]sortedStructField)
}
fields := make([]sortedStructField, 0, t.NumField())
for i, n := 0, t.NumField(); i < n; i++ {
sf := t.Field(i)
for sf := range t.Fields() {
name := sf.Name
if v := sf.Tag.Get("json"); v != "" {
v, _, _ = strings.Cut(v, ",")
@@ -423,7 +416,7 @@ func structTypeSortedFields(t reflect.Type) []sortedStructField {
}
}
fields = append(fields, sortedStructField{
Index: i,
Index: sf.Index[0],
Name: name,
SortName: removeTypePrefixes(name),
MetricType: sf.Tag.Get("metrictype"),
@@ -457,7 +450,7 @@ func foreachExportedStructField(rv reflect.Value, f func(fieldOrJSONName, metric
sf := ssf.StructFieldType
if ssf.MetricType != "" || sf.Type.Kind() == reflect.Struct {
f(ssf.Name, ssf.MetricType, rv.Field(ssf.Index))
} else if sf.Type.Kind() == reflect.Ptr && sf.Type.Elem().Kind() == reflect.Struct {
} else if sf.Type.Kind() == reflect.Pointer && sf.Type.Elem().Kind() == reflect.Struct {
fv := rv.Field(ssf.Index)
if !fv.IsNil() {
f(ssf.Name, ssf.MetricType, fv.Elem())
+200
View File
@@ -0,0 +1,200 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package vcstime provides a file system wrapper that gives embedded
// files a meaningful modification time.
//
// embed.FS stores and serves no ModTime: files report a zero
// [fs.FileInfo.ModTime], which disables HTTP caching semantics when
// served with [net/http]: no Last-Modified header is sent and
// If-Modified-Since requests are never answered with a 304. vcstime
// derives a modification time from the vcs.time build setting recorded
// by the go command (see "go version -m" or [debug.BuildInfo]),
// enabling [http.FS] to provide working cache headers for clean builds.
//
// FS wraps an [fs.FS] (typically an [embed.FS]) and reports that time as
// the modification time of every file that does not have a real
// timestamp of its own. Usage:
//
// //go:embed internal/whatever/*.*
// var content embed.FS
//
// mux.Handle("/", http.FileServer(http.FS(vcstime.FS(content))))
//
// vcs.time is the commit time of the revision that was built, not the
// wall-clock build time: the two coincide for clean CI builds, but a
// tree with local modifications yields an older commit time. Binaries
// built without VCS stamping (for example with -buildvcs=false) have no
// vcs.time, and FS then behaves exactly like the file system it wraps.
package vcstime
import (
"io"
"io/fs"
"runtime/debug"
"sync"
"time"
)
var (
vcsTimeOnce sync.Once
vcsTimeVal time.Time
)
// ModTime returns the modification time reported for timestamp-less files
// by file systems created with FS: the vcs.time commit time of the
// running binary's source revision, or the zero time if the binary
// carries no VCS build stamp.
func ModTime() time.Time {
vcsTimeOnce.Do(func() {
bi, ok := debug.ReadBuildInfo()
if ok {
vcsTimeVal = vcsTime(bi)
}
})
return vcsTimeVal
}
// vcsTime returns the vcs.time build setting from bi as a time, or the
// zero time if it is absent or unparseable.
func vcsTime(bi *debug.BuildInfo) time.Time {
for _, s := range bi.Settings {
if s.Key != "vcs.time" {
continue
}
t, err := time.Parse(time.RFC3339, s.Value)
if err != nil {
return time.Time{}
}
return t
}
return time.Time{}
}
// FS returns an [fs.FS] that serves files from fsys, reporting [ModTime]
// as the modification time of any file that has no real timestamp of its
// own.
//
// It is intended for wrapping an [embed.FS], whose files always report a
// zero ModTime. Files that already have a real timestamp (for example in
// an [fstest.MapFS] or os-backed file system) are passed through
// unchanged, so it is always safe to wrap.
//
// If the binary carries no vcs.time stamp, fsys itself is returned
// unchanged.
func FS(fsys fs.FS) fs.FS {
return New(fsys, ModTime())
}
// New is like FS, but with an explicit modification time to report for
// files that have none of their own. If mod is the zero time, fsys is
// returned unchanged.
//
// This exists mostly for tests; prefer FS in production code.
func New(fsys fs.FS, mod time.Time) fs.FS {
if fsys == nil || mod.IsZero() {
return fsys
}
return &modFS{fsys: fsys, mod: mod}
}
// modFS is an fs.FS wrapper that stamps timestamp-less files with a fixed
// modification time.
type modFS struct {
fsys fs.FS
mod time.Time
}
var (
_ fs.ReadDirFS = (*modFS)(nil)
_ fs.ReadFileFS = (*modFS)(nil)
)
func (m *modFS) Open(name string) (fs.File, error) {
f, err := m.fsys.Open(name)
if err != nil {
return nil, err
}
return &modFile{File: f, name: name, mod: m.mod}, nil
}
func (m *modFS) ReadDir(name string) ([]fs.DirEntry, error) {
des, err := fs.ReadDir(m.fsys, name)
return stampDirEntries(des, m.mod), err
}
func (m *modFS) ReadFile(name string) ([]byte, error) {
return fs.ReadFile(m.fsys, name)
}
// modFile is an fs.File wrapper that reports a stamped modification time
// from Stat. Read, Close, Seek and ReadDir are forwarded to the
// underlying file when it supports them, so that http.FS and other
// consumers keep working (http.FS type-asserts files for io.Seeker and
// fs.ReadDirFile).
type modFile struct {
fs.File
name string
mod time.Time
}
var _ fs.ReadDirFile = (*modFile)(nil)
func (f *modFile) Stat() (fs.FileInfo, error) {
info, err := f.File.Stat()
if err != nil {
return nil, err
}
return stampFileInfo(info, f.mod), nil
}
func (f *modFile) Seek(offset int64, whence int) (int64, error) {
if s, ok := f.File.(io.Seeker); ok {
return s.Seek(offset, whence)
}
return 0, &fs.PathError{Op: "seek", Path: f.name, Err: fs.ErrInvalid}
}
func (f *modFile) ReadDir(n int) ([]fs.DirEntry, error) {
rdf, ok := f.File.(fs.ReadDirFile)
if !ok {
return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid}
}
des, err := rdf.ReadDir(n)
return stampDirEntries(des, f.mod), err
}
// stampFileInfo returns info unchanged if it already has a modification
// time or there is nothing to stamp it with, and otherwise a FileInfo
// that reports mod instead.
func stampFileInfo(info fs.FileInfo, mod time.Time) fs.FileInfo {
if mod.IsZero() || !info.ModTime().IsZero() {
return info
}
return modFileInfo{FileInfo: info, mod: mod}
}
func stampDirEntries(des []fs.DirEntry, mod time.Time) []fs.DirEntry {
if mod.IsZero() {
return des
}
for i, de := range des {
info, err := de.Info()
if err != nil {
continue
}
if info.ModTime().IsZero() {
des[i] = fs.FileInfoToDirEntry(modFileInfo{FileInfo: info, mod: mod})
}
}
return des
}
// modFileInfo is an fs.FileInfo that reports mod as its modification
// time, delegating everything else.
type modFileInfo struct {
fs.FileInfo
mod time.Time
}
func (i modFileInfo) ModTime() time.Time { return i.mod }