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") + } +}