Compare commits

..
5 Commits
Author SHA1 Message Date
Hein 4f6878099b fix(quickproxy): reject Exclude entries outside their rule's URLPrefix
Tests / Unit Tests (push) Failing after 5s
Tests / Integration Tests (push) Failing after 23s
Build , Vet Test, and Lint / Build (push) Successful in 52s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 55s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 56s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m4s
An Exclude entry only ever matches requests that already fall under
its rule's URLPrefix, so one written without that prefix (e.g.
"/health" on a rule for "/api") silently never triggered. Validate
that each Exclude entry itself starts with the rule's URLPrefix,
failing NewService instead of accepting a no-op config.
2026-09-17 11:40:49 +02:00
Hein 0d8b136b91 feat(quickproxy): support per-rule Exclude path prefixes
Rule.Exclude lists path prefixes that should never be proxied by that
rule, even though they fall under its URLPrefix. A request matching an
Exclude prefix is treated as a non-match for that rule: matching
continues against other configured rules, falling back to the
caller-supplied handler if none apply. Lets a catch-all "/" rule proxy
everything except carved-out paths like "/health".
2026-09-17 11:38:02 +02:00
Hein 6de9be0ae7 chore(proxy): remove outdated proxy documentation 2026-09-17 11:09:14 +02:00
Hein 82e923b16e fix(quickproxy): use http.NotFoundHandler per golangci-lint gocritic 2026-09-17 11:08:12 +02:00
Hein 9a664593f0 feat(quickproxy): add reverse-proxy-with-static-fallback package
Adds pkg/server/quickproxy: longest-prefix rule matching over
net/http/httputil.ReverseProxy, falling back to a caller-supplied
handler when the upstream is unreachable or returns 404. Any other
upstream response streams through unchanged. All HTTP methods are
proxied, with a configurable global dial/response-header timeout
(quickproxy.WithTimeout, default 10s).

GoCore-side wiring (config field, webserver2/proxy.go, server.go
route ordering) is tracked separately in that repo.
2026-09-17 11:07:53 +02:00
2 changed files with 544 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
// Package quickproxy provides a small reverse-proxy layer that tries a set
// of configured upstream targets first, and falls back to a caller-supplied
// http.Handler (typically static file serving) when the upstream is
// unreachable or returns 404.
package quickproxy
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strings"
"time"
)
// Rule maps a URL path prefix to an upstream target.
// A Rule with URLPrefix "/" acts as a catch-all passthrough.
type Rule struct {
// URLPrefix is the URL path prefix this rule matches. Must start with "/".
URLPrefix string
// Target is the upstream base URL, e.g. "http://localhost:3000".
// The incoming request path and query are forwarded unchanged; only the
// scheme and host are rewritten to Target's.
Target string
// Exclude is a list of URL path prefixes that this rule should not
// proxy, even though they fall under URLPrefix. Each entry is a full
// path from root and must itself start with URLPrefix (e.g. rule
// URLPrefix "/api" excluding a subpath must use "/api/health", not
// "/health"). A request matching an Exclude prefix is treated as if
// this rule didn't match at all: matching continues against any other
// configured rule, falling back if none match. This is typically used
// to carve out paths (e.g. "/health") from a catch-all "/" rule so
// they're served by the fallback handler instead of being proxied.
Exclude []string
}
// DefaultTimeout is the dial and response-header timeout applied to
// upstream requests when no WithTimeout option is given. It does not limit
// response body streaming.
const DefaultTimeout = 10 * time.Second
// Option configures a Service.
type Option func(*options)
type options struct {
timeout time.Duration
}
// WithTimeout sets the dial and response-header timeout used when
// connecting to upstream targets. It does not limit response body
// streaming, so it won't interrupt long-lived downloads or SSE/WebSocket
// connections once established.
func WithTimeout(d time.Duration) Option {
return func(o *options) { o.timeout = d }
}
// compiledRule pairs a Rule with its ready-to-use reverse proxy.
type compiledRule struct {
prefix string
excludes []string
proxy *httputil.ReverseProxy
}
// excluded reports whether path falls under one of the rule's Exclude prefixes.
func (r *compiledRule) excluded(path string) bool {
for _, ex := range r.excludes {
if strings.HasPrefix(path, ex) {
return true
}
}
return false
}
// Service holds a compiled set of proxy rules and performs longest-prefix
// matching against them. A Service is safe for concurrent use once
// returned from NewService; Handler must be called once per Service to
// wire up the fallback handler before the returned http.Handler is served.
type Service struct {
rules []compiledRule // sorted by descending prefix length
}
// errUpstreamNotFound is a sentinel error returned from ModifyResponse to
// make ReverseProxy invoke ErrorHandler (our fallback path) instead of
// writing the upstream's 404 to the client. Nothing has been written to
// the ResponseWriter yet when this happens.
var errUpstreamNotFound = errors.New("quickproxy: upstream returned 404")
// NewService compiles the given rules into a Service. Rules are matched by
// longest URLPrefix, so a catch-all "/" rule can coexist with more specific
// rules such as "/api".
func NewService(rules []Rule, opts ...Option) (*Service, error) {
if len(rules) == 0 {
return nil, fmt.Errorf("quickproxy: no rules configured")
}
cfg := options{timeout: DefaultTimeout}
for _, opt := range opts {
opt(&cfg)
}
seen := make(map[string]bool, len(rules))
compiled := make([]compiledRule, 0, len(rules))
for _, r := range rules {
if !strings.HasPrefix(r.URLPrefix, "/") {
return nil, fmt.Errorf("quickproxy: rule prefix %q must start with /", r.URLPrefix)
}
if seen[r.URLPrefix] {
return nil, fmt.Errorf("quickproxy: duplicate rule prefix %q", r.URLPrefix)
}
seen[r.URLPrefix] = true
target, err := url.Parse(r.Target)
if err != nil || target.Scheme == "" || target.Host == "" {
return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix)
}
for _, ex := range r.Exclude {
if !strings.HasPrefix(ex, "/") {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must start with /", ex, r.URLPrefix)
}
if !strings.HasPrefix(ex, r.URLPrefix) {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must itself start with the rule's URLPrefix", ex, r.URLPrefix)
}
}
compiled = append(compiled, compiledRule{
prefix: r.URLPrefix,
excludes: r.Exclude,
proxy: newReverseProxy(target, cfg.timeout),
})
}
// Longest prefix first, so the first match in Handler is always the
// most specific one.
sort.Slice(compiled, func(i, j int) bool {
return len(compiled[i].prefix) > len(compiled[j].prefix)
})
return &Service{rules: compiled}, nil
}
func newReverseProxy(target *url.URL, timeout time.Duration) *httputil.ReverseProxy {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: timeout,
}).DialContext,
ResponseHeaderTimeout: timeout,
}
return &httputil.ReverseProxy{
Transport: transport,
Director: func(req *http.Request) {
originalHost := req.Host
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
if originalHost != "" {
req.Header.Set("X-Forwarded-Host", originalHost)
}
},
ModifyResponse: func(resp *http.Response) error {
if resp.StatusCode == http.StatusNotFound {
return errUpstreamNotFound
}
return nil
},
}
}
// Handler returns an http.Handler that tries the configured proxy rules
// first (longest-prefix match), and calls fallback when no rule matches,
// the upstream is unreachable, or the upstream returns 404. Any other
// upstream response (2xx, other 4xx, 5xx) is streamed through to the
// client unchanged.
//
// Handler wires up ErrorHandler on the Service's compiled rules, so it
// should be called once per Service, before the returned http.Handler
// starts serving requests.
func (s *Service) Handler(fallback http.Handler) http.Handler {
if fallback == nil {
fallback = http.NotFoundHandler()
}
for i := range s.rules {
s.rules[i].proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, _ error) {
fallback.ServeHTTP(w, r)
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rule := s.match(r.URL.Path)
if rule == nil {
fallback.ServeHTTP(w, r)
return
}
rule.proxy.ServeHTTP(w, r)
})
}
// match returns the longest-prefix rule matching path, or nil if none match.
// A rule whose Exclude covers path is skipped, and matching continues
// against the next-longest-prefix rule.
func (s *Service) match(path string) *compiledRule {
for i := range s.rules {
if !strings.HasPrefix(path, s.rules[i].prefix) {
continue
}
if s.rules[i].excluded(path) {
continue
}
return &s.rules[i]
}
return nil
}
+322
View File
@@ -0,0 +1,322 @@
package quickproxy
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewService_Validation(t *testing.T) {
tests := []struct {
name string
rules []Rule
wantErr bool
}{
{"no rules", nil, true},
{"empty rules", []Rule{}, true},
{"bad prefix", []Rule{{URLPrefix: "api", Target: "http://localhost:1"}}, true},
{"bad target", []Rule{{URLPrefix: "/api", Target: "not-a-url"}}, true},
{"missing host", []Rule{{URLPrefix: "/api", Target: "http://"}}, true},
{"duplicate prefix", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1"},
{URLPrefix: "/api", Target: "http://localhost:2"},
}, true},
{"bad exclude prefix", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"health"}},
}, true},
{"exclude outside rule's URLPrefix", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, true},
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false},
{"valid with exclude", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, false},
{"valid with nested exclude", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/api/health"}},
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewService(tt.rules)
if (err != nil) != tt.wantErr {
t.Fatalf("NewService() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func fallbackHandler(body string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
})
}
func TestHandler_ProxiesSuccessResponse(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
req := httptest.NewRequest(http.MethodGet, "/api/widgets", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "upstream:/api/widgets" {
t.Fatalf("body = %q", got)
}
}
func TestHandler_404FallsBack(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("upstream not found"))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/missing.html", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_UnreachableUpstreamFallsBack(t *testing.T) {
// A closed listener address: nothing is listening, so dialing fails.
unreachable := "http://127.0.0.1:1"
svc, err := NewService([]Rule{{URLPrefix: "/", Target: unreachable}}, WithTimeout(500*time.Millisecond))
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_NonNotFoundErrorsPassThrough(t *testing.T) {
codes := []int{http.StatusOK, http.StatusForbidden, http.StatusBadRequest, http.StatusInternalServerError}
for _, code := range codes {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
_, _ = w.Write([]byte("upstream response"))
}))
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
upstream.Close()
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != code {
t.Errorf("status for upstream code %d = %d, want %d", code, rr.Code, code)
}
if got := rr.Body.String(); got != "upstream response" {
t.Errorf("body for upstream code %d = %q, want passthrough", code, got)
}
upstream.Close()
}
}
func TestHandler_LongestPrefixMatch(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/other": "general",
"/anything": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_ExcludeFallsBackToFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: upstream.URL, Exclude: []string{"/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/health/live", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/other", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "upstream:/other" {
t.Fatalf("body = %q, want upstream:/other", got)
}
}
func TestHandler_ExcludeFallsThroughToNextRule(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/api", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL, Exclude: []string{"/api/v1/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/v1/health": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_NoMatchFallsBack(t *testing.T) {
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/other", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_AllMethodsProxied(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(r.Method + ":" + string(body)))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}
for _, method := range methods {
req := httptest.NewRequest(method, "/api/widgets", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
want := method + ":"
if got := rr.Body.String(); got != want {
t.Errorf("method %s: body = %q, want %q", method, got, want)
}
}
}