feat(cache): add Cloudflare SigV4 S3 signature compatibility fix and compile locally
Some checks failed
Go CI with S3 Caching / build-and-test (push) Failing after 4s

This commit is contained in:
2026-05-19 21:28:45 -07:00
parent c4227a0eb1
commit 6e45f50874
18 changed files with 2529 additions and 2 deletions

View File

@@ -50,8 +50,8 @@ runs:
if: ${{ inputs.cache == 'true' && inputs.s3-bucket != '' }}
shell: bash
run: |
echo "Installing go-cache-plugin..."
go install github.com/tailscale/go-cache-plugin/cmd/go-cache-plugin@${{ inputs.go-cache-plugin-version }}
echo "Installing custom Cloudflare-compatible go-cache-plugin..."
go build -o $HOME/go/bin/go-cache-plugin ${{ github.action_path }}/go-cache-plugin-src/cmd/go-cache-plugin
- name: Configure Go Cache
if: ${{ inputs.cache == 'true' && inputs.s3-bucket != '' }}

View File

@@ -0,0 +1,28 @@
name: Go presubmit
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize]
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Go presubmit
runs-on: ${{ matrix.os }}
strategy:
matrix:
go-version: ['stable']
os: ['ubuntu-latest']
steps:
- uses: actions/checkout@v4
- name: Install Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- uses: creachadair/go-presubmit-action@v2

View File

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2024 Tailscale Inc & AUTHORS.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,94 @@
# go-cache-plugin
[![GoDoc](https://img.shields.io/static/v1?label=godoc&message=reference&color=lightgrey)](https://pkg.go.dev/github.com/tailscale/go-cache-plugin)
[![CI](https://github.com/tailscale/go-cache-plugin/actions/workflows/go-presubmit.yml/badge.svg?event=push&branch=main)](https://github.com/tailscale/go-cache-plugin/actions/workflows/go-presubmit.yml)
This repository defines a tool implementing a `GOCACHEPROG` plugin backed by Amazon S3.
## Installation
```shell
go install github.com/tailscale/go-cache-plugin/cmd/go-cache-plugin@latest
```
## Usage Outline
```shell
export GOCACHEPROG="go-cache-plugin --cache-dir=/tmp/gocache --bucket=some-s3-bucket"
go test ./...
```
Using the plugin requires a Go toolchain built with `GOEXPERIMENT=cacheprog` enabled.
However, you do not need the experiment enabled to build the plugin itself.
## Discussion
The `go-cache-plugin` program supports two modes of operation:
1. **Direct mode**: The program is invoked directly by the Go toolchain as a
subprocess, and exits when the toolchain execution ends.
This is the default mode of operation, and requires no additional setup.
2. **Server mode**: The program runs as a separate process and the Go toolchain
communicates with it over a local socket.
This mode requires the server to be started up ahead of time, but makes the
configuration for the toolchain simpler. This mode also permits running an
in-process module and sum database proxy.
### Server Mode
To run in server mode, use the `serve` subcommand:
```sh
# N.B.: The --plugin flag is required.
go-cache-plugin serve \
--plugin=5930 \
--cache-dir=/tmp/gocache \
--bucket=some-s3-bucket
```
To connect to a server running in this mode, use the `connect` subcommand:
```sh
# Use the same port given to the server's --plugin flag.
# Mnemonic: 5930 == (Go) (C)ache (P)lugin
export GOCACHEPROG="go-cache-plugin connect 5930"
go build ./...
```
The `connect` command just bridges the socket to stdin/stdout, which is how the
Go toolchain expects to talk to the plugin.
### Running a Module Proxy
To enable a caching module proxy, use the `--modproxy` flag to `serve`. The
module proxy uses HTTP, not the plugin interface, use `--http` to set the address:
```sh
go-cache-plugin serve \
--plugin=5930 \
--http=localhost:5970 --modproxy \
--cache-dir=/tmp/gocache \
# ... other flags
```
To tell the Go toolchain about the proxy, set:
```sh
# Mnemonic: 5970 == (Go) (M)odule (P)roxy
export GOPROXY=http://localhost:5970/mod # use the --http address
```
If you want to also proxy queries to `sum.golang.org`, also add:
```sh
export GOSUMDB='sum.golang.org http://locahost:5970/mod/sumdb/sum.golang.org'
```
## References
- [Cache plugin protocol (proposal)](https://github.com/golang/go/issues/59719)
- [Cache plugin library](https://github.com/creachadair/gocache)
- [Go module proxy documentation](https://proxy.golang.org)

View File

@@ -0,0 +1,29 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package main
import (
"errors"
"log"
"github.com/creachadair/atomicfile"
"github.com/creachadair/command"
"github.com/creachadair/tlsutil"
)
func installSigningCert(env *command.Env, cert tlsutil.Certificate) error {
const certFile = "revproxy-ca.crt"
if err := atomicfile.WriteData(certFile, cert.CertPEM(), 0644); err != nil {
log.Printf("WARNING: Unable to write cert file: %v", err)
} else {
log.Printf("Wrote signing cert to %s", certFile)
}
// TODO(creachadair): Maybe crib some other cases from mkcert, if we need
// them, for example:
// https://github.com/FiloSottile/mkcert/blob/master/truststore_darwin.go
return errors.New("unable to install a certificate on this system")
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"errors"
"fmt"
"os"
"github.com/creachadair/command"
"github.com/creachadair/tlsutil"
"golang.org/x/sys/unix"
)
func installSigningCert(env *command.Env, cert tlsutil.Certificate) error {
const ubuntuCertFile = "/etc/ssl/certs/ca-certificates.crt"
return lockAndAppend(ubuntuCertFile, cert.CertPEM())
}
// lockAndAppend acquires an exclusive advisory lock on path, if possible, and
// appends data to the end of it. It reports an error if path does not exist,
// or if the lock could not be acquired. The lock is automatically released
// before returning.
func lockAndAppend(path string, data []byte) error {
f, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND, 0)
if err != nil {
return err
}
fd := int(f.Fd())
if err := unix.Flock(fd, unix.LOCK_EX); err != nil {
f.Close()
return fmt.Errorf("lock: %w", err)
}
defer unix.Flock(fd, unix.LOCK_UN)
_, werr := f.Write(data)
cerr := f.Close()
return errors.Join(werr, cerr)
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/creachadair/command"
"github.com/creachadair/gocache"
"github.com/creachadair/taskgroup"
)
var flags struct {
CacheDir string `flag:"cache-dir,default=$GOCACHE_DIR,Local cache directory (required)"`
S3Bucket string `flag:"bucket,default=$GOCACHE_S3_BUCKET,S3 bucket name (required)"`
S3Region string `flag:"region,default=$GOCACHE_S3_REGION,S3 region"`
S3Endpoint string `flag:"s3-endpoint-url,default=$GOCACHE_S3_ENDPOINT_URL,S3 custom endpoint URL (if unset, use AWS default)"`
S3PathStyle bool `flag:"s3-path-style,default=$GOCACHE_S3_PATH_STYLE,S3 path-style URLs (optional)"`
KeyPrefix string `flag:"prefix,default=$GOCACHE_KEY_PREFIX,S3 key prefix (optional)"`
MinUploadSize int64 `flag:"min-upload-size,default=$GOCACHE_MIN_SIZE,Minimum object size to upload to S3 (in bytes)"`
Concurrency int `flag:"c,default=$GOCACHE_CONCURRENCY,Maximum number of concurrent requests"`
S3Concurrency int `flag:"u,default=$GOCACHE_S3_CONCURRENCY,Maximum concurrency for upload to S3"`
PrintMetrics bool `flag:"metrics,default=$GOCACHE_METRICS,Print summary metrics to stderr at exit"`
Expiration time.Duration `flag:"expiry,default=$GOCACHE_EXPIRY,Cache expiration period (optional)"`
Verbose bool `flag:"v,default=$GOCACHE_VERBOSE,Enable verbose logging"`
DebugLog int `flag:"debug,default=$GOCACHE_DEBUG,Enable detailed per-request debug logging (noisy)"`
}
const (
debugBuildCache = 1 << iota
debugModProxy
debugRevProxy
)
// runDirect runs a cache communicating on stdin/stdout, for use as a direct
// GOCACHEPROG plugin.
func runDirect(env *command.Env) error {
s, _, err := initCacheServer(env)
if err != nil {
return err
}
if err := s.Run(env.Context(), os.Stdin, os.Stdout); err != nil {
return fmt.Errorf("cache server exited with error: %w", err)
}
if flags.Verbose || flags.PrintMetrics {
fmt.Fprintln(os.Stderr, s.Metrics())
}
return nil
}
var serveFlags struct {
Plugin int `flag:"plugin,default=$GOCACHE_PLUGIN,Plugin service port (required)"`
HTTP string `flag:"http,default=$GOCACHE_HTTP,HTTP service address ([host]:port)"`
ModProxy bool `flag:"modproxy,default=$GOCACHE_MODPROXY,Enable a Go module proxy (requires --http)"`
RevProxy string `flag:"revproxy,default=$GOCACHE_REVPROXY,Reverse proxy these hosts (comma-separated; requires --http)"`
SumDB string `flag:"sumdb,default=$GOCACHE_SUMDB,SumDB servers to proxy for (comma-separated)"`
}
func noopClose(context.Context) error { return nil }
// runServe runs a cache communicating over a local TCP socket.
func runServe(env *command.Env) error {
if serveFlags.Plugin <= 0 {
return env.Usagef("you must provide a --plugin port")
}
// Initialize the cache server. Unlike a direct server, only close down and
// wait for cache cleanup when the whole process exits.
s, s3c, err := initCacheServer(env)
if err != nil {
return err
}
closeHook := s.Close
s.Close = noopClose
// Listen for connections from the Go toolchain on the specified socket.
lst, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", serveFlags.Plugin))
if err != nil {
return fmt.Errorf("listen: %w", err)
}
log.Printf("plugin listening at %q", lst.Addr())
ctx, cancel := signal.NotifyContext(env.Context(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
var g taskgroup.Group
g.Run(func() {
<-ctx.Done()
log.Printf("closing plugin listener")
lst.Close()
})
// If a module proxy is enabled, start it.
modProxy, modCleanup, err := initModProxy(env.SetContext(ctx), s3c)
if err != nil {
lst.Close()
return fmt.Errorf("module proxy: %w", err)
}
defer modCleanup()
// If a reverse proxy is enabled, start it.
revProxy, err := initRevProxy(env.SetContext(ctx), s3c, &g)
if err != nil {
lst.Close()
return fmt.Errorf("reverse proxy: %w", err)
}
// If an HTTP server is enabled, start it up with debug routes
// and whatever other services were requested.
if serveFlags.HTTP != "" {
srv := &http.Server{
Addr: serveFlags.HTTP,
Handler: makeHandler(modProxy, revProxy),
}
g.Go(srv.ListenAndServe)
vprintf("HTTP server listening at %q", serveFlags.HTTP)
g.Run(func() {
<-ctx.Done()
vprintf("stopping HTTP service")
srv.Shutdown(context.Background())
})
}
for {
conn, err := lst.Accept()
if err != nil {
if !errors.Is(err, net.ErrClosed) {
log.Printf("accept failed: %v, exiting server loop", err)
}
break
}
log.Printf("new client connection")
g.Go(func() error {
defer func() {
log.Printf("client connection closed")
conn.Close()
}()
return s.Run(ctx, conn, conn)
})
}
log.Printf("server loop exited, waiting for client exit")
g.Wait()
if closeHook != nil {
ctx := gocache.WithLogf(context.Background(), log.Printf)
if err := closeHook(ctx); err != nil {
log.Printf("server close: %v (ignored)", err)
}
}
return nil
}
// runConnect implements a direct cache proxy by connecting to a remote server.
func runConnect(env *command.Env, plugin string) error {
port, err := strconv.Atoi(plugin)
if err != nil {
return fmt.Errorf("invalid plugin port: %w", err)
}
conn, err := net.Dial("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return fmt.Errorf("dial: %w", err)
}
start := time.Now()
vprintf("connected to %q", conn.RemoteAddr())
out := taskgroup.Go(func() error {
defer conn.(*net.TCPConn).CloseWrite() // let the server finish
return copy(conn, os.Stdin)
})
if rerr := copy(os.Stdout, conn); rerr != nil {
vprintf("read responses: %v", err)
}
out.Wait()
conn.Close()
vprintf("connection closed (%v elapsed)", time.Since(start))
return nil
}
// copy emulates the base case of io.Copy, but does not attempt to use the
// io.ReaderFrom or io.WriterTo implementations.
//
// TODO(creachadair): For some reason io.Copy does not work correctly when r is
// a pipe (e.g., stdin) and w is a TCP socket. Figure out why.
func copy(w io.Writer, r io.Reader) error {
var buf [4096]byte
for {
nr, err := r.Read(buf[:])
if nr > 0 {
if nw, err := w.Write(buf[:nr]); err != nil {
return fmt.Errorf("copy to: %w", err)
} else if nw < nr {
return fmt.Errorf("wrote %d < %d bytes: %w", nw, nr, io.ErrShortWrite)
}
}
if err == io.EOF {
return nil
} else if err != nil {
return fmt.Errorf("copy from: %w", err)
}
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Program gocache implements the experimental GOCACHEPROG protocol over an S3
// bucket, for use in builder and CI workers.
package main
import (
"context"
"log"
"os"
"github.com/creachadair/command"
"github.com/creachadair/flax"
"github.com/tailscale/go-cache-plugin/lib/s3util"
)
func main() {
log.SetFlags(log.Ltime | log.Lmicroseconds)
root := &command.C{
Name: command.ProgramName(),
Usage: "--cache-dir d --bucket b [options]\nhelp",
Help: `Run a cache service for the Go toolchain backed by an S3 bucket.
This program serves the Go toolchain cache protocol on stdin/stdout.
It is meant to be run by the "go" tool as a GOCACHEPROG plugin.
For example:
GOCACHEPROG=` + command.ProgramName() + ` go build ./...
Note that Go toolchains prior to 1.24 must be built with GOEXPERIMENT=cacheprog
to enable this integration. Go 1.24 and later have it enabled by default.
You must provide --cache-dir, --bucket, and --region, or the corresponding
environment variables (see "help environment"). Entries in the cache are
stored in the specified S3 bucket, and staged in a local directory specified by
the --cache-dir flag or GOCACHE_DIR environment.`,
SetFlags: command.Flags(flax.MustBind, &flags),
Run: command.Adapt(runDirect),
Commands: []*command.C{
{
Name: "serve",
Usage: "--plugin <port>",
Help: `Run a cache server.
In this mode, the cache server listens for connections on a socket instead of
serving directly over stdin/stdout. The "connect" command adapts the direct
interface to this one.
By default, only the build cache is exported via the --plugin port.
If --http is set, the server also exports an HTTP server at that address.
By default, this exports only /debug endpoints, including metrics.
When --http is enabled, the following options are available:
- When --modcache is true, the server also exports a caching module proxy at
http://<host>:<port>/mod/.
- When --revproxy is set, the server also hosts a caching reverse proxy for the
specified hosts at http://<host>:<port>. The reverse proxy handles both HTTP
and HTTPS requests, and caches immutable successful responses.`,
SetFlags: command.Flags(flax.MustBind, &serveFlags),
Run: command.Adapt(runServe),
},
{
Name: "connect",
Usage: "<port>",
Help: `Connect to a remote cache server.
This mode bridges stdin/stdout to a cache server (see the "serve" command)
listening on the specified port.`,
Run: command.Adapt(runConnect),
},
command.HelpCommand(helpTopics),
command.VersionCommand(),
},
}
command.RunOrFail(root.NewEnv(nil), os.Args[1:])
}
// getBucketRegion reports the specified region for the given bucket.
// if the --region flag was set, that value is returned without error.
// Otherwise, it queries the GetBucketLocation API.
func getBucketRegion(ctx context.Context, bucket string) (string, error) {
if flags.S3Region != "" {
return flags.S3Region, nil
}
return s3util.BucketRegion(ctx, bucket)
}
// vprintf acts as log.Printf if the --verbose flag is set; otherwise it
// discards its input.
func vprintf(msg string, args ...any) {
if flags.Verbose || flags.DebugLog != 0 {
log.Printf(msg, args...)
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import "github.com/creachadair/command"
var helpTopics = []command.HelpTopic{
{
Name: "configure",
Help: `How to configure the plugin.
To run the plugin, install the program somewhere on your system and set the
GOCACHEPROG environment variable to the command line of the plugin. You can
either specify the full path to the program, or install it in your $PATH.
Parameters can be passed either as flags or via environment variables.
See also "help environment".
The plugin requires credentials to access S3. If you are running in AWS, it can
get credentials from the instance metadata service; otherwise you will need to
plumb AWS environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
AWS_ENDPOINT_URL) or set up a configuration file.
See also: "help environment".
Related: "direct-mode", "serve-mode", "module-proxy", "reverse-proxy".`,
},
{
Name: "environment",
Help: `Environment variables understood by this program.
To make it easier to configure this tool for multiple workflows, most of the
settings can be set via environment variables as well as flags.
--------------------------------------------------------------------
Flag (global) Variable Format Default
--------------------------------------------------------------------
--cache-dir GOCACHE_DIR path (required)
--bucket GOCACHE_S3_BUCKET string (required)
--region GOCACHE_S3_REGION string based on bucket
--s3-path-style GOCACHE_S3_PATH_STYLE bool false
--s3-endpoint-url GOCACHE_S3_ENDPOINT_URL string ""
--prefix GOCACHE_KEY_PREFIX string ""
--min-upload-size GOCACHE_MIN_SIZE int64 0
--metrics GOCACHE_METRICS bool false
--expiry GOCACHE_EXPIRY duration 0
-c GOCACHE_CONCURRENCY int runtime.NumCPU
-u GOCACHE_S3_CONCURRENCY duration runtime.NumCPU
-v GOCACHE_VERBOSE bool false
--debug GOCACHE_DEBUG int 0 (see "help debug")
--------------------------------------------------------------------
Flag (serve) Variable Format Default
--------------------------------------------------------------------
--plugin GOCACHE_PLUGIN port (required)
--http GOCACHE_HTTP [host]:port ""
--modproxy GOCACHE_MODPROXY bool false
--revproxy GOCACHE_REVPROXY host,... ""
--sumdb GOCACHE_SUMDB host,... ""
See also: "help configure".`,
},
{
Name: "direct-mode",
Help: `Run the plugin directly as a subprocess of the toolchain.
export GOCACHEPROG="go-cache-plugin --cache-dir=/tmp/gocache --bucket ..."
go build ...
Alternatively:
export GOCACHE_DIR=/tmp/gocache
export GOCACHE_S3_BUCKET=cache-bucket-name
export GOCACHEPROG=go-cache-plugin
go build ...
In this mode, you must specify the --cache-dir and --bucket settings.`,
},
{
Name: "serve-mode",
Help: `Run the plugin as a standalone service.
Use the "serve" subcommand:
go-cache-plugin serve \
--cache-dir=/tmp/gocache --bucket=$B \
--plugin $PORT
You can then use the "connect" subcommand to wire up the toolchain:
export GOCACHEPROG="go-cache-plugin connect $PORT"
In this mode, the server must have credentials to access to S3, but the
toolchain process does not need AWS credentials.`,
},
{
Name: "module-proxy",
Help: `Run a Go module and sum database proxy.
With the --modproxy flag, the server will also export an HTTP proxy for the
public Go module proxy (proxy.golang.org) and sum DB (sum.golang.org) at the
given address:
go-cache-plugin serve ... --http=localhost:5970 --modproxy
To use the module proxy, set the standard GOPROXY environment variable.
The module proxy serves under the path "/mod/":
export GOPROXY=http://localhost:5970/mod
export GOCACHEPROG="go-cache-plugin connect $PORT"
go build ...
To use the sum DB proxy, set the GOSUMDB environment variable.
The proxy path for a given sumdb (e.g., sum.golang.org) has this format:
export GOSUMDB="sum.golang.org http://localhost:5970/mod/sumdb/sum.golang.org"
See also: https://proxy.golang.org/`,
},
{
Name: "reverse-proxy",
Help: `Run a caching reverse proxy.
With the --revproxy flag, the server will also export a caching reverse
proxy for the specified hosts, given as a comma-separated list:
go-cache-plugin serve ... \
--http=localhost:5970 \
--revproxy='api.example.com,www.example.com'
When this is enabled, you can configure this address as an HTTP proxy:
HTTPS_PROXY=localhost:5970 curl https://api.example.com/foo
The proxy supports both HTTP and HTTPS backends. For HTTPS proxy targets, the
server generates its own TLS certificate, and tries to install a custom signing
cert so that other tools will validate it. The ability to do this varies by
system and configuration, however.`,
},
{
Name: "debug",
Help: `Enable detailed debug logging.
The --debug flag enables (very) verbose debug logging for the components of the
cache plugin. The value of the flag is a bit mask of:
1: Go build cache
2: Go module proxy and sum database
4: HTTP reverse proxy
The default is 0 (no debug logging).`,
},
}

View File

@@ -0,0 +1,319 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"expvar"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
"github.com/creachadair/command"
"github.com/creachadair/gocache"
"github.com/creachadair/gocache/cachedir"
"github.com/creachadair/mhttp/proxyconn"
"github.com/creachadair/taskgroup"
"github.com/creachadair/tlsutil"
"github.com/goproxy/goproxy"
"github.com/tailscale/go-cache-plugin/lib/gobuild"
"github.com/tailscale/go-cache-plugin/lib/modproxy"
"github.com/tailscale/go-cache-plugin/lib/revproxy"
"github.com/tailscale/go-cache-plugin/lib/s3util"
"tailscale.com/tsweb"
)
func initCacheServer(env *command.Env) (*gocache.Server, *s3util.Client, error) {
switch {
case flags.CacheDir == "":
return nil, nil, env.Usagef("you must provide a --cache-dir")
case flags.S3Bucket == "":
return nil, nil, env.Usagef("you must provide an S3 --bucket name")
}
region, err := getBucketRegion(env.Context(), flags.S3Bucket)
if err != nil {
return nil, nil, env.Usagef("you must provide an S3 --region name")
}
dir, err := cachedir.New(flags.CacheDir)
if err != nil {
return nil, nil, fmt.Errorf("create local cache: %w", err)
}
opts := []func(*config.LoadOptions) error{
config.WithRegion(region),
config.WithResponseChecksumValidation(aws.ResponseChecksumValidationWhenRequired),
}
if flags.S3Endpoint != "" {
vprintf("S3 endpoint URL: %s", flags.S3Endpoint)
opts = append(opts, config.WithBaseEndpoint(flags.S3Endpoint))
}
cfg, err := config.LoadDefaultConfig(env.Context(), opts...)
if err != nil {
return nil, nil, fmt.Errorf("load AWS config: %w", err)
}
vprintf("local cache directory: %s", flags.CacheDir)
vprintf("S3 cache bucket %q (%s)", flags.S3Bucket, region)
client := &s3util.Client{
Client: s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = flags.S3PathStyle
// Cloudflare SigV4 Compatibility Fix
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
return stack.Finalize.Insert(middleware.FinalizeMiddlewareFunc("RemoveAcceptEncoding",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
middleware.FinalizeOutput, middleware.Metadata, error,
) {
if req, ok := in.Request.(*smithyhttp.Request); ok {
req.Header.Del("Accept-Encoding")
req.Header.Del("Amz-Sdk-Invocation-Id")
req.Header.Del("Amz-Sdk-Request")
}
return next.HandleFinalize(ctx, in)
}), "Signing", middleware.Before)
})
}),
Bucket: flags.S3Bucket,
}
cache := &gobuild.S3Cache{
Local: dir,
S3Client: client,
KeyPrefix: flags.KeyPrefix,
MinUploadSize: flags.MinUploadSize,
UploadConcurrency: flags.S3Concurrency,
}
cache.SetMetrics(env.Context(), expvar.NewMap("gocache_host"))
close := cache.Close
if flags.Expiration > 0 {
dirClose := dir.Cleanup(flags.Expiration)
close = func(ctx context.Context) error {
return errors.Join(cache.Close(ctx), dirClose(ctx))
}
}
s := &gocache.Server{
Get: cache.Get,
Put: cache.Put,
Close: close,
SetMetrics: cache.SetMetrics,
MaxRequests: flags.Concurrency,
Logf: vprintf,
LogRequests: flags.DebugLog&debugBuildCache != 0,
}
expvar.Publish("gocache_server", s.Metrics().Get("server"))
return s, client, nil
}
// initModProxy initializes a Go module proxy if one is enabled. If not, it
// returns a nil handler without error. The caller must defer a call to the
// cleanup function unless an error is reported.
func initModProxy(env *command.Env, s3c *s3util.Client) (_ http.Handler, cleanup func(), _ error) {
if !serveFlags.ModProxy {
return nil, noop, nil // OK, proxy is disabled
} else if serveFlags.HTTP == "" {
return nil, nil, env.Usagef("you must set --http to enable --modproxy")
}
modCachePath := filepath.Join(flags.CacheDir, "module")
if err := os.MkdirAll(modCachePath, 0755); err != nil {
return nil, nil, fmt.Errorf("create module cache: %w", err)
}
cacher := &modproxy.S3Cacher{
Local: modCachePath,
S3Client: s3c,
KeyPrefix: path.Join(flags.KeyPrefix, "module"),
MaxTasks: flags.S3Concurrency,
Logf: vprintf,
LogRequests: flags.DebugLog&debugModProxy != 0,
}
cleanup = func() { vprintf("close cacher (err=%v)", cacher.Close()) }
proxy := &goproxy.Goproxy{
Fetcher: &goproxy.GoFetcher{
// As configured, the fetcher should never shell out to the go
// tool. Specifically, because we set GOPROXY and do not set any
// bypass via GONOPROXY, GOPRIVATE, etc., we will only attempt to
// proxy for the specific server(s) listed in Env.
GoBin: "/bin/false",
Env: []string{"GOPROXY=https://proxy.golang.org"},
},
Cacher: cacher,
ProxiedSumDBs: []string{"sum.golang.org"}, // default, see below
}
vprintf("enabling Go module proxy")
if serveFlags.SumDB != "" {
proxy.ProxiedSumDBs = strings.Split(serveFlags.SumDB, ",")
vprintf("enabling sum DB proxy for %s", strings.Join(proxy.ProxiedSumDBs, ", "))
}
expvar.Publish("modcache", cacher.Metrics())
return http.StripPrefix("/mod", proxy), cleanup, nil
}
// initRevProxy initializes a reverse proxy if one is enabled. If not, it
// returns nil, nil to indicate a proxy was not requested. Otherwise, it
// returns a [http.Handler] to dispatch reverse proxy requests.
//
// The reverse proxy runs two collaborating HTTP servers:
//
// - The "inner" server is the proxy itself, which checks for cached values,
// forwards client requests to the remote origin (if necessary), and
// updates the cache with responses. The [revproxy.Server] is a lightweight
// wrapper around [net/http/httputil.ReverseProxy].
//
// - The "outer" server is a bridge, that intercepts client requests. The
// bridge forwards plain HTTP requests directly to the inner server. For
// HTTPS CONNECT requests, the bridge hijacks the client connection and
// terminates TLS using a locally-signed certificate, and forwards the
// decrypted client requests to the inner caching proxy.
//
// The outer bridge is what receives requests routed by the main HTTP endpoint;
// the inner server gets all its input via the bridge:
//
// +------------+ +--------+
// client --[proxy-request]->|HTTP handler+--->| bridge +--CONNECT--+
// +------------+ +---+----+ |
// | |
// HTTP v
// +-------------+ | +---------------+
// [response]<---| cache proxy |<------+--------+ terminate TLS |
// +-------------+ +---------------+
//
// To the main HTTP listener, the bridge is an [http.Handler] that serves
// requests routed to it. To the inner server, the bridge is a [net.Listener],
// a source of client connections (with TLS terminated).
func initRevProxy(env *command.Env, s3c *s3util.Client, g *taskgroup.Group) (http.Handler, error) {
if serveFlags.RevProxy == "" {
return nil, nil // OK, proxy is disabled
} else if serveFlags.HTTP == "" {
return nil, env.Usagef("you must set --http to enable --revproxy")
}
revCachePath := filepath.Join(flags.CacheDir, "revproxy")
if err := os.MkdirAll(revCachePath, 0755); err != nil {
return nil, fmt.Errorf("create revproxy cache: %w", err)
}
hosts := strings.Split(serveFlags.RevProxy, ",")
// Issue a server certificate so we can proxy HTTPS requests.
cert, err := initServerCert(env, hosts)
if err != nil {
return nil, err
}
proxy := &revproxy.Server{
Targets: hosts,
Local: revCachePath,
S3Client: s3c,
KeyPrefix: path.Join(flags.KeyPrefix, "revproxy"),
Logf: vprintf,
LogRequests: flags.DebugLog&debugRevProxy != 0,
}
bridge := &proxyconn.Bridge{
Addrs: hosts,
Handler: proxy, // forward HTTP requests unencrypted to the proxy
Logf: vprintf,
// Forward connections not matching Addrs directly to their targets.
ForwardConnect: true,
}
expvar.Publish("proxyconn", bridge.Metrics())
// Run the proxy on its own separate server with TLS support. This server
// does not listen on a real network; it receives connections forwarded by
// the bridge internally from successful CONNECT requests.
psrv := &http.Server{
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
// Ordinarly HTTP proxy requests are delegated directly.
Handler: proxy,
}
g.Go(func() error { return psrv.ServeTLS(bridge, "", "") })
g.Run(func() {
<-env.Context().Done()
vprintf("stopping proxy bridge")
psrv.Shutdown(context.Background())
})
expvar.Publish("revcache", proxy.Metrics())
vprintf("enabling reverse proxy for %s", strings.Join(proxy.Targets, ", "))
return bridge, nil
}
// initServerCert creates a signed certificate advertising the specified host
// names, for use in creating a TLS server.
func initServerCert(env *command.Env, hosts []string) (tls.Certificate, error) {
ca, err := tlsutil.NewSigningCert(24*time.Hour, &x509.Certificate{
Subject: pkix.Name{Organization: []string{"Tailscale build automation"}},
})
if err != nil {
return tls.Certificate{}, fmt.Errorf("generate signing cert: %w", err)
}
if err := installSigningCert(env, ca); err != nil {
vprintf("WARNING: %v", err)
} else {
vprintf("installed signing cert in system store")
// TODO(creachadair): We should probably clean up old expired certs.
// This is OK for ephemeral build/CI workers, though.
}
sc, err := tlsutil.NewServerCert(24*time.Hour, ca, &x509.Certificate{
Subject: pkix.Name{Organization: []string{"Go cache plugin reverse proxy"}},
DNSNames: hosts,
})
if err != nil {
return tls.Certificate{}, fmt.Errorf("generate server cert: %w", err)
}
return sc.TLSCertificate()
}
// makeHandler returns an HTTP handler that dispatches requests to debug
// handlers or to the specified proxies, if they are defined.
func makeHandler(modProxy, revProxy http.Handler) http.HandlerFunc {
mux := http.NewServeMux()
tsweb.Debugger(mux)
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Host != "" && r.URL.Host == r.Host {
// The caller wants us to proxy for them.
if revProxy != nil {
revProxy.ServeHTTP(w, r)
return
}
// We don't allow proxying in this configuration, bug off.
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
return
}
path := r.URL.Path
if strings.HasPrefix(path, "/debug/") {
mux.ServeHTTP(w, r)
return
}
if modProxy != nil && r.Method == http.MethodGet && strings.HasPrefix(path, "/mod/") {
modProxy.ServeHTTP(w, r)
return
}
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
}
// noop is a cleanup function that does nothing, used as a default.
func noop() {}

View File

@@ -0,0 +1,58 @@
module github.com/tailscale/go-cache-plugin
go 1.26.1
require (
github.com/aws/aws-sdk-go-v2 v1.41.6
github.com/aws/aws-sdk-go-v2/config v1.32.16
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
github.com/creachadair/atomicfile v0.4.1
github.com/creachadair/command v0.2.2
github.com/creachadair/flax v0.0.5
github.com/creachadair/gocache v0.0.0-20260418161958-99bafa82eafe
github.com/creachadair/mds v0.27.1
github.com/creachadair/mhttp v0.0.0-20260407161248-6866af4c556b
github.com/creachadair/scheddle v0.0.0-20260418161627-87a4a0c853c4
github.com/creachadair/taskgroup v0.14.2
github.com/creachadair/tlsutil v0.0.0-20260218173745-49b0059fedaf
github.com/goproxy/goproxy v0.21.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0
tailscale.com v1.96.5
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/creachadair/msync v0.8.1 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
honnef.co/go/tools v0.7.0 // indirect
)
retract (
v0.0.19
v0.0.16
)
tool honnef.co/go/tools/cmd/staticcheck

View File

@@ -0,0 +1,98 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/creachadair/atomicfile v0.4.1 h1:72SopAy00u42/iL0p3CZILv51oUEem8uDbxUfnzmsXU=
github.com/creachadair/atomicfile v0.4.1/go.mod h1:+PsFcWa9SZuK+xnykzupXErqESId5eCGsuqFWkazMQI=
github.com/creachadair/command v0.2.2 h1:4RGsUhqFf1imFC+vMWOOCiQdncThCdcdMJp0JNCjxxc=
github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E=
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/gocache v0.0.0-20260418161958-99bafa82eafe h1:u136zrzzDUBqNQKPLiclaxza2spo+xtz06Mho09Vb/M=
github.com/creachadair/gocache v0.0.0-20260418161958-99bafa82eafe/go.mod h1:wmtczGXKsjsSCKcMzEDDPofBdwXKfOM5fX0TMg1nsvU=
github.com/creachadair/mds v0.27.1 h1:GlO1tPbrsaoafkF6mz7dFutkGXAtIfQLI450u0ypqwA=
github.com/creachadair/mds v0.27.1/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creachadair/mhttp v0.0.0-20260407161248-6866af4c556b h1:bIvrALyE2ixkCvz9LADfOIuhkUMYWMBsvQ29xe70nI0=
github.com/creachadair/mhttp v0.0.0-20260407161248-6866af4c556b/go.mod h1:eV+igunEfCBbgpM0gkDK/25/ukdeNYXH9PZuuEWZTDo=
github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM=
github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4=
github.com/creachadair/scheddle v0.0.0-20260418161627-87a4a0c853c4 h1:uYu6k39F6Sz4Y5Zb+R2ZnhE1S5ehCw4lknwrt4bm7NI=
github.com/creachadair/scheddle v0.0.0-20260418161627-87a4a0c853c4/go.mod h1:PE4owEX+08lTR1VNomiSFFh6DzkmmzlBgysEOq8zwdI=
github.com/creachadair/taskgroup v0.14.2 h1:vPHw50VFJ98Eb+h3S1RYRejK3DE0xpGCXb2ZFtHfFeA=
github.com/creachadair/taskgroup v0.14.2/go.mod h1:FV5K1BzHUCnTVc04O1WLO+iLmvjfOTnYLTcZaC9ZxbM=
github.com/creachadair/tlsutil v0.0.0-20260218173745-49b0059fedaf h1:6ZqacBCoIdOSlwh03zr3TMqA5VyuFpzUS7E2yzfjq7w=
github.com/creachadair/tlsutil v0.0.0-20260218173745-49b0059fedaf/go.mod h1:YUng4wUBh60nfswe0FwLeBOpoIsrW6ReXt9Zr9k55Sg=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/goproxy/goproxy v0.21.0 h1:S3JPCp/WLqLl7X40eo5FFiAbBZwD8S6TzGPBITwfHlo=
github.com/goproxy/goproxy v0.21.0/go.mod h1:4j3iRV76B133PK4sOzVYWSBs/SsE5ovbZsdceoC5f+w=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=

View File

@@ -0,0 +1,267 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package gobuild implements callbacks for a gocache.Server that store data
// into an S3 bucket through a local directory.
package gobuild
import (
"context"
"errors"
"expvar"
"fmt"
"io/fs"
"os"
"path"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/creachadair/gocache"
"github.com/creachadair/gocache/cachedir"
"github.com/creachadair/taskgroup"
"github.com/tailscale/go-cache-plugin/lib/s3util"
)
// S3Cache implements callbacks for a gocache.Server using an S3 bucket for
// backing store with a local directory for staging.
//
// # Remote Cache Layout
//
// Within the designated S3 bucket, keys are organized into two groups. Each
// action is stored in a file named:
//
// [<prefix>/]action/<xx>/<action-id>
//
// Each output object is stored in a file named:
//
// [<prefix>/]output/<xx>/<object-id>
//
// The object and action IDs are encoded as lower-case hexadecimal strings,
// with "<xx>" denoting the first two bytes of the ID to partition the space.
//
// The contents of each action file have the format:
//
// <output-id> <timestamp>
//
// where the object ID is hex encoded and the timestamp is Unix nanoseconds.
// The object file contains just the binary data of the object.
type S3Cache struct {
// Local is the local cache directory where actions and objects are staged.
// It must be non-nil. A local stage is required because the Go toolchain
// needs direct access to read the files reported by the cache.
// It is safe to use a tmpfs directory.
Local *cachedir.Dir
// S3Client is the S3 client used to read and write cache entries to the
// backing store. It must be non-nil.
S3Client *s3util.Client
// KeyPrefix, if non-empty, is prepended to each key stored into S3, with an
// intervening slash.
KeyPrefix string
// MinUploadSize, if positive, defines a minimum object size in bytes below
// which the cache will not write the object to S3.
MinUploadSize int64
// UploadConcurrency, if positive, defines the maximum number of concurrent
// tasks for writing cache entries to S3. If zero or negative, it uses
// runtime.NumCPU.
UploadConcurrency int
// Tracks tasks pushing cache writes to S3.
initOnce sync.Once
push *taskgroup.Group
start func(taskgroup.Task)
getLocalHit expvar.Int // count of Get hits in the local cache
getFaultHit expvar.Int // count of Get hits faulted in from S3
getFaultMiss expvar.Int // count of Get faults that were misses
putSkipSmall expvar.Int // count of "small" objects not written to S3
putS3Found expvar.Int // count of objects not written to S3 because they were already present
putS3Action expvar.Int // count of actions written to S3
putS3Object expvar.Int // count of objects written to S3
putS3Error expvar.Int // count of errors writing to S3
}
func (s *S3Cache) init() {
s.initOnce.Do(func() {
s.push, s.start = taskgroup.New(nil).Limit(s.uploadConcurrency())
})
}
// Get implements the corresponding callback of the cache protocol.
func (s *S3Cache) Get(ctx context.Context, actionID string) (outputID, diskPath string, _ error) {
s.init()
objID, diskPath, err := s.Local.Get(ctx, actionID)
if err == nil && objID != "" && diskPath != "" {
s.getLocalHit.Add(1)
return objID, diskPath, nil // cache hit, OK
}
// Reaching here, either we got a cache miss or an error reading from local.
// Try reading the action from S3.
action, err := s.S3Client.GetData(ctx, s.actionKey(actionID))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
s.getFaultMiss.Add(1)
return "", "", nil // cache miss, OK
}
return "", "", fmt.Errorf("[s3] read action %s: %w", actionID, err)
}
// We got an action hit remotely, try to update the local copy.
outputID, mtime, err := parseAction(action)
if err != nil {
return "", "", err
}
object, size, err := s.S3Client.Get(ctx, s.outputKey(outputID))
if err != nil {
// At this point we know the action exists, so if we can't read the
// object report it as an error rather than a cache miss.
return "", "", fmt.Errorf("[s3] read object %s: %w", outputID, err)
}
defer object.Close()
s.getFaultHit.Add(1)
// Now we should have the body; poke it into the local cache. Preserve the
// modification timestamp recorded with the original action.
diskPath, err = s.Local.Put(ctx, gocache.Object{
ActionID: actionID,
OutputID: outputID,
Size: size,
Body: object,
ModTime: mtime,
})
return outputID, diskPath, err
}
// Put implements the corresponding callback of the cache protocol.
func (s *S3Cache) Put(ctx context.Context, obj gocache.Object) (diskPath string, _ error) {
s.init()
// Compute an etag so we can do a conditional put on the object data.
// We do not rely on it as a secure checksum. The toolchain verifies the
// content address against the bits we actually store.
etr := s3util.NewETagReader(obj.Body)
obj.Body = etr
diskPath, err := s.Local.Put(ctx, obj)
if err != nil {
return "", err // don't bother trying to forward it to the remote
}
if obj.Size < s.MinUploadSize {
s.putSkipSmall.Add(1)
return diskPath, nil // don't bother uploading this, it's too small
}
// Try to push the record to S3 in the background.
s.start(func() error {
// Override the context with a separate timeout in case S3 is farkakte.
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Minute)
defer cancel()
// Stage 1: Maybe write the object. Do this before writing the action
// record so we are less likely to get a spurious miss later.
mtime, err := s.maybePutObject(sctx, obj.OutputID, diskPath, etr.ETag())
if err != nil {
return err
}
// Stage 2: Write the action record.
if err := s.S3Client.Put(ctx, s.actionKey(obj.ActionID),
strings.NewReader(fmt.Sprintf("%s %d", obj.OutputID, mtime.UnixNano()))); err != nil {
gocache.Logf(ctx, "write action %s: %v", obj.ActionID, err)
return err
}
s.putS3Action.Add(1)
return nil
})
return diskPath, nil
}
// Close implements the corresponding callback of the cache protocol.
func (s *S3Cache) Close(ctx context.Context) error {
if s.push != nil {
gocache.Logf(ctx, "waiting for uploads...")
wstart := time.Now()
s.push.Wait()
gocache.Logf(ctx, "uploads complete (%v elapsed)", time.Since(wstart).Round(10*time.Microsecond))
}
return nil
}
// SetMetrics implements the corresponding server callback.
func (s *S3Cache) SetMetrics(_ context.Context, m *expvar.Map) {
m.Set("get_local_hit", &s.getLocalHit)
m.Set("get_fault_hit", &s.getFaultHit)
m.Set("get_fault_miss", &s.getFaultMiss)
m.Set("put_skip_small", &s.putSkipSmall)
m.Set("put_s3_found", &s.putS3Found)
m.Set("put_s3_action", &s.putS3Action)
m.Set("put_s3_object", &s.putS3Object)
m.Set("put_s3_error", &s.putS3Error)
}
// maybePutObject writes the specified object contents to S3 if there is not
// already a matching key with the same etag. It returns the modified time of
// the object file, whether or not it was sent to S3.
func (s *S3Cache) maybePutObject(ctx context.Context, outputID, diskPath, etag string) (time.Time, error) {
f, err := os.Open(diskPath)
if err != nil {
gocache.Logf(ctx, "[s3] open local object %s: %v", outputID, err)
return time.Time{}, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return time.Time{}, err
}
written, err := s.S3Client.PutCond(ctx, s.outputKey(outputID), etag, f)
if err != nil {
s.putS3Error.Add(1)
gocache.Logf(ctx, "[s3] put object %s: %v", outputID, err)
return fi.ModTime(), err
}
if written {
s.putS3Found.Add(1)
return fi.ModTime(), nil // already present and matching
}
s.putS3Object.Add(1)
return fi.ModTime(), nil
}
// makeKey assembles a complete key from the specified parts, including the key
// prefix if one is defined.
func (s *S3Cache) makeKey(parts ...string) string {
return path.Join(s.KeyPrefix, path.Join(parts...))
}
func (s *S3Cache) actionKey(id string) string { return s.makeKey("action", id[:2], id) }
func (s *S3Cache) outputKey(id string) string { return s.makeKey("output", id[:2], id) }
func (s *S3Cache) uploadConcurrency() int {
if s.UploadConcurrency <= 0 {
return runtime.NumCPU()
}
return s.UploadConcurrency
}
func parseAction(data []byte) (outputID string, mtime time.Time, _ error) {
fs := strings.Fields(string(data))
if len(fs) != 2 {
return "", time.Time{}, errors.New("invalid action record")
}
ts, err := strconv.ParseInt(fs[1], 10, 64)
if err != nil {
return "", time.Time{}, fmt.Errorf("invalid timestamp: %w", err)
}
return fs[0], time.Unix(ts/1e9, ts%1e9), nil
}

View File

@@ -0,0 +1,323 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package modproxy implements components of a Go module proxy that caches
// files locally on disk, backed by objects in an S3 bucket.
package modproxy
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"expvar"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/creachadair/atomicfile"
"github.com/creachadair/taskgroup"
"github.com/goproxy/goproxy"
"github.com/tailscale/go-cache-plugin/lib/s3util"
"golang.org/x/sync/semaphore"
)
var _ goproxy.Cacher = (*S3Cacher)(nil)
// S3Cacher implements the [github.com/goproxy/goproxy.Cacher] interface using
// a local disk cache backed by an S3 bucket.
//
// # Cache Layout
//
// Module cache files are stored under a SHA256 digest of the filename
// presented to the cache, encoded as hex and partitioned by the first two
// bytes of the digest:
//
// For example:
//
// SHA256("fizzlepug") → 160db4d719252162c87a9169e26deda33d2340770d0d540fd4c580c55008b2d6
// <cache-dir>/module/16/160db4d719252162c87a9169e26deda33d2340770d0d540fd4c580c55008b2d6
//
// When files are stored in S3, the same naming convention is used, but with
// the specified key prefix instead:
//
// <key-prefix>/module/16/0db4d719252162c87a9169e26deda33d2340770d0d540fd4c580c55008b2d6
type S3Cacher struct {
// Local is the path of a local cache directory where modules are cached.
// It must be non-empty.
Local string
// S3Client is the S3 client used to read and write cache entries to the
// backing store. It must be non-nil.
S3Client *s3util.Client
// KeyPrefix, if non-empty, is prepended to each key stored into S3, with an
// intervening slash.
KeyPrefix string
// MaxTasks, if positive, limits the number of concurrent tasks that may be
// interacting with S3. If zero or negative, the default is
// [runtime.NumCPU].
MaxTasks int
// Logf, if non-nil, is used to write log messages. If nil, logs are
// discarded.
Logf func(string, ...any)
// LogRequests, if true, enables detailed (but noisy) debug logging of all
// requests handled by the cache. Logs are written to Logf.
//
// Each result is presented in the format:
//
// B <op> "<name>" (<digest>)
// E <op> "<name>", err=<error>, <time> elapsed
//
// Where the operations are "GET" and "PUT". The "B" line is when the
// operation began, and "E" when it ended. When a GET operation successfully
// faults in a result from S3, the log is:
//
// F GET "<name>" hit (<digest>)
//
// When a PUT operation finishes writing a value behind to S3, the log is:
//
// W PUT "<name>", err=<error>, <time> elapsed
//
LogRequests bool
// Tracks tasks interacting with S3 in the background.
initOnce sync.Once
tasks *taskgroup.Group
start func(taskgroup.Task)
sema *semaphore.Weighted
pathError expvar.Int // errors constructing file paths
getRequest expvar.Int // total number of Get requests
getLocalHit expvar.Int // get: hit in local directory
getLocalMiss expvar.Int // get: miss in local directory
getFaultHit expvar.Int // get: hit in S3
getFaultMiss expvar.Int // get: miss in S3
getLocalError expvar.Int // get: error reading the local directory
getFaultError expvar.Int // get: error reading from S3
getLocalBytes expvar.Int // get: total bytes fetched from the local directory
getS3Bytes expvar.Int // get: total bytes fetched from S3
putRequest expvar.Int // total number of Put requests
putLocalHit expvar.Int // put: put of object already stored locally
putLocalError expvar.Int // put: error writing the local directory
putS3Error expvar.Int // put: error writing to S3
putLocalBytes expvar.Int // put: total bytes written to the local directory
putS3Bytes expvar.Int // put: total bytes written to S3
}
func (c *S3Cacher) init() {
c.initOnce.Do(func() {
nt := c.MaxTasks
if nt <= 0 {
nt = runtime.NumCPU()
}
c.tasks, c.start = taskgroup.New(nil).Limit(nt)
c.sema = semaphore.NewWeighted(int64(nt))
})
}
// Get implements a method of the goproxy.Cacher interface. It reports cache
// hits out of the local directory if available, or faults in from S3.
func (c *S3Cacher) Get(ctx context.Context, name string) (_ io.ReadCloser, oerr error) {
c.init()
c.getRequest.Add(1)
start := time.Now()
hash, path, err := c.makePath(name)
c.vlogf("mc B GET %q (%s)", name, hash)
defer func() { c.vlogf("mc E GET %q, err=%v, %v elapsed", name, oerr, time.Since(start)) }()
if err != nil {
return nil, err
}
// Check whether the file already exists locally.
if rc, size, err := openReader(path); err == nil {
c.getLocalHit.Add(1)
c.getLocalBytes.Add(size)
return rc, nil
} else if errors.Is(err, os.ErrNotExist) {
c.getLocalMiss.Add(1)
} else {
c.getLocalError.Add(1)
c.logf("get %q local: %v (treating as miss)", name, err)
}
// Local cache miss, fault in from S3.
if err := c.sema.Acquire(ctx, 1); err != nil {
return nil, err
}
defer c.sema.Release(1)
obj, _, err := c.S3Client.Get(ctx, c.makeKey(hash))
if errors.Is(err, fs.ErrNotExist) {
c.getFaultMiss.Add(1)
return nil, err
} else if err != nil {
c.getFaultError.Add(1)
return nil, err
}
defer obj.Close()
c.getFaultHit.Add(1)
c.vlogf("mc F GET %q hit (%s)", name, hash)
if _, err := c.putLocal(ctx, name, path, obj); err != nil {
return nil, err
}
rc, _, err := openReader(path)
return rc, err
}
// putLocal reports whether the specified path already exists in the local
// cache, and if not, writes data atomically into the path.
func (c *S3Cacher) putLocal(ctx context.Context, name, path string, data io.Reader) (bool, error) {
if _, err := os.Stat(path); err == nil {
return true, nil
}
nw, err := atomicfile.WriteAll(path, data, 0644)
c.putLocalBytes.Add(nw)
if err != nil {
c.putLocalError.Add(1)
}
return false, err
}
// Put implements a method of the goproxy.Cacher interface. It stores data into
// the local directory and then writes it back to S3 in the background.
func (c *S3Cacher) Put(ctx context.Context, name string, data io.ReadSeeker) (oerr error) {
c.init()
c.putRequest.Add(1)
start := time.Now()
hash, path, err := c.makePath(name)
c.vlogf("mc B PUT %q (%s)", name, hash)
defer func() { c.vlogf("mc E PUT %q, err=%v, %v elapsed", name, oerr, time.Since(start)) }()
if err != nil {
return err
}
if ok, err := c.putLocal(ctx, name, path, data); err != nil {
return err
} else if ok {
c.putLocalHit.Add(1)
return nil
}
// Try to push the object to S3 in the background.
f, size, err := openFileSize(path)
if err != nil {
c.putLocalError.Add(1)
return err
}
c.start(func() error {
defer f.Close()
start := time.Now()
// Override the context with a separate timeout in case S3 is farkakte.
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Minute)
defer cancel()
if err := c.S3Client.Put(sctx, c.makeKey(hash), f); err != nil {
c.putS3Error.Add(1)
c.logf("[s3] put %q failed: %v", name, err)
} else {
c.putS3Bytes.Add(size)
}
c.vlogf("mc W PUT %q, err=%v %v elapsed", name, err, time.Since(start))
return err
})
return nil
}
// Close waits until all background updates are complete.
func (c *S3Cacher) Close() error {
c.init()
return c.tasks.Wait()
}
// Metrics returns a map of cacher metrics. The caller is responsible for
// publishing these metrics.
func (c *S3Cacher) Metrics() *expvar.Map {
m := new(expvar.Map)
m.Set("path_error", &c.pathError)
m.Set("get_request", &c.getRequest)
m.Set("get_local_hit", &c.getLocalHit)
m.Set("get_local_miss", &c.getLocalMiss)
m.Set("get_fault_hit", &c.getFaultHit)
m.Set("get_fault_miss", &c.getFaultMiss)
m.Set("get_local_error", &c.getLocalError)
m.Set("get_local_bytes", &c.getLocalBytes)
m.Set("get_s3_bytes", &c.getS3Bytes)
m.Set("put_request", &c.putRequest)
m.Set("put_local_hit", &c.putLocalHit)
m.Set("put_local_error", &c.putLocalError)
m.Set("put_s3_error", &c.putS3Error)
m.Set("put_local_bytes", &c.putLocalBytes)
m.Set("put_s3_bytes", &c.putS3Bytes)
return m
}
func hashName(name string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(name)))
}
// makeKey assembles a complete S3 key from the specified parts, including the
// key prefix if one is defined.
func (c *S3Cacher) makeKey(hash string) string {
return path.Join(c.KeyPrefix, hash[:2], hash)
}
// makePath assembles a complete local cache path for the given name, creating
// the enclosing directory if needed.
func (c *S3Cacher) makePath(name string) (hash, path string, err error) {
hash = hashName(name)
path = filepath.Join(c.Local, hash[:2], hash)
err = os.MkdirAll(filepath.Dir(path), 0755)
if err != nil {
c.pathError.Add(1)
}
return hash, path, err
}
func (c *S3Cacher) logf(msg string, args ...any) {
if c.Logf != nil {
c.Logf(msg, args...)
}
}
func (c *S3Cacher) vlogf(msg string, args ...any) {
if c.LogRequests {
c.logf(msg, args...)
}
}
func openReader(path string) (_ io.ReadCloser, size int64, _ error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, 0, err
}
return io.NopCloser(bytes.NewReader(data)), int64(len(data)), nil
}
func openFileSize(path string) (io.ReadCloser, int64, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, fi.Size(), nil
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package revproxy
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/creachadair/atomicfile"
"github.com/creachadair/scheddle"
"github.com/creachadair/taskgroup"
)
// cacheLoadLocal reads cached headers and body from the local cache.
func (s *Server) cacheLoadLocal(hash string) ([]byte, http.Header, error) {
data, err := os.ReadFile(s.makePath(hash))
if err != nil {
return nil, nil, err
}
return parseCacheObject(data)
}
// cacheStoreLocal writes the contents of body to the local cache.
//
// The file format is a plain-text section at the top recording a subset of the
// response headers, followed by "\n\n", followed by the response body.
func (s *Server) cacheStoreLocal(hash string, hdr http.Header, body []byte) error {
path := s.makePath(hash)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return atomicfile.Tx(s.makePath(hash), 0644, func(f io.Writer) error {
return writeCacheObject(f, hdr, body)
})
}
// cacheLoadS3 reads cached headers and body from the remote S3 cache.
func (s *Server) cacheLoadS3(ctx context.Context, hash string) ([]byte, http.Header, error) {
data, err := s.S3Client.GetData(ctx, s.makeKey(hash))
if err != nil {
return nil, nil, err
}
return parseCacheObject(data)
}
// cacheStoreS3 returns a task that writes the contents of body to the remote
// S3 cache.
func (s *Server) cacheStoreS3(hash string, hdr http.Header, body []byte) taskgroup.Task {
var buf bytes.Buffer
writeCacheObject(&buf, hdr, body)
nb := buf.Len()
return func() error {
sctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
if err := s.S3Client.Put(sctx, s.makeKey(hash), &buf); err != nil {
s.logf("[s3] put %q failed: %v", hash, err)
s.rspPushError.Add(1)
} else {
s.rspPush.Add(1)
s.rspPushBytes.Add(int64(nb))
}
return nil
}
}
// cacheLoadMemory reads cached headers and body from the memory cache.
func (s *Server) cacheLoadMemory(hash string) ([]byte, http.Header, error) {
e, ok := s.mcache.Get(hash)
if !ok {
return nil, nil, fs.ErrNotExist
}
return e.body, e.header, nil
}
// cacheStoreMemory writes the contents of body to the memory cache.
func (s *Server) cacheStoreMemory(hash string, maxAge time.Duration, hdr http.Header, body []byte) {
s.mcache.Put(hash, memCacheEntry{
header: trimCacheHeader(hdr),
body: body,
})
s.expire.After(maxAge, scheddle.Run(func() {
s.mcache.Remove(hash)
}))
}
var keepHeader = []string{
"Cache-Control", "Content-Type", "Date", "Etag",
}
func trimCacheHeader(h http.Header) http.Header {
out := make(http.Header)
for _, name := range keepHeader {
if v := h.Get(name); v != "" {
out.Set(name, v)
}
}
return out
}
// parseCacheDbject parses cached object data to extract the body and headers.
func parseCacheObject(data []byte) ([]byte, http.Header, error) {
hdr, rest, ok := bytes.Cut(data, []byte("\n\n"))
if !ok {
return nil, nil, errors.New("invalid cache object: missing header")
}
h := make(http.Header)
for _, line := range strings.Split(string(hdr), "\n") {
name, value, ok := strings.Cut(line, ": ")
if ok {
h.Add(name, value)
}
}
return rest, h, nil
}
// writeCacheObject writes the specified response data into a cache object at w.
func writeCacheObject(w io.Writer, h http.Header, body []byte) error {
hprintf(w, h, "Content-Type", "application/octet-stream")
hprintf(w, h, "Date", "")
hprintf(w, h, "Etag", "")
fmt.Fprint(w, "\n")
_, err := w.Write(body)
return err
}
func hprintf(w io.Writer, h http.Header, name, fallback string) {
if v := h.Get(name); v != "" {
fmt.Fprintf(w, "%s: %s\n", name, v)
} else if fallback != "" {
fmt.Fprintf(w, "%s: %s\n", name, fallback)
}
}
// setXCacheInfo adds cache-specific headers to h.
func setXCacheInfo(h http.Header, result, hash string) {
h.Set("X-Cache", result)
if hash != "" {
h.Set("X-Cache-Id", hash[:12])
}
}
// memCacheEntry is the format of entries in the memory cache.
type memCacheEntry struct {
header http.Header
body []byte
}
func entrySize(e memCacheEntry) int64 { return int64(len(e.body)) }

View File

@@ -0,0 +1,407 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package revproxy implements a minimal HTTP reverse proxy that caches files
// locally on disk, backed by objects in an S3 bucket.
//
// # Limitations
//
// By default, only objects marked "immutable" by the target server are
// eligible to be cached. Volatile objects that specify a max-age are also
// cached in-memory, but are not persisted on disk or in S3. If we think it's
// worthwhile we can spend some time to add more elaborate cache pruning, but
// for now we're doing the simpler thing.
package revproxy
import (
"bytes"
"crypto/sha256"
"expvar"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/creachadair/mds/cache"
"github.com/creachadair/mds/mapset"
"github.com/creachadair/scheddle"
"github.com/creachadair/taskgroup"
"github.com/tailscale/go-cache-plugin/lib/s3util"
)
// Server is a caching reverse proxy server that caches successful responses to
// GET requests for certain designated domains.
//
// The host field of the request URL must match one of the configured targets.
// If not, the request is rejected with HTTP 502 (Bad Gateway). Otherwise, the
// request is forwarded. A successful response will be cached if the server's
// Cache-Control does not include "no-store", and does include "immutable".
//
// In addition, a successful response that is not immutable and specifies a
// max-age will be cached temporarily in-memory.
//
// # Cache Format
//
// A cached response is a file with a header section and the body, separated by
// a blank line. Only a subset of response headers are saved.
//
// # Cache Responses
//
// For requests handled by the proxy, the response includes an "X-Cache" header
// indicating how the response was obtained:
//
// - "hit, memory": The response was served out of the memory cache.
// - "hit, local": The response was served out of the local cache.
// - "hit, remote": The response was faulted in from S3.
// - "fetch, cached": The response was forwarded to the target and cached.
// - "fetch, uncached": The response was forwarded to the target and not cached.
//
// For results intersecting with the cache, it also reports a X-Cache-Id giving
// the storage key of the cache object.
type Server struct {
// Targets is the list of hosts for which the proxy should forward requests.
// Host names should be fully-qualified ("host.example.com").
Targets []string
// Local is the path of a local cache directory where responses are cached.
// It must be non-empty.
Local string
// S3Client is the S3 client used to read and write cache entries to the
// backing store. It must be non-nil
S3Client *s3util.Client
// KeyPrefix, if non-empty, is prepended to each key stored into S3, with an
// intervening slash.
KeyPrefix string
// Logf, if non-nil, is used to write log messages. If nil, logs are
// discarded.
Logf func(string, ...any)
// LogRequests, if true, enables detailed (but noisy) debug logging of all
// requests handled by the reverse proxy. Logs are written to Logf.
//
// Each request is presented in the format:
//
// B U:"<url>" H:<digest> C:<bool>
// E H:<digest> <disposition> B:<bytes> (<time> elapsed)
// - H:<digest> miss
//
// The "B" line is when the request began, and "E" when it was finished.
// The abbreviated fields are:
//
// U: -- request URL
// H: -- request URL digest (cache key)
// C: -- whether the request is cacheable (true/false)
// B: -- body size in bytes (for hits)
//
// The dispositions of a request are:
//
// hit mem -- cache hit in memory (volatile)
// hit disk -- cache hit in local disk
// hit S3 -- cache hit in S3 (faulted to disk)
// fetch -- fetched from the origin server
//
// On fetches, the "RC" tag indicates whether the response is cacheable,
// with "no" meaning it was not cached at all, "mem" meaning it was cached
// as a short-lived volatile response in memory, and "yes" meaning it was
// cached on disk (and S3).
LogRequests bool
initOnce sync.Once
tasks *taskgroup.Group
start func(taskgroup.Task)
mcache *cache.Cache[string, memCacheEntry] // short-lived mutable objects
expire *scheddle.Queue // cache expirations
reqReceived expvar.Int // total requests received
reqMemoryHit expvar.Int // hit in memory cache (volatile)
reqLocalHit expvar.Int // hit in local cache
reqLocalMiss expvar.Int // miss in local cache
reqFaultHit expvar.Int // hit in remote (S3) cache
reqFaultMiss expvar.Int // miss in remote (S3) cache
reqForward expvar.Int // request forwarded directly to upstream
rspSave expvar.Int // successful response saved in local cache
rspSaveMem expvar.Int // response saved in memory cache
rspSaveError expvar.Int // error saving to local cache
rspSaveBytes expvar.Int // bytes written to local cache
rspPush expvar.Int // successful response saved in S3
rspPushError expvar.Int // error saving to S3
rspPushBytes expvar.Int // bytes written to S3
rspNotCached expvar.Int // response not cached anywhere
}
func (s *Server) init() {
s.initOnce.Do(func() {
nt := runtime.NumCPU()
s.tasks, s.start = taskgroup.New(nil).Limit(nt)
s.mcache = cache.New(cache.LRU[string, memCacheEntry]().
WithLimit(10 << 20).
WithSizeFunc(entrySize),
)
s.expire = scheddle.NewQueue(nil)
})
}
// Metrics returns a map of cache server metrics for s. The caller is
// responsible to publish these metrics as desired.
func (s *Server) Metrics() *expvar.Map {
m := new(expvar.Map)
m.Set("req_received", &s.reqReceived)
m.Set("req_memory_hit", &s.reqMemoryHit)
m.Set("req_local_hit", &s.reqLocalHit)
m.Set("req_local_miss", &s.reqLocalMiss)
m.Set("req_fault_hit", &s.reqFaultHit)
m.Set("req_fault_miss", &s.reqFaultMiss)
m.Set("req_forward", &s.reqForward)
m.Set("rsp_save", &s.rspSave)
m.Set("rsp_save_memory", &s.rspSaveMem)
m.Set("rsp_save_error", &s.rspSaveError)
m.Set("rsp_save_bytes", &s.rspSaveBytes)
m.Set("rsp_push", &s.rspPush)
m.Set("rsp_push_error", &s.rspPushError)
m.Set("rsp_push_bytes", &s.rspPushBytes)
m.Set("rsp_not_cached", &s.rspNotCached)
return m
}
// ServeHTTP implements the [http.Handler] interface for the proxy.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.init()
s.reqReceived.Add(1)
// Check whether this request is to a target we are permitted to proxy for.
if !hostMatchesTarget(r.Host, s.Targets) {
s.logf("reject proxy request for non-target %q", r.Host)
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
return
}
hash := hashRequestURL(r.URL)
canCache := s.canCacheRequest(r)
s.vlogf("rp B U:%q H:%s C:%v", r.URL, hash, canCache)
start := time.Now()
if canCache {
// Check for a hit on this object in the memory cache.
if data, hdr, err := s.cacheLoadMemory(hash); err == nil {
s.reqMemoryHit.Add(1)
setXCacheInfo(hdr, "hit, memory", hash)
writeCachedResponse(w, hdr, data)
s.vlogf("rp E H:%s hit mem B:%d (%v elapsed)", hash, len(data), time.Since(start))
return
}
// Check for a hit on this object in the local cache.
if data, hdr, err := s.cacheLoadLocal(hash); err == nil {
s.reqLocalHit.Add(1)
setXCacheInfo(hdr, "hit, local", hash)
writeCachedResponse(w, hdr, data)
s.vlogf("rp E H:%s hit disk B:%d (%v elapsed)", hash, len(data), time.Since(start))
return
}
s.reqLocalMiss.Add(1)
// Fault in from S3.
if data, hdr, err := s.cacheLoadS3(r.Context(), hash); err == nil {
s.reqFaultHit.Add(1)
if err := s.cacheStoreLocal(hash, hdr, data); err != nil {
s.logf("update %q local: %v", hash, err)
}
setXCacheInfo(hdr, "hit, remote", hash)
writeCachedResponse(w, hdr, data)
s.vlogf("rp E H:%s hit S3 B:%d (%v elapsed)", hash, len(data), time.Since(start))
return
}
s.reqFaultMiss.Add(1)
s.vlogf("rp - H:%s miss", hash)
}
// Reaching here, the object is not already cached locally so we have to
// talk to the backend to get it. We need to do this whether or not it is
// cacheable. Note we handle each request with its own proxy instance, so
// that we can handle each response in context of this request.
s.reqForward.Add(1)
proxy := &httputil.ReverseProxy{Rewrite: s.rewriteRequest}
updateCache := func() {}
if canCache {
proxy.ModifyResponse = func(rsp *http.Response) error {
maxAge, isVolatile := s.canMemoryCache(rsp)
canCacheResponse := s.canCacheResponse(rsp)
if !canCacheResponse && !isVolatile {
// A response we cannot cache at all.
setXCacheInfo(rsp.Header, "fetch, uncached", "")
s.rspNotCached.Add(1)
s.vlogf("rp E H:%s fetch RC:no (%v elapsed)", hash, time.Since(start))
return nil
}
// Read out the whole response body so we can update the cache, and
// replace the response reader so we can copy it back to the caller.
var buf bytes.Buffer
rsp.Body = copyReader{
Reader: io.TeeReader(rsp.Body, &buf),
Closer: rsp.Body,
}
if !canCacheResponse && isVolatile {
// A volatile response we can cache temporarily.
setXCacheInfo(rsp.Header, "fetch, cached, volatile", hash)
updateCache = func() {
body := buf.Bytes()
s.cacheStoreMemory(hash, maxAge, rsp.Header, body)
s.rspSaveMem.Add(1)
// N.B. Don't persist on disk or in S3.
s.vlogf("rp E H:%s fetch RC:mem B:%d (%v elapsed)", hash, len(body), time.Since(start))
}
} else {
setXCacheInfo(rsp.Header, "fetch, cached", hash)
updateCache = func() {
body := buf.Bytes()
if err := s.cacheStoreLocal(hash, rsp.Header, body); err != nil {
s.rspSaveError.Add(1)
s.logf("save %q to cache: %v", hash, err)
// N.B.: Don't bother trying to forward to S3 in this case.
} else {
s.rspSave.Add(1)
s.rspSaveBytes.Add(int64(len(body)))
s.start(s.cacheStoreS3(hash, rsp.Header, body))
}
s.vlogf("rp E H:%s fetch RC:yes B:%d (%v elapsed)", hash, len(body), time.Since(start))
}
}
return nil
}
}
proxy.ServeHTTP(w, r)
updateCache()
}
// rewriteRequest rewrites the inbound request for routing to a target.
func (s *Server) rewriteRequest(pr *httputil.ProxyRequest) {
u, _ := url.ParseRequestURI(pr.In.RequestURI)
u.Host = pr.In.Host
if u.Scheme == "" {
u.Scheme = "https"
}
pr.Out.URL = u
pr.Out.Host = u.Host
}
type copyReader struct {
io.Reader
io.Closer
}
// makePath returns the local cache path for the specified request hash.
func (s *Server) makePath(hash string) string { return filepath.Join(s.Local, hash[:2], hash) }
// makeKey returns the S3 object key for the specified request hash.
func (s *Server) makeKey(hash string) string { return path.Join(s.KeyPrefix, hash[:2], hash) }
func (s *Server) logf(msg string, args ...any) {
if s.Logf != nil {
s.Logf(msg, args...)
}
}
func (s *Server) vlogf(msg string, args ...any) {
if s.LogRequests {
s.logf(msg, args...)
}
}
func hostMatchesTarget(host string, targets []string) bool {
return slices.Contains(targets, host)
}
// canCacheRequest reports whether r is a request whose response can be cached.
func (s *Server) canCacheRequest(r *http.Request) bool {
return r.Method == "GET" && !parseCacheControl(r.Header.Get("Cache-Control")).Keys.Has("no-store")
}
// canCacheResponse reports whether r is a response whose body can be cached.
func (s *Server) canCacheResponse(rsp *http.Response) bool {
if rsp.StatusCode != http.StatusOK {
return false
}
cc := parseCacheControl(rsp.Header.Get("Cache-Control"))
if cc.Keys.Has("no-store") {
return false
} else if cc.Keys.Has("immutable") {
return true
}
// We treat a response that is not immutable but requires validation as
// cacheable if its max-age is so long it doesn't matter.
const goodLongTime = 60 * 24 * time.Hour
return cc.Keys.Has("must-revalidate") && cc.MaxAge > goodLongTime
}
type cacheControl struct {
Keys mapset.Set[string]
MaxAge time.Duration
}
func parseCacheControl(s string) (out cacheControl) {
for _, v := range strings.Split(s, ",") {
key, val, ok := strings.Cut(strings.TrimSpace(v), "=")
if ok && key == "max-age" {
sec, err := strconv.Atoi(val)
if err == nil {
out.MaxAge = time.Duration(sec) * time.Second
}
}
out.Keys.Add(key)
}
return
}
// canMemoryCache reports whether r is a volatile response whose body can be
// cached temporarily, and if so returns the maxmimum length of time the cache
// entry should be valid for.
func (s *Server) canMemoryCache(rsp *http.Response) (time.Duration, bool) {
if rsp.StatusCode != http.StatusOK {
return 0, false
}
cc := parseCacheControl(rsp.Header.Get("Cache-Control"))
if cc.Keys.Has("no-store") || cc.Keys.Has("no-cache") {
// While no-cache doesn't mean we can't cache it, it requires
// re-validation before reusing the response, so treat that as if it were
// no-store.
return 0, false
}
// We'll cache things in memory if they aren't expected to last too long.
if cc.MaxAge > 0 && cc.MaxAge < time.Hour {
return cc.MaxAge, true
}
return 0, false
}
// hashRequest generates the storage digest for the specified request URL.
func hashRequestURL(u *url.URL) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(u.String())))
}
// writeCachedResponse generates an HTTP response for a cached result using the
// provided headers and body from the cache object.
func writeCachedResponse(w http.ResponseWriter, hdr http.Header, body []byte) {
wh := w.Header()
for name, vals := range hdr {
for _, val := range vals {
wh.Add(name, val)
}
}
w.Write(body)
}

View File

@@ -0,0 +1,169 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package s3util defines some helpful utilities for working with S3.
package s3util
import (
"cmp"
"context"
"crypto/md5"
"errors"
"fmt"
"hash"
"io"
"io/fs"
"os"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/creachadair/mds/value"
)
// IsNotExist reports whether err is an error indicating the requested resource
// was not found, taking into account S3 and standard library types.
func IsNotExist(err error) bool {
var e1 *types.NotFound
var e2 *types.NoSuchKey
if errors.As(err, &e1) || errors.As(err, &e2) {
return true
}
return errors.Is(err, os.ErrNotExist)
}
// BucketRegion reports the specified region for the given bucket using the
// GetBucketLocation API.
func BucketRegion(ctx context.Context, bucket string) (string, error) {
// The default AWS region, which we use for resolving the bucket location
// and also serves as the fallback if the API reports an empty region name.
// The API returns "" for buckets in this region for historical reasons.
const defaultRegion = "us-east-1"
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(defaultRegion))
if err != nil {
return "", err
}
cli := s3.NewFromConfig(cfg)
loc, err := cli.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: &bucket})
if err != nil {
return "", err
}
return cmp.Or(string(loc.LocationConstraint), defaultRegion), nil
}
// NewETagReader returns a new S3 ETag reader for the contents of r.
func NewETagReader(r io.Reader) ETagReader {
// Note: We use MD5 here because the S3 API requires it for an ETag, we do
// not rely on it as a secure checksum.
h := md5.New()
return ETagReader{r: io.TeeReader(r, h), hash: h}
}
// ETagReader implements the [io.Reader] interface by delegating to a nested
// reader. The ETag method returns a correctly-formatted S3 ETag for all the
// data that have been read so far (initially none).
type ETagReader struct {
r io.Reader
hash hash.Hash
}
// Read satisfies [io.Reader] by delegating to the wrapped reader.
func (e ETagReader) Read(data []byte) (int, error) { return e.r.Read(data) }
// ETag returns a correctly-formatted S3 etag for the contents of e that have
// been read so far.
func (e ETagReader) ETag() string { return fmt.Sprintf("%x", e.hash.Sum(nil)) }
// Client is a wrapper for an S3 client that provides basic read and write
// facilities to a specific bucket.
type Client struct {
Client *s3.Client
Bucket string
}
// Put writes the specified data to S3 under the given key.
func (c *Client) Put(ctx context.Context, key string, data io.Reader) error {
// Attempt to find the size of the input to send as a content length.
// If we can't do this, let the SDK figure it out.
var sizePtr *int64
switch t := data.(type) {
case sizer:
sizePtr = value.Ptr(t.Size())
case statter:
fi, err := t.Stat()
if err == nil {
sizePtr = value.Ptr(fi.Size())
}
case io.Seeker:
v, err := t.Seek(0, io.SeekEnd)
if err == nil {
sizePtr = &v
// Try to seek back to the beginning. If we cannot do this, fail out
// so we don't try to write a partial object.
_, err = t.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("[unexpected] seek failed: %w", err)
}
}
}
_, err := c.Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &c.Bucket,
Key: &key,
Body: data,
ContentLength: sizePtr,
})
return err
}
// Get returns the contents of the specified key from S3. On success, the
// returned reader contains the contents of the object, and the caller must
// close the reader when finished.
//
// If the key is not found, the resulting error satisfies [fs.ErrNotExist].
func (c *Client) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
rsp, err := c.Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &c.Bucket,
Key: &key,
})
if err != nil {
if IsNotExist(err) {
return nil, -1, fmt.Errorf("key %q: %w", key, fs.ErrNotExist)
}
return nil, -1, err
}
return rsp.Body, *rsp.ContentLength, nil
}
// GetData returns the contents of the specified key from S3. It is a shorthand
// for calling Get followed by io.ReadAll on the result.
func (c *Client) GetData(ctx context.Context, key string) ([]byte, error) {
rc, _, err := c.Get(ctx, key)
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// PutCond writes the specified data to S3 under the given key if the key does
// not already exist, or if its content differs from the given etag.
// The etag is an MD5 of the expected contents, encoded as lowercase hex digits.
// On success, written reports whether the object was written.
func (c *Client) PutCond(ctx context.Context, key, etag string, data io.Reader) (written bool, _ error) {
if _, err := c.Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &c.Bucket,
Key: &key,
IfMatch: &etag,
}); err == nil {
return false, nil
}
return true, c.Put(ctx, key, data)
}
// A sizer exports a Size method, e.g., [bytes.Reader] and similar.
type sizer interface{ Size() int64 }
// A statter exports a Stat method, e.g., [os.File] and similar.
type statter interface{ Stat() (fs.FileInfo, error) }

View File

@@ -0,0 +1,42 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package s3util_test
import (
"bytes"
"crypto/md5"
"encoding/hex"
"io"
"strings"
"testing"
"github.com/tailscale/go-cache-plugin/lib/s3util"
)
func TestETagReader(t *testing.T) {
const testInput = "the once and future kitten"
want := md5.Sum([]byte(testInput))
t.Logf("MD5(%q) = %x", testInput, want)
r := s3util.NewETagReader(strings.NewReader(testInput))
nr, err := io.Copy(io.Discard, r)
if err != nil {
t.Fatalf("Copy failed; %v", err)
} else if nr != int64(len(testInput)) {
t.Errorf("Copied %d bytes, want %d", nr, len(testInput))
}
etag := r.ETag()
t.Logf("Got etag %s for input %q", etag, testInput)
got, err := hex.DecodeString(r.ETag())
if err != nil {
t.Fatalf("Result is not valid hex: %s", r.ETag())
}
if !bytes.Equal(got, want[:]) {
t.Errorf("Wrong result: got %x, want %x", got, want)
}
}