diff --git a/pkg/server/quickproxy/quickproxy.go b/pkg/server/quickproxy/quickproxy.go new file mode 100644 index 0000000..05af9b2 --- /dev/null +++ b/pkg/server/quickproxy/quickproxy.go @@ -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 +} diff --git a/pkg/server/quickproxy/quickproxy_test.go b/pkg/server/quickproxy/quickproxy_test.go new file mode 100644 index 0000000..30d0909 --- /dev/null +++ b/pkg/server/quickproxy/quickproxy_test.go @@ -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) + } + } +} diff --git a/proxy.md b/proxy.md new file mode 100644 index 0000000..32f23a3 --- /dev/null +++ b/proxy.md @@ -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.