vitrine

config: a forwards section, for reverse-proxy mounts

commit 9b90c98088aa3d4753c3eddb7f18bcee5c82816e · parent e5ae340386 · browse this tree
Ximianth, 2026-08-14 16:24:37 UTC

Forwards map URL prefixes to backend base URLs, sibling to exhibits.
The name "/" is the root forward: it will catch requests that nothing
else claims (dispatch wiring comes separately; this commit is schema
and validation only).

Validation is fail-closed at load time, same posture as exhibits:
  - a name colliding with an exhibit is an error;
  - the target scheme is mandatory, http or https only.
Notably ws:// is rejected with an explanation: a websocket connection
starts as an ordinary http request carrying an Upgrade header, so the
proxy target is http even for pure-websocket backends.  Bare host:port
is also rejected rather than sugared -- url.Parse reads "localhost:8866"
as scheme "localhost", and un-confusing that costs more than the sugar
is worth.

Backend reachability is deliberately NOT checked at load time:
backends come and go while vitrine runs; per-request 502 is the
correct report (and arrives with the dispatch commit).
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")
+	}
+}