Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cachew.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ gomod {
}

hermit { }

proxy { }
12 changes: 7 additions & 5 deletions cmd/cachewd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func newRegistries(scheduler jobscheduler.Provider, cloneManagerProvider gitclon
strategy.RegisterGitHubReleases(sr, tokenManagerProvider)
strategy.RegisterHermit(sr)
strategy.RegisterHost(sr)
strategy.RegisterHTTPProxy(sr)
git.Register(sr, scheduler, cloneManagerProvider, tokenManagerProvider)
gomod.Register(sr, cloneManagerProvider)

Expand All @@ -138,7 +139,7 @@ func printSchema(kctx *kong.Context, cr *cache.Registry, sr *strategy.Registry)
}
}

func newMux(ctx context.Context, cr *cache.Registry, sr *strategy.Registry, providersConfigHCL *hcl.AST, vars map[string]string) (*http.ServeMux, error) {
func newMux(ctx context.Context, cr *cache.Registry, sr *strategy.Registry, providersConfigHCL *hcl.AST, vars map[string]string) (http.Handler, error) {
mux := http.NewServeMux()

mux.HandleFunc("GET /_liveness", func(w http.ResponseWriter, _ *http.Request) {
Expand Down Expand Up @@ -171,11 +172,12 @@ func newMux(ctx context.Context, cr *cache.Registry, sr *strategy.Registry, prov
http.DefaultServeMux.ServeHTTP(w, r)
}))

if err := config.Load(ctx, cr, sr, providersConfigHCL, mux, vars); err != nil {
handler, err := config.Load(ctx, cr, sr, providersConfigHCL, mux, vars)
if err != nil {
return nil, errors.Errorf("load config: %w", err)
}

return mux, nil
return handler, nil
}

// extractPathPrefix extracts the strategy name, path prefix from a request path.
Expand All @@ -189,13 +191,13 @@ func extractPathPrefix(path string) string {
return prefix
}

func newServer(ctx context.Context, mux *http.ServeMux, bind string, metricsConfig metrics.Config) *http.Server {
func newServer(ctx context.Context, muxHandler http.Handler, bind string, metricsConfig metrics.Config) *http.Server {
logger := logging.FromContext(ctx)

var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attribute.String("cachew.http.path.prefix", extractPathPrefix(r.URL.Path)))
mux.ServeHTTP(w, r)
muxHandler.ServeHTTP(w, r)
})

// Add standard otelhttp middleware
Expand Down
36 changes: 26 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,17 @@ func Split[GlobalConfig any](ast *hcl.AST) (global, providers *hcl.AST) {
}

// Load HCL configuration and use that to construct the cache backend, and proxy strategies.
// It returns an http.Handler that wraps mux — any loaded strategies that implement
// strategy.Interceptor are applied as middleware before ServeMux route matching, so
// that they can inspect r.RequestURI rather than the path-only r.URL.Path.
func Load(
ctx context.Context,
cr *cache.Registry,
sr *strategy.Registry,
ast *hcl.AST,
mux *http.ServeMux,
vars map[string]string,
) error {
) (http.Handler, error) {
logger := logging.FromContext(ctx)
expandVars(ast, vars)

Expand All @@ -114,33 +117,46 @@ func Load(
strategyCandidates = append(strategyCandidates, node)
continue
} else if err != nil {
return errors.Errorf("%s: %w", node.Pos, err)
return nil, errors.Errorf("%s: %w", node.Pos, err)
}
caches = append(caches, c)

case *hcl.Attribute:
return errors.Errorf("%s: attributes are not allowed", node.Pos)
return nil, errors.Errorf("%s: attributes are not allowed", node.Pos)
}
}
if len(caches) == 0 {
return errors.Errorf("%s: expected at least one cache backend", ast.Pos)
return nil, errors.Errorf("%s: expected at least one cache backend", ast.Pos)
}

cache := cache.MaybeNewTiered(ctx, caches)

logger.DebugContext(ctx, "Cache backend", "cache", cache)

// Second pass, instantiate strategies and bind them to the mux.
// Collect strategies that implement Interceptor separately — they need
// to run before ServeMux route matching, not as mux routes.
var interceptors []strategy.Interceptor
for _, block := range strategyCandidates {
strategy := block.Name
logger := logger.With("strategy", strategy)
mlog := &loggingMux{logger: logger, mux: mux}
_, err := sr.Create(ctx, strategy, block, cache, mlog, vars)
name := block.Name
slogger := logger.With("strategy", name)
mlog := &loggingMux{logger: slogger, mux: mux}
s, err := sr.Create(ctx, name, block, cache, mlog, vars)
if err != nil {
return errors.Errorf("%s: %w", block.Pos, err)
return nil, errors.Errorf("%s: %w", block.Pos, err)
}
if interceptor, ok := s.(strategy.Interceptor); ok {
interceptors = append(interceptors, interceptor)
}
}
return nil

// Wrap the mux with interceptors. The last-registered interceptor runs
// outermost so that registration order matches interception order.
var h http.Handler = mux
for i := len(interceptors) - 1; i >= 0; i-- {
h = interceptors[i].Intercept(h)
}
return h, nil
}

// ExpandVars expands environment variable references in HCL strings and heredocs.
Expand Down
12 changes: 12 additions & 0 deletions internal/strategy/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,15 @@ func (r *Registry) Create(
type Strategy interface {
String() string
}

// Interceptor is an optional interface a Strategy may implement to intercept
// incoming HTTP requests before ServeMux route matching. This is necessary for
// strategies like the HTTP proxy that need to inspect r.RequestURI rather than
// r.URL.Path — registering a "/" catch-all on the mux is insufficient because
// more-specific routes (e.g. /api/v1/) still win for overlapping paths.
type Interceptor interface {
Strategy
// Intercept wraps next, returning a handler that intercepts matching
// requests and delegates all others to next.
Intercept(next http.Handler) http.Handler
}
117 changes: 117 additions & 0 deletions internal/strategy/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package strategy

import (
"context"
"log/slog"
"net/http"
"net/url"
"strings"

"github.com/block/cachew/internal/cache"
"github.com/block/cachew/internal/logging"
"github.com/block/cachew/internal/strategy/handler"
)

// RegisterHTTPProxy registers a caching HTTP proxy strategy. It intercepts
// absolute-form proxy requests (e.g. from sdkmanager with --proxy_host /
// --proxy_port) where the client sends:
//
// GET http://dl.google.com/some/path HTTP/1.1
//
// The request URI is upgraded to HTTPS and the response is fetched and cached.
// Only GET requests are intercepted; other methods are passed through.
func RegisterHTTPProxy(r *Registry) {
Register(r, "proxy", "Caching HTTP proxy for absolute-form proxy requests.", func(ctx context.Context, _ ProxyConfig, c cache.Cache, mux Mux) (*HTTPProxy, error) {
return NewHTTPProxy(ctx, c, mux)
})
}

// ProxyConfig holds configuration for the HTTP proxy strategy.
// Currently no options are required.
type ProxyConfig struct{}

// HTTPProxy is a caching HTTP proxy strategy that handles standard HTTP proxy
// requests in absolute form (GET http://host/path HTTP/1.1).
//
// It implements the Interceptor interface rather than registering on the mux
// directly, so that absolute-form request detection happens before ServeMux
// route matching. This prevents overlap with more-specific routes such as
// /api/v1/ or /admin/ when the proxied upstream path happens to match them.
type HTTPProxy struct {
logger *slog.Logger
handler http.Handler
}

var (
_ Strategy = (*HTTPProxy)(nil)
_ Interceptor = (*HTTPProxy)(nil)
)

func NewHTTPProxy(ctx context.Context, c cache.Cache, _ Mux) (*HTTPProxy, error) {
logger := logging.FromContext(ctx)
client := &http.Client{}
p := &HTTPProxy{logger: logger}

p.handler = handler.New(client, c).
CacheKey(func(r *http.Request) string {
target := p.parseProxyURI(r)
if target == nil {
return ""
}
return target.String()
}).
Transform(func(r *http.Request) (*http.Request, error) {
target := p.parseProxyURI(r)
if target == nil {
return r, nil
}
return http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original HTTP method for forwarded requests

The transform unconditionally rewrites upstream calls to GET, but this handler is mounted on "/" for all methods. Any absolute-form HEAD, POST, PUT, etc. request is therefore silently converted into a GET, which returns incorrect semantics and can cause clients to misbehave (for example, HEAD probes downloading content or POST never reaching upstream). Either constrain this strategy to GET/HEAD at routing time or forward method/body/headers as-is.

Useful? React with 👍 / 👎.

}).
OnError(func(err error, w http.ResponseWriter, r *http.Request) {
target := p.parseProxyURI(r)
if target == nil {
http.NotFound(w, r)
return
}
p.logger.ErrorContext(r.Context(), "Proxy request failed",
slog.String("url", target.String()),
slog.String("error", err.Error()))
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
})

logger.InfoContext(ctx, "HTTP proxy strategy initialized")
return p, nil
}

func (p *HTTPProxy) String() string { return "proxy" }

// Intercept returns an http.Handler that intercepts absolute-form GET proxy
// requests before they reach the ServeMux, delegating all other requests to
// next. This ensures that a proxied path like /api/v1/... is not accidentally
// routed to cachew's own API handler.
func (p *HTTPProxy) Intercept(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only intercept absolute-form GET requests. Non-GET requests
// (HEAD, POST, …) are not cached and are passed through.
if r.Method == http.MethodGet && strings.HasPrefix(r.RequestURI, "http://") {
p.handler.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}

// parseProxyURI returns the HTTPS upstream URL for an absolute-form proxy
// request, or nil if the request is not a proxy request.
func (p *HTTPProxy) parseProxyURI(r *http.Request) *url.URL {
if !strings.HasPrefix(r.RequestURI, "http://") {
return nil
}
target, err := url.Parse(r.RequestURI)
if err != nil || target.Host == "" {
return nil
}
// Upgrade to HTTPS for the upstream fetch.
target.Scheme = "https"
return target
}
Loading