mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-17 21:52:38 +00:00
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.
This commit is contained in:
@@ -0,0 +1,184 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
proxy *httputil.ReverseProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledRule{
|
||||||
|
prefix: r.URLPrefix,
|
||||||
|
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.HandlerFunc(http.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
func (s *Service) match(path string) *compiledRule {
|
||||||
|
for i := range s.rules {
|
||||||
|
if strings.HasPrefix(path, s.rules[i].prefix) {
|
||||||
|
return &s.rules[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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},
|
||||||
|
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, 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_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Plan: Proxy endpoint support for pkg/webserver2 static serving
|
||||||
|
|
||||||
|
Status: **`quickproxy` package implemented in ResolveSpec.** GoCore-side wiring
|
||||||
|
(config field, `webserver2/proxy.go`, `server.go` route ordering) is a
|
||||||
|
separate repo, not present here, and remains unimplemented.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`pkg/webserver2` (via `WithStaticFS`) currently serves static files from
|
||||||
|
`WebResDir` (`/res`) and `WebStaticDir` (`/`), using ResolveSpec's
|
||||||
|
`pkg/server/staticweb` package, with an HTML fallback for SPA routing.
|
||||||
|
|
||||||
|
We're adding a proxy layer: requests are tried against a configured proxy
|
||||||
|
target first; if the proxy can't resolve them (unreachable, or upstream
|
||||||
|
returns 404), they fall back to the existing static file handling.
|
||||||
|
|
||||||
|
## Decisions (from Q&A with user)
|
||||||
|
|
||||||
|
1. **Config shape**: a list of prefix rules (like nginx `location` blocks),
|
||||||
|
where one rule may use prefix `/` to act as a catch-all passthrough.
|
||||||
|
So a single config list covers both "proxy just `/api`" and "proxy
|
||||||
|
everything not otherwise matched" use cases.
|
||||||
|
2. **Package placement**: new package in the **ResolveSpec** repo
|
||||||
|
(`/mnt/vault/ResolveSpec`, published as `github.com/bitechdev/ResolveSpec`),
|
||||||
|
named **`quickproxy`**, living alongside `pkg/server/staticweb` at
|
||||||
|
`pkg/server/quickproxy`. GoCore's `pkg/webserver2` will consume it the
|
||||||
|
same way it consumes `staticweb` today (wire it up in `static.go`),
|
||||||
|
after bumping the ResolveSpec dependency version — mirroring the existing
|
||||||
|
`fix(resolve-spec): update ResolveSpec to vX.Y.Z` commit pattern already
|
||||||
|
used in this repo's history.
|
||||||
|
3. **Fallback trigger**: fall back to static files both when the upstream is
|
||||||
|
unreachable (dial/connect error, timeout) **and** when the upstream
|
||||||
|
responds with `404`. Any other upstream response (2xx, other 4xx, 5xx) is
|
||||||
|
passed through to the client as-is.
|
||||||
|
4. **Method scope**: proxying applies to **all HTTP methods**, not just GET
|
||||||
|
(unlike today's static routes, which are GET-only).
|
||||||
|
|
||||||
|
## New package: `github.com/bitechdev/ResolveSpec/pkg/server/quickproxy`
|
||||||
|
|
||||||
|
Repo location: `/mnt/vault/ResolveSpec/pkg/server/quickproxy`
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
package quickproxy
|
||||||
|
|
||||||
|
// Rule maps a URL prefix to an upstream target.
|
||||||
|
// A Rule with URLPrefix "/" acts as a catch-all passthrough.
|
||||||
|
type Rule struct {
|
||||||
|
URLPrefix string // e.g. "/api", "/"
|
||||||
|
Target string // upstream base URL, e.g. "http://localhost:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service holds the compiled set of rules and does longest-prefix matching.
|
||||||
|
type Service struct { ... }
|
||||||
|
|
||||||
|
func NewService(rules []Rule) (*Service, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Matching & fallback behavior
|
||||||
|
|
||||||
|
- Longest-prefix match against configured rules, same convention as
|
||||||
|
`staticweb`'s mount points.
|
||||||
|
- Implemented with `net/http/httputil.ReverseProxy` per matched rule
|
||||||
|
(director rewrites scheme/host/path, adds `X-Forwarded-Host` /
|
||||||
|
`X-Forwarded-For`).
|
||||||
|
- **404 detection without buffering the body**: use
|
||||||
|
`ReverseProxy.ModifyResponse` to inspect `resp.StatusCode` as soon as
|
||||||
|
headers arrive from upstream, *before* the body is streamed to the
|
||||||
|
client. If it's 404, return a sentinel error from `ModifyResponse` — this
|
||||||
|
causes `ReverseProxy` to invoke `ErrorHandler` instead of writing
|
||||||
|
anything to the client, so we never commit a partial/wrong response.
|
||||||
|
- **Connection failure detection**: `ReverseProxy.ErrorHandler` catches
|
||||||
|
both the sentinel 404 error and real transport errors (dial failure,
|
||||||
|
timeout, connection reset).
|
||||||
|
- In both cases, `ErrorHandler` invokes a caller-supplied fallback
|
||||||
|
`http.Handler` (the existing static file service) instead of writing an
|
||||||
|
error response. Because nothing has been written to the `ResponseWriter`
|
||||||
|
yet in either failure path, the fallback handler can write a normal
|
||||||
|
response (status, headers, body) as if the proxy had never been tried.
|
||||||
|
- Any other status code (including other 4xx/5xx) streams straight through
|
||||||
|
to the client — no masking of real upstream errors.
|
||||||
|
|
||||||
|
### Handler API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 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.
|
||||||
|
func (s *Service) Handler(fallback http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the shape of `staticweb.StaticFileService.Handler()` so it
|
||||||
|
composes the same way in `pkg/webserver2`.
|
||||||
|
|
||||||
|
## Config changes (GoCore)
|
||||||
|
|
||||||
|
`pkg/cfg/settings-model.go` — add a new field to `Settings`, following the
|
||||||
|
existing flat-JSON convention used by `WebStaticDir`/`WebResDir`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ProxyRule struct {
|
||||||
|
URLPrefix string `json:"urlprefix"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// in Settings:
|
||||||
|
WebProxyRules []ProxyRule `json:"webproxyrules"`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wiring changes (GoCore, `pkg/webserver2`)
|
||||||
|
|
||||||
|
New file `pkg/webserver2/proxy.go` (parallel to `static.go`), e.g.:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Router) WithProxyFS(corestate cfg.State) *Router {
|
||||||
|
if len(corestate.Cfg.WebProxyRules) == 0 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
rules := make([]quickproxy.Rule, 0, len(corestate.Cfg.WebProxyRules))
|
||||||
|
for _, pr := range corestate.Cfg.WebProxyRules {
|
||||||
|
rules = append(rules, quickproxy.Rule{URLPrefix: pr.URLPrefix, Target: pr.Target})
|
||||||
|
}
|
||||||
|
service, err := quickproxy.NewService(rules)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("could not configure proxy service %v", err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r.proxyService = service // stored for static.go to consume as fallback target
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration point with existing static routing — needs care
|
||||||
|
|
||||||
|
This is the trickiest part of the wiring, flagged here rather than decided,
|
||||||
|
since it affects route registration order in `server.go`:
|
||||||
|
|
||||||
|
- For a proxy rule prefix that **doesn't** overlap with the static mounts
|
||||||
|
(e.g. `/api`), we can register new routes directly:
|
||||||
|
`r.Handle(method, "/api/*path", quickproxy handler)` for all methods,
|
||||||
|
with no fallback (there's no static content at `/api` anyway).
|
||||||
|
- For the **catch-all `/` proxy rule**, it directly overlaps the existing
|
||||||
|
`WebStaticDir` mount at `/`. Rather than registering a second competing
|
||||||
|
route, `WithStaticFS`'s existing GET handler for `/*path` needs to become
|
||||||
|
`proxyService.Handler(staticAuth(service.Handler()))` — i.e. the proxy
|
||||||
|
wraps the static handler as its fallback, instead of the two being
|
||||||
|
separate router registrations. This means `WithProxyFS` needs to run
|
||||||
|
*before* `WithStaticFS` (or the two need to be merged into one method),
|
||||||
|
and `server.go`'s call chain (`WithStaticFS(corestate)`) needs adjusting
|
||||||
|
accordingly.
|
||||||
|
- Non-GET methods on the catch-all `/` rule have no static fallback to
|
||||||
|
offer (static routes are GET-only today) — they'd just proxy or 404.
|
||||||
|
|
||||||
|
## Release flow
|
||||||
|
|
||||||
|
Since `quickproxy` lives in the separate ResolveSpec module:
|
||||||
|
1. Implement + test in `/mnt/vault/ResolveSpec`.
|
||||||
|
2. Tag/publish a new ResolveSpec version.
|
||||||
|
3. Bump `github.com/bitechdev/ResolveSpec` in GoCore's `go.mod`
|
||||||
|
(`go get github.com/bitechdev/ResolveSpec@vX.Y.Z`), same as the recent
|
||||||
|
`fix(resolve-spec): update ResolveSpec to v1.1.46` commit.
|
||||||
|
4. Land the GoCore-side wiring (`cfg` field, `proxy.go`, `server.go` call
|
||||||
|
order change) in the same or a follow-up commit.
|
||||||
|
|
||||||
|
## Resolved decisions (quickproxy implementation)
|
||||||
|
|
||||||
|
- **Timeouts**: a single global default (`quickproxy.DefaultTimeout`,
|
||||||
|
10s), overridable via `quickproxy.WithTimeout(d)` passed to `NewService`.
|
||||||
|
It bounds dial + response-header wait only; response body streaming is
|
||||||
|
unbounded, so it won't cut off long downloads or upgraded connections.
|
||||||
|
- **Auth middleware**: out of scope for `quickproxy`. Whether/how
|
||||||
|
`staticAuth` wraps proxied requests is entirely GoCore's call at the
|
||||||
|
wiring layer, not something this package decides.
|
||||||
|
- **Path rewriting**: no prefix stripping — the full incoming path/query is
|
||||||
|
forwarded unchanged to the target host (matches nginx `proxy_pass`
|
||||||
|
without a trailing slash). Only scheme/host are rewritten, and
|
||||||
|
`X-Forwarded-Host` is set from the original `Host` header.
|
||||||
|
|
||||||
|
## Open questions / not yet decided
|
||||||
|
|
||||||
|
- WebSocket upgrade proxying (needed if any proxied target does live
|
||||||
|
reload / HMR, e.g. a frontend dev server) — plain `ReverseProxy` handles
|
||||||
|
this automatically via its `Transport`, should confirm it's not disabled.
|
||||||
|
- Whether request bodies need special handling for large uploads proxied
|
||||||
|
to non-GET targets (streaming vs. buffering — `ReverseProxy` streams by
|
||||||
|
default, should be fine, but worth confirming with the intended proxy
|
||||||
|
targets).
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
|
||||||
|
- `quickproxy` package: unit tests in ResolveSpec covering longest-prefix
|
||||||
|
matching, 404-triggers-fallback, connection-error-triggers-fallback,
|
||||||
|
pass-through of non-404 error codes, all-methods support.
|
||||||
|
- `pkg/webserver2`: integration test standing up a fake upstream
|
||||||
|
(`httptest.Server`) plus a temp static dir, verifying the
|
||||||
|
proxy-then-static-fallback order end to end.
|
||||||
Reference in New Issue
Block a user