vitrine

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)