Merge branch 'forwarding': reverse-proxy mounts, websockets and SSE included.
commit 964f50d4cbf8d257074ec780a0965b33d4543101 · parent bd278b0336 · parent db16d53845
· browse this tree
Ximianth, 2026-08-14 17:02:37 UTC
diff --git a/README.md b/README.md index 1bbf98c..4303631 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Point it at some directories via a small config file, and it serves them: markdown renders to HTML, directories become listings, and `?fmt=raw` on any URL serves the file bytes directly. No javascript required, no uploads, no auth, no dynamic anything beyond rendering. +(URL prefixes can also forward to other local services; see Forwarding below.) Config ------ @@ -38,7 +39,7 @@ exhibits: Trailing slashes on names and paths are accepted and normalized away. - `expose-git` (optional, default false): see below. -A bad config (parse error, unknown key, missing path, or zero exhibits) +A bad config (parse error, unknown key, missing path, or nothing configured at all) is a startup error: vitrine refuses to run rather than serve the wrong thing. An exhibit whose directory is absent is not a bad config, though: @@ -50,6 +51,48 @@ Config changes apply live: vitrine watches the config file so a change lands within ~5s worst case, usually instantly). Edit freely; a typo mid-edit logs a rejection and leaves the running server untouched. +Forwarding +---------- + +Besides serving file trees, vitrine can reverse-proxy URL prefixes to other services, +so one publicly routable port can carry both the docs and, say, a game server: + +```yaml +exhibits: + some-repo: + path: "%" +forwards: + /: + to: http://localhost:8866 + tools/dashboard: + to: http://localhost:9090 +``` + +- `forwards` maps URL prefixes to backend base URLs, sibling to `exhibits`. + Names share the URL namespace with exhibits (a collision is a config error) + and appear in listings like any other mount. +- The name `/` is the root forward: it catches every request + that no exhibit, forward, or listing claims. + Exhibits keep winning at their own prefixes -- dispatch is longest-prefix-match -- + so a backend at the root simply never sees URLs that collide + with an exhibit prefix, its ancestors, or the reserved `/-/` prefix. +- `to` must be an absolute `http://` or `https://` URL; the scheme is mandatory. + Websocket backends are also `http://`: a websocket connection begins + as an ordinary http request with an `Upgrade` header, which the proxy passes through, + so websockets and SSE streams work with no extra configuration. +- A path on the `to` URL becomes a prefix on every proxied request. +- The mount prefix is stripped before proxying: + a backend behind `tools/dashboard` sees `/x`, not `/tools/dashboard/x`. + It should therefore use relative URLs in its pages + (or be told its public prefix by its own means; vitrine does not rewrite response bodies). +- An unreachable backend answers 502 per-request. + Backend liveness is deliberately not part of config validation: + backends come and go while vitrine runs. + +Forwarding is one of two deliberate exceptions to "read-only, no dynamic anything" +(the other is `expose-git`); vitrine adds no auth, no TLS, and no rewriting -- +the forward is a pane of glass to another local port. + Theming ------- diff --git a/config.go b/config.go index 40f4146..4b9cd81 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -21,10 +22,25 @@ type exhibit struct { ExposeGit bool `yaml:"expose-git"` } +// forward is one configured reverse-proxy mount: +// requests under a URL prefix are forwarded to another http service. +type forward struct { + // To is the target base URL: scheme://host[:port][/path], scheme http or https. + // Websocket backends also use http/https: a websocket connection begins life + // as an ordinary http request (with an Upgrade header), so there is no ws:// target. + To string `yaml:"to"` + // url is To, parsed; loadConfig guarantees it for every forward it returns. + url *url.URL +} + // config is the top-level schema of the vitrine config file. type config struct { // Exhibits maps a URL prefix (which may contain slashes) to what it serves. Exhibits map[string]exhibit `yaml:"exhibits"` + // Forwards maps a URL prefix to a service proxied behind it. + // The name "/" is the root forward: it catches every request + // that no exhibit, forward, or synthetic listing claims. + Forwards map[string]forward `yaml:"forwards"` } // loadConfig reads and validates the YAML config file at the given path. @@ -45,8 +61,8 @@ func loadConfig(path string) (*config, error) { return nil, fmt.Errorf("config %s: %w", path, err) } - if len(cfg.Exhibits) == 0 { - return nil, fmt.Errorf("config %s: no exhibits configured; a vitrine with nothing on display is a misconfiguration", path) + if len(cfg.Exhibits) == 0 && len(cfg.Forwards) == 0 { + return nil, fmt.Errorf("config %s: no exhibits or forwards configured; a vitrine with nothing on display is a misconfiguration", path) } base, err := filepath.Abs(filepath.Dir(path)) @@ -90,5 +106,56 @@ func loadConfig(path string) (*config, error) { normalized[cleanName] = ex } cfg.Exhibits = normalized + + forwards := make(map[string]forward, len(cfg.Forwards)) + for name, f := range cfg.Forwards { + // Forward names are URL prefixes; leading and trailing slashes are + // accepted and normalized away. Unlike exhibits, the all-slashes name is + // meaningful here: "/" normalizes to "" and denotes the root forward. + cleanName := strings.Trim(name, "/") + if _, dup := forwards[cleanName]; dup { + return nil, fmt.Errorf("config %s: forward %q: duplicate of %q after trimming slashes", path, name, cleanName) + } + if _, clash := normalized[cleanName]; clash { + return nil, fmt.Errorf("config %s: forward %q: collides with the exhibit of the same name", path, name) + } + u, err := parseForwardTarget(f.To) + if err != nil { + return nil, fmt.Errorf("config %s: forward %q: %w", path, name, err) + } + f.url = u + forwards[cleanName] = f + } + cfg.Forwards = forwards return &cfg, nil } + +// parseForwardTarget validates a forward's "to" URL. +// The supported shape is exactly scheme://host[:port][/path]: +// userinfo, query, and fragment have no meaning for a forward target +// and are rejected rather than silently merged into every proxied request. +func parseForwardTarget(raw string) (*url.URL, error) { + if raw == "" { + return nil, fmt.Errorf("missing required field %q", "to") + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("target %q: %w", raw, err) + } + switch u.Scheme { + case "http", "https": + case "ws", "wss": + return nil, fmt.Errorf("target %q: websockets ride ordinary http (the handshake is an http request with an Upgrade header), so the target scheme is http:// or https:// even for pure-websocket backends", raw) + default: + // This branch also catches bare host:port, which url.Parse reads as + // scheme host, opaque port -- hence scheme-mandatory, no sugar. + return nil, fmt.Errorf("target %q: scheme must be http:// or https:// (and is mandatory; bare host:port is not accepted)", raw) + } + if u.Host == "" { + return nil, fmt.Errorf("target %q: missing host", raw) + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("target %q: userinfo, query, and fragment are not supported in a forward target", raw) + } + return u, nil +} diff --git a/config_test.go b/config_test.go index 4362231..586edbc 100644 --- a/config_test.go +++ b/config_test.go @@ -165,3 +165,85 @@ func TestConfigMissingFileRejected(t *testing.T) { t.Fatal("want error for missing file, got nil") } } + +func TestConfigForwardValid(t *testing.T) { + p := writeConfig(t, ` +exhibits: + demo: + path: /srv/demo +forwards: + game: + to: http://localhost:8866 + /: + to: https://localhost:9999/subpath +`) + cfg, err := loadConfig(p) + if err != nil { + t.Fatal(err) + } + if got := cfg.Forwards["game"].To; got != "http://localhost:8866" { + t.Errorf("game to = %q", got) + } + // The root forward normalizes to the empty name: the same + // no-leading-slash, no-trailing-slash form every other mount name uses. + if got := cfg.Forwards[""].To; got != "https://localhost:9999/subpath" { + t.Errorf("root to = %q; forwards = %+v", got, cfg.Forwards) + } +} + +func TestConfigForwardsOnlyIsValid(t *testing.T) { + p := writeConfig(t, ` +forwards: + /: + to: http://localhost:8866 +`) + if _, err := loadConfig(p); err != nil { + t.Fatalf("a forwards-only config should load: %v", err) + } +} + +func TestConfigForwardBadTargetsRejected(t *testing.T) { + for name, tc := range map[string]struct{ to, wantErr string }{ + "missing": {"", "to"}, + "schemeless": {"localhost:8866", "scheme"}, + "relative": {"/some/path", "scheme"}, + "ws": {"ws://localhost:8866", "Upgrade"}, + "wss": {"wss://localhost:8866", "Upgrade"}, + "ftp": {"ftp://localhost:21", "scheme"}, + "no-host": {"http://", "host"}, + } { + p := writeConfig(t, "forwards:\n fwd:\n to: \""+tc.to+"\"\n") + _, err := loadConfig(p) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("%s (to=%q): want error containing %q, got %v", name, tc.to, tc.wantErr, err) + } + } +} + +func TestConfigForwardExhibitCollisionRejected(t *testing.T) { + p := writeConfig(t, ` +exhibits: + demo: + path: /srv/demo +forwards: + demo/: + to: http://localhost:8866 +`) + _, err := loadConfig(p) + if err == nil || !strings.Contains(err.Error(), "collides") { + t.Fatalf("want collision error, got %v", err) + } +} + +func TestConfigRootExhibitRejected(t *testing.T) { + // The root name is meaningful only for forwards; a root *exhibit* stays + // a misconfiguration, same as it always was (empty-after-trimming name). + p := writeConfig(t, ` +exhibits: + /: + path: /srv/demo +`) + if _, err := loadConfig(p); err == nil { + t.Fatal("want error for root exhibit name, got nil") + } +} diff --git a/forward_stream_test.go b/forward_stream_test.go new file mode 100644 index 0000000..9b96e82 --- /dev/null +++ b/forward_stream_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The tests in this file prove streaming behavior through a forward: +// websocket upgrades and SSE both demand more than request/response -- +// bytes must flow while the connection stays open. +// httptest.ResponseRecorder can neither hijack nor observe mid-stream, +// so these run vitrine in a real httptest.Server and speak over real sockets. + +// TestForwardWebsocketUpgrade proves an Upgrade round-trip through the proxy: +// handshake, then bytes both ways on the hijacked connection. +// The handshake is hand-rolled: the subject is upgrade *pass-through*, +// not websocket framing, so no ws library is needed on either end. +func TestForwardWebsocketUpgrade(t *testing.T) { + // The backend hijacks upgrade requests and echoes lines back prefixed. + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + t.Errorf("backend saw no Upgrade header; got %q", r.Header.Get("Upgrade")) + http.Error(w, "expected upgrade", http.StatusBadRequest) + return + } + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Errorf("hijack: %v", err) + return + } + defer conn.Close() + rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") + rw.Flush() + for { + line, err := rw.ReadString('\n') + if err != nil { + return + } + rw.WriteString("echo:" + line) + rw.Flush() + } + })) + defer backend.Close() + + vitrine := httptest.NewServer(forwardServer(t, map[string]string{"game": backend.URL})) + defer vitrine.Close() + + // Dial vitrine raw and perform the upgrade handshake through the proxy. + conn, err := net.Dial("tcp", strings.TrimPrefix(vitrine.URL, "http://")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + fmt.Fprintf(conn, "GET /game/ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("status = %d, want 101", resp.StatusCode) + } + + // The connection is now a two-way pipe through the proxy; prove both directions. + for _, msg := range []string{"hello\n", "worlds\n"} { + if _, err := io.WriteString(conn, msg); err != nil { + t.Fatal(err) + } + line, err := br.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if line != "echo:"+msg { + t.Fatalf("read %q, want %q", line, "echo:"+msg) + } + } +} + +// TestForwardSSEStreamsMidResponse proves the proxy flushes streamed bytes +// while the backend holds the connection open, rather than buffering to EOF. +// The backend sends one event, then blocks until the client confirms receipt +// (channel-gated, no sleeps): if the event arrives at all, it arrived mid-stream. +func TestForwardSSEStreamsMidResponse(t *testing.T) { + received := make(chan struct{}) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: tick\n\n") + w.(http.Flusher).Flush() + // Hold the response open until the client has read the event. + // Only then may the stream end; a proxy that buffers to EOF deadlocks + // here instead of passing, which is exactly the point. + select { + case <-received: + case <-r.Context().Done(): + t.Error("backend: client gave up before confirming the event") + } + })) + defer backend.Close() + + vitrine := httptest.NewServer(forwardServer(t, map[string]string{"": backend.URL})) + defer vitrine.Close() + + // The client timeout is damage control only, never load-bearing: + // a correctly-flushing proxy delivers the event immediately. + // Without it, a buffering proxy would deadlock the test instead of failing it + // (client blocked on read, backend blocked on the confirmation channel). + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(vitrine.URL + "/events") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Fatalf("content-type = %q", ct) + } + br := bufio.NewReader(resp.Body) + line, err := br.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if line != "data: tick\n" { + t.Fatalf("read %q, want the SSE event line", line) + } + close(received) +} 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..dd0f22e 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,8 @@ // // Exhibits come from a YAML config file given via the required -config flag; // see config.go for the schema and README.md for examples. +// The config can also declare forwards: URL prefixes reverse-proxied +// to other http services (websockets and SSE pass through); see serve.go. package main import ( @@ -38,7 +40,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 +65,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 6225ed3..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,23 +154,60 @@ 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. - for mount, tr := range mounts { + // 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.trees { if req == mount || strings.HasPrefix(req, mount+"/") { - rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/") - if rel == "" { - rel = "." + if len(mount) > bestLen { + bestLen = len(mount) + 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 } - s.serveFromTree(w, r, tr, mount, rel) + } + } + 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 = "." + } + s.serveFromTree(w, r, bestTree, bestMount, rel) + 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 @@ -142,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 fa07d3c..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 @@ -157,6 +162,37 @@ func TestSyntheticListingsAboveMount(t *testing.T) { } } +// TestNestedMountWinsOverParent pins dispatch to longest-prefix matching. +// Both "docs" and "docs/deep" are mounted, and the parent tree contains its own +// deep/note.md, so both mounts can serve the request path -- with different bytes. +// Map iteration order made the old first-hit dispatch nondeterministic here, +// hence the repeated requests: one lucky pass must not mask the bug. +func TestNestedMountWinsOverParent(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + "parent/deep/note.md": "from parent tree\n", + "child/note.md": "from child mount\n", + } { + p := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + srv := exhibitServer(map[string]exhibit{ + "docs": {Path: filepath.Join(dir, "parent")}, + "docs/deep": {Path: filepath.Join(dir, "child")}, + }, theme{}) + for i := 0; i < 20; i++ { + rec := get(t, srv, "/docs/deep/note.md?fmt=raw") + if got := rec.Body.String(); got != "from child mount\n" { + t.Fatalf("request %d served %q; want the more specific mount", i, got) + } + } +} + func TestUnmountedSiblingsInvisible(t *testing.T) { srv := newNestedTestServer(t) rec := get(t, srv, "/meta/research/") @@ -216,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)