forwards: reverse-proxy mounts, including a root forward
commit a052659317888fc10d7fe6c662bffac95ba272ad · parent 9b90c98088
· browse this tree
Ximianth, 2026-08-14 16:33:23 UTC
The mount snapshot grows a forwards map beside the exhibit trees; both kinds dispatch by the same longest-prefix rule, and forward names appear in the synthetic listings like any other mount. Proxying is stdlib httputil.ReverseProxy, one per forward, built at snapshot construction. The mount prefix is stripped (backends see paths relative to their own root), a target URL path is prepended if present, and X-Forwarded-* headers are set. An unreachable backend answers 502 per-request; liveness is never checked at config time. Precedence, most to least specific: /-/ assets, longest-prefix mounts, non-root ancestor listings, then the root forward for everything unclaimed. The root listing yields to a root forward -- putting an app at / is the point of a root forward, and exhibits stay reachable at their own URLs and deeper listings. Websocket upgrade and SSE streaming pass-through are claimed by the ReverseProxy docs; the next commit proves both against live sockets.
diff --git a/forward_test.go b/forward_test.go new file mode 100644 index 0000000..29396f3 --- /dev/null +++ b/forward_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// echoBackend is a test backend that reports the path and query it saw, +// so tests can verify prefix stripping and query pass-through in one read. +func echoBackend(t *testing.T) *httptest.Server { + t.Helper() + b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "path=%s query=%s", r.URL.Path, r.URL.RawQuery) + })) + t.Cleanup(b.Close) + return b +} + +// forwardServer builds a vitrine server with the given forwards (and no exhibits). +func forwardServer(t *testing.T, forwards map[string]string) *server { + t.Helper() + fwds := make(map[string]forward, len(forwards)) + for name, to := range forwards { + u, err := parseForwardTarget(to) + if err != nil { + t.Fatal(err) + } + fwds[name] = forward{To: to, url: u} + } + return newServer(&config{Forwards: fwds}, theme{}) +} + +func TestForwardStripsPrefixKeepsQuery(t *testing.T) { + b := echoBackend(t) + srv := forwardServer(t, map[string]string{"game": b.URL}) + rec := get(t, srv, "/game/world/sector?fmt=raw&x=1") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if got, want := rec.Body.String(), "path=/world/sector query=fmt=raw&x=1"; got != want { + t.Errorf("backend saw %q, want %q", got, want) + } +} + +func TestForwardMountRootRedirectsToSlash(t *testing.T) { + b := echoBackend(t) + srv := forwardServer(t, map[string]string{"game": b.URL}) + rec := get(t, srv, "/game") + if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/game/" { + t.Fatalf("status = %d, location = %q; want redirect to /game/", rec.Code, rec.Header().Get("Location")) + } + rec = get(t, srv, "/game/") + if got, want := rec.Body.String(), "path=/ query="; got != want { + t.Errorf("backend saw %q, want %q", got, want) + } +} + +func TestForwardTargetPathPrepended(t *testing.T) { + b := echoBackend(t) + srv := forwardServer(t, map[string]string{"game": b.URL + "/base"}) + rec := get(t, srv, "/game/world") + if got, want := rec.Body.String(), "path=/base/world query="; got != want { + t.Errorf("backend saw %q, want %q", got, want) + } +} + +func TestForwardRootCatchesUnclaimed(t *testing.T) { + b := echoBackend(t) + srv := forwardServer(t, map[string]string{"": b.URL}) + for url, wantPath := range map[string]string{ + "/": "/", + "/world": "/world", + "/events": "/events", + "/app.js": "/app.js", + } { + rec := get(t, srv, url) + if rec.Code != http.StatusOK || rec.Body.String() != "path="+wantPath+" query=" { + t.Errorf("%s: status = %d, body = %q", url, rec.Code, rec.Body.String()) + } + } +} + +// TestForwardRootYieldsToMounts pins the dispatch precedence: +// exhibits and their ancestor listings stay reachable over a root forward; +// only genuinely unclaimed URL space falls through to it. +func TestForwardRootYieldsToMounts(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "research"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "research/notes.md"), []byte("# exhibit wins\n"), 0o644); err != nil { + t.Fatal(err) + } + b := echoBackend(t) + u, err := parseForwardTarget(b.URL) + if err != nil { + t.Fatal(err) + } + srv := newServer(&config{ + Exhibits: map[string]exhibit{"meta/research": {Path: filepath.Join(dir, "research")}}, + Forwards: map[string]forward{"": {To: b.URL, url: u}}, + }, theme{}) + + // Inside the exhibit: the tree serves, not the backend. + if body := get(t, srv, "/meta/research/notes.md?fmt=raw").Body.String(); body != "# exhibit wins\n" { + t.Errorf("exhibit path served %q", body) + } + // Strict ancestor of the exhibit: synthetic listing, not the backend. + if body := get(t, srv, "/meta/").Body.String(); !strings.Contains(body, `href="research/"`) { + t.Errorf("ancestor listing missing; got %q", body) + } + // The root itself: the backend, NOT the root listing. + if body := get(t, srv, "/").Body.String(); body != "path=/ query=" { + t.Errorf("root served %q, want the root forward", body) + } + // Unclaimed space: the backend. + if body := get(t, srv, "/world").Body.String(); body != "path=/world query=" { + t.Errorf("unclaimed path served %q, want the root forward", body) + } + // Reserved assets still win over the root forward. + if ct := get(t, srv, "/-/style.css").Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") { + t.Errorf("/-/style.css content-type = %q, want vitrine's own stylesheet", ct) + } +} + +func TestForwardBackendDownIs502(t *testing.T) { + // A listener that is opened and closed again yields a port that is + // known-free right now: connecting to it fails fast. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + l.Close() + srv := forwardServer(t, map[string]string{"game": "http://" + addr}) + rec := get(t, srv, "/game/world") + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } +} + +func TestForwardNamesAppearInListings(t *testing.T) { + b := echoBackend(t) + srv := forwardServer(t, map[string]string{"svc/game": b.URL}) + if body := get(t, srv, "/").Body.String(); !strings.Contains(body, `href="svc/"`) { + t.Errorf("root listing missing forward; got %q", body) + } + if body := get(t, srv, "/svc/").Body.String(); !strings.Contains(body, `href="game/"`) { + t.Errorf("svc/ listing missing forward; got %q", body) + } +} diff --git a/main.go b/main.go index d1c9ca0..8cda9ea 100644 --- a/main.go +++ b/main.go @@ -38,7 +38,7 @@ func main() { if err != nil { log.Fatal(err) } - srv := newServer(cfg.Exhibits, newTheme(seed)) + srv := newServer(cfg, newTheme(seed)) // Config changes apply live: reloads are fail-stable // (a malformed config logs loudly and the old exhibits keep serving). @@ -63,6 +63,6 @@ func reloadConfig(srv *server, path string) { log.Printf("config reload REJECTED, keeping previous exhibits: %v", err) return } - srv.setExhibits(cfg.Exhibits) + srv.setMounts(cfg) log.Printf("config reloaded: %d exhibits", len(cfg.Exhibits)) } diff --git a/serve.go b/serve.go index 71d2a4c..1e0e46a 100644 --- a/serve.go +++ b/serve.go @@ -4,6 +4,8 @@ import ( "io/fs" "log" "net/http" + "net/http/httputil" + "net/url" "os" "os/exec" "path" @@ -12,7 +14,7 @@ import ( "sync/atomic" ) -// server holds the currently mounted exhibits behind an atomic pointer, +// server holds the currently mounted exhibits and forwards behind an atomic pointer, // so a config reload can swap the whole set at once while requests read it lock-free. // Each request loads the pointer once and works with that immutable snapshot for its whole duration. // (A RWMutex would also work, but there is nothing to lock *around*: @@ -22,9 +24,15 @@ type server struct { theme theme // fixed for the process lifetime; see theme.go } -// mountSet maps mount names (URL prefixes) to opened trees. +// mountSet holds everything served under mount names (URL prefixes): +// opened exhibit trees and reverse-proxy forwards. // A mountSet is never mutated after being published to server.mounts. -type mountSet map[string]tree +type mountSet struct { + trees map[string]tree + // forwards is keyed like trees; the empty name is the root forward, + // which catches requests that no mount or synthetic listing claims. + forwards map[string]http.Handler +} // tree is one exhibit as served: an opened os.Root plus its options. // os.Root guarantees all path resolution stays inside the tree @@ -34,18 +42,20 @@ type tree struct { exposeGit bool } -func newServer(exhibits map[string]exhibit, th theme) *server { +func newServer(cfg *config, th theme) *server { s := &server{theme: th} - s.setExhibits(exhibits) + s.setMounts(cfg) return s } -// setExhibits builds a fresh mount set from the given exhibits and publishes it atomically. +// setMounts builds a fresh mount set from the given config and publishes it atomically. // An exhibit whose directory cannot be opened (typically because it does not exist) // is logged and skipped, not fatal: a config may legitimately name a tree // that is absent on this particular host, and the rest should still serve. // Malformed config is rejected earlier, in loadConfig; by here the config is well-formed // and the only question is which of its directories are actually present. +// Forwards have no analogous liveness question at this point: +// their backends are contacted per-request, and an unreachable one answers 502 then. // // The previous mount set's os.Root handles are deliberately not closed here. // An in-flight request may have loaded the old set just before the swap @@ -55,26 +65,57 @@ func newServer(exhibits map[string]exhibit, th theme) *server { // That is the simplest scheme that is actually correct: // refcounting would buy only promptness, // and a few directory fds lingering until the next GC cycle cost nothing. -func (s *server) setExhibits(exhibits map[string]exhibit) { - mounts := make(mountSet, len(exhibits)) - for name, ex := range exhibits { +func (s *server) setMounts(cfg *config) { + mounts := &mountSet{ + trees: make(map[string]tree, len(cfg.Exhibits)), + forwards: make(map[string]http.Handler, len(cfg.Forwards)), + } + for name, ex := range cfg.Exhibits { root, err := os.OpenRoot(ex.Path) if err != nil { log.Printf("exhibit %q skipped: %v", name, err) continue } - mounts[name] = tree{root: root, exposeGit: ex.ExposeGit} + mounts.trees[name] = tree{root: root, exposeGit: ex.ExposeGit} } // Run update-server-info only for exhibits that actually opened above. // Re-running on every successful reload is deliberate and idempotent. // (Watching the git repos themselves for new commits is possible future work; // today a commit becomes clonable at the next config reload or restart.) - for name, tr := range mounts { + for name, tr := range mounts.trees { if tr.exposeGit { - updateGitServerInfo(name, exhibits[name].Path) + updateGitServerInfo(name, cfg.Exhibits[name].Path) } } - s.mounts.Store(&mounts) + for name, f := range cfg.Forwards { + mounts.forwards[name] = newForwardProxy(name, f.url) + } + s.mounts.Store(mounts) +} + +// newForwardProxy builds the reverse proxy for one forward. +// The mount prefix is stripped and the target's path (if any) prepended, +// so a request for /name/a/b against target http://h/base proxies to http://h/base/a/b. +// Websocket upgrades and streaming responses (SSE et al.) are handled by +// httputil.ReverseProxy itself: upgrades become a bidirectional byte copy, +// and unknown-length responses are flushed immediately as bytes arrive. +func newForwardProxy(name string, target *url.URL) http.Handler { + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(target) // joins target.Path with the (pre-stripped) request path + pr.SetXForwarded() + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("forward %q: %v", name, err) + http.Error(w, "bad gateway: the service behind this forward did not answer", http.StatusBadGateway) + }, + } + // The root forward proxies paths as-is; named forwards get their mount + // prefix stripped so backends see paths relative to their own root. + if name == "" { + return proxy + } + return http.StripPrefix("/"+name, proxy) } // updateGitServerInfo runs `git update-server-info` in the exhibit directory, @@ -113,24 +154,45 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // One snapshot of the mount set serves the whole request, // so a concurrent config reload cannot pull a tree out from under us. - mounts := *s.mounts.Load() + mounts := s.mounts.Load() - // A request path inside a mount is served from that tree. + // A request path inside a mount is served by that mount, exhibit or forward. // Longest prefix wins, so a mount nested under another mount's name // (e.g. "docs/deep" under "docs") is dispatched deterministically // to the more specific one, never by map iteration luck. + // The root forward is not matched here: it is the fallback for URL space + // nothing else claims, applied below the synthetic listings. bestLen := -1 var bestTree tree + var bestFwd http.Handler var bestMount string - for mount, tr := range mounts { + for mount, tr := range mounts.trees { if req == mount || strings.HasPrefix(req, mount+"/") { if len(mount) > bestLen { bestLen = len(mount) - bestMount, bestTree = mount, tr + bestMount, bestTree, bestFwd = mount, tr, nil + } + } + } + for mount, h := range mounts.forwards { + if mount != "" && (req == mount || strings.HasPrefix(req, mount+"/")) { + if len(mount) > bestLen { + bestLen = len(mount) + bestMount, bestFwd = mount, h } } } - if bestLen >= 0 { + switch { + case bestFwd != nil: + // /name redirects to /name/, same as directories do, so that relative + // URLs in pages served by the backend resolve under the mount prefix. + if req == bestMount && !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) + return + } + bestFwd.ServeHTTP(w, r) + return + case bestLen >= 0: rel := strings.TrimPrefix(strings.TrimPrefix(req, bestMount), "/") if rel == "" { rel = "." @@ -139,9 +201,13 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // A request path *above* a mount gets a synthetic listing + // A request path strictly *above* a mount gets a synthetic listing // showing only the exposed next-level names. - if kids := mountChildren(mounts, req); kids != nil { + // The root listing is the one exception: it yields to a root forward + // (putting an app at "/" is the point of a root forward, and exhibits + // remain reachable at their own URLs), so it renders only without one. + rootFwd := mounts.forwards[""] + if kids := mountChildren(mounts, req); kids != nil && (req != "" || rootFwd == nil) { if req != "" && !strings.HasSuffix(r.URL.Path, "/") { http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) return @@ -154,25 +220,41 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Everything unclaimed goes to the root forward, if one is configured. + if rootFwd != nil { + rootFwd.ServeHTTP(w, r) + return + } + http.NotFound(w, r) } // mountChildren returns the listing for a synthetic (above-mount) directory: -// the deduplicated next path segments of all mounts under the given prefix. +// the deduplicated next path segments of all mounts (exhibits and named +// forwards alike) under the given prefix. // Returns nil if no mount lives under this prefix. -func mountChildren(mounts mountSet, prefix string) []dirEntry { +// The root forward (empty name) never appears: it has no name to list. +func mountChildren(mounts *mountSet, prefix string) []dirEntry { seen := make(map[string]bool) - for mount := range mounts { + note := func(mount string) { rest := mount if prefix != "" { if !strings.HasPrefix(mount, prefix+"/") { - continue + return } rest = strings.TrimPrefix(mount, prefix+"/") } seg, _, _ := strings.Cut(rest, "/") seen[seg] = true } + for mount := range mounts.trees { + note(mount) + } + for mount := range mounts.forwards { + if mount != "" { + note(mount) + } + } if len(seen) == 0 { return nil } diff --git a/serve_test.go b/serve_test.go index 17c8e6b..825dedd 100644 --- a/serve_test.go +++ b/serve_test.go @@ -10,6 +10,11 @@ import ( "testing" ) +// exhibitServer builds a server around exhibits only, no forwards. +func exhibitServer(exhibits map[string]exhibit, th theme) *server { + return newServer(&config{Exhibits: exhibits}, th) +} + // newTestServer builds a server around a throwaway tree. func newTestServer(t *testing.T) *server { t.Helper() @@ -29,7 +34,7 @@ func newTestServer(t *testing.T) *server { t.Fatal(err) } } - srv := newServer(map[string]exhibit{"demo": {Path: dir}}, newTheme("testhost")) + srv := exhibitServer(map[string]exhibit{"demo": {Path: dir}}, newTheme("testhost")) return srv } @@ -130,7 +135,7 @@ func newNestedTestServer(t *testing.T) *server { t.Fatal(err) } } - srv := newServer(map[string]exhibit{ + srv := exhibitServer(map[string]exhibit{ "meta/research/latest": {Path: filepath.Join(dir, "research/latest")}, }, theme{}) return srv @@ -176,7 +181,7 @@ func TestNestedMountWinsOverParent(t *testing.T) { t.Fatal(err) } } - srv := newServer(map[string]exhibit{ + srv := exhibitServer(map[string]exhibit{ "docs": {Path: filepath.Join(dir, "parent")}, "docs/deep": {Path: filepath.Join(dir, "child")}, }, theme{}) @@ -247,7 +252,7 @@ func newExposeGitTestServer(t *testing.T) (*server, string) { t.Fatalf("git %v: %v: %s", args, err, out) } } - srv := newServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, theme{}) + srv := exhibitServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, theme{}) return srv, dir } diff --git a/watch_test.go b/watch_test.go index c7e8ac4..e0cd821 100644 --- a/watch_test.go +++ b/watch_test.go @@ -40,7 +40,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { if err != nil { t.Fatal(err) } - fx.srv = newServer(cfg.Exhibits, theme{}) + fx.srv = newServer(cfg, theme{}) watchConfig(fx.cfgPath, pollInterval, func() { reloadConfig(fx.srv, fx.cfgPath) fx.checked.Add(1)