carlos / platform Public

edge: resolve a leading path segment for hosts that carry a prefix map

Host resolution is unchanged and stays the primitive: the routability check
in the cert path asks only whether this box serves a hostname, and must not
gain a path. Request dispatch gets a path-aware wrapper that reads one
snapshot of the map for both lookups.

Matching is segment-wise equality, so /paulcarson is never dragged across a
migration by /paulca, and the prefix is not stripped, so the flipped and
unflipped URL spaces stay identical. Every miss — unlisted prefix, dangling
target, bare / — falls through to the hostname's own route.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7c497b7e22751aa8d95a113baa3a7cab67dc40d0 parent e56564b
2 files changed, +400 −10
  • internal/edge/edge.go +108 −10
  • internal/edge/path_test.go +292 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 9c5a1bc42..6ee146919 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -254,6 +254,18 @@ type upstream struct {
// the flag exists so wildcard parent derivation (currentHosts) never
// treats a customer hostname as a platform host.
alias bool
+ // paths, when non-nil, splits this hostname's traffic by leading URL
+ // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the
+ // route that serves it, resolved against the same live map at request
+ // time. Nil for every host that does not use prefix routing, which is
+ // every host on the platform until a claim publishes a map — the nil
+ // check in lookupUpstreamPath is the entire cost this feature imposes on
+ // everyone else.
+ //
+ // Hostnames rather than resolved upstreams, deliberately: an upstream
+ // captured here would be a stale copy of the target's entry the moment
+ // the target's route changed within the same map build.
+ paths map[string]string
}
// New builds an Edge over reg and performs the startup reload. act may be nil
@@ -581,6 +593,33 @@ func (e *Edge) Reload() error {
}
m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib}
}
+ // Path routes, folded BEFORE aliases so an alias of a prefix-routed
+ // hostname inherits its map along with everything else it copies — an
+ // alias is meant to be indistinguishable from its primary, and a
+ // hostname that routed by prefix under one name but not the other would
+ // be a trap.
+ //
+ // Targets are recorded as hostnames, not resolved here: every route is
+ // already in m by this point, but resolving now would freeze a copy of
+ // the target's entry, and the target's own row may still be rewritten by
+ // the alias fold below.
+ paths, err := e.reg.ListPaths()
+ if err != nil {
+ return err
+ }
+ for _, p := range paths {
+ up, ok := m[p.Host]
+ if !ok {
+ // The hostname has no route on this box. The reconcile pass
+ // should not have produced this row; nothing to attach it to.
+ continue
+ }
+ if up.paths == nil {
+ up.paths = make(map[string]string)
+ }
+ up.paths[p.Prefix] = p.Target
+ m[p.Host] = up
+ }
// Attached hostnames (#151): each alias resolves to the primary's OWN
// entry — same proxy value, same route (so activator keying, version
// header, and purge tenancy are all the primary's). Routes always win
@@ -849,13 +888,13 @@ func (e *Edge) deploymentHost(host string) bool {
return e.Allowed(host) || e.homes.Load().KnownHost(host)
}
-// lookupUpstream resolves host to its upstream entry — the request-routing
-// lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy — see the comment
-// there). An exact match (a real route, or a plain #151 alias landed in the
-// same map) always wins; on a miss, a one-label wildcard key folds in:
-// "*.<parent>" is the shape Task 3's reconcile pass derives from a claim's
-// catch-all row, and it lands in the SAME map as every other alias
-// (up.alias = true, primary's route + proxy verbatim) so this fallback
+// lookupIn resolves host to its upstream entry within a given map — the
+// request-routing lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy —
+// see the comment there). An exact match (a real route, or a plain #151
+// alias landed in the same map) always wins; on a miss, a one-label wildcard
+// key folds in: "*.<parent>" is the shape Task 3's reconcile pass derives
+// from a claim's catch-all row, and it lands in the SAME map as every other
+// alias (up.alias = true, primary's route + proxy verbatim) so this fallback
// resolves to the primary's own entry — the identical map value the
// #151/#152 agreed rule requires, not a value that merely behaves the same.
// A wildcard matches exactly one label, mirroring the TLS cert: host[i:]
@@ -863,8 +902,7 @@ func (e *Edge) deploymentHost(host string) bool {
// (a.b.woodstar.app) composes "*.b.woodstar.app" and misses, and the apex
// itself (woodstar.app) composes "*.app" — never its own "*.woodstar.app" —
// so it can never match its own wildcard.
-func (e *Edge) lookupUpstream(host string) (upstream, bool) {
- m := e.current()
+func lookupIn(m map[string]upstream, host string) (upstream, bool) {
if up, ok := m[host]; ok {
return up, true
}
@@ -876,6 +914,66 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
return upstream{}, false
}
+// lookupUpstream resolves a hostname alone. Kept as the primitive because not
+// every caller is answering "where does this REQUEST go" — the :80->:443
+// routability check in httpFallbackHandler asks only whether this box serves
+// the hostname at all, and giving that question a path would make a
+// hostname's routability depend on which URL happened to ask.
+func (e *Edge) lookupUpstream(host string) (upstream, bool) {
+ return lookupIn(e.current(), host)
+}
+
+// lookupUpstreamPath resolves a request: the hostname exactly as
+// lookupUpstream does, then — only for a hostname carrying a prefix map — the
+// first path segment.
+//
+// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then
+// current() again would let a reload land between them and resolve a prefix
+// against a different table than the host came from.
+//
+// Every miss falls through to the hostname's own route rather than failing:
+// an unlisted prefix, a dangling target, a request for "/". A prefix map is a
+// migration in progress, and the un-migrated answer is always the safe one.
+func (e *Edge) lookupUpstreamPath(host, path string) (upstream, bool) {
+ m := e.current()
+ up, ok := lookupIn(m, host)
+ if !ok || up.paths == nil {
+ return up, ok
+ }
+ seg := firstSegment(path)
+ if seg == "" {
+ return up, true
+ }
+ target, ok := up.paths[seg]
+ if !ok {
+ return up, true
+ }
+ tu, ok := m[target]
+ if !ok {
+ return up, true
+ }
+ return tu, true
+}
+
+// firstSegment returns the first path segment of an absolute URL path,
+// without slashes: "/paulca/awesomeconf" -> "paulca". "" for "/", for the
+// empty string, and for anything not starting with "/".
+//
+// Returning the segment rather than testing a string prefix is what makes
+// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not
+// equal to "paulca", so a longer account name can never be dragged across a
+// migration by a shorter one.
+func firstSegment(p string) string {
+ if p == "" || p[0] != '/' {
+ return ""
+ }
+ p = p[1:]
+ if i := strings.IndexByte(p, '/'); i >= 0 {
+ p = p[:i]
+ }
+ return p
+}
+
func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
@@ -927,7 +1025,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// nothing else.
r, hopped := e.acceptHop(r, host)
- up, ok := e.lookupUpstream(host)
+ up, ok := e.lookupUpstreamPath(host, r.URL.Path)
if !ok {
if hopped {
// A PROVEN hop from a sibling edge, for a host that has moved (or
diff --git a/internal/edge/path_test.go b/internal/edge/path_test.go
new file mode 100644
index 000000000..8c3ff8ae9
--- /dev/null
+++ b/internal/edge/path_test.go
@@ -0,0 +1,292 @@
+package edge
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// upsertPath writes one route_paths row, the shape the domains reconcile pass
+// derives from a claim's Paths map.
+func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) {
+ t.Helper()
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app",
+ }); err != nil {
+ t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err)
+ }
+}
+
+// splitHost stands up one hostname whose default route is "classic" and whose
+// "flipped" prefix reaches a second route, returning the live test server.
+func splitHost(t *testing.T) *httptest.Server {
+ t.Helper()
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the
+// other route's backend while the same hostname's other traffic does not.
+func TestPathRouteReachesTarget(t *testing.T) {
+ srv := splitHost(t)
+ for _, tc := range []struct{ path, want string }{
+ {"/flipped", "go"},
+ {"/flipped/awesomeconf", "go"},
+ {"/flipped/awesomeconf/admin", "go"},
+ {"/notflipped", "classic"},
+ {"/notflipped/event", "classic"},
+ {"/", "classic"},
+ {"", "classic"},
+ } {
+ code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", tc.path, code)
+ }
+ if body != tc.want {
+ t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want)
+ }
+ }
+}
+
+// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be
+// segment-wise equality, never a string prefix — "flippedxyz" is a different
+// account from "flipped" and must not be dragged across the migration with it.
+func TestPathMatchIsSegmentBounded(t *testing.T) {
+ srv := splitHost(t)
+ for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} {
+ code, body := getHostPath(t, srv.URL, "ti.to", p)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", p, code)
+ }
+ if body != "classic" {
+ t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body)
+ }
+ }
+}
+
+// TestPathFoldOrderAliasInheritsPrefixMap pins the fold order in Reload:
+// path routes are folded into the map BEFORE aliases (edge.go, the comment
+// above the path fold), so an alias of a prefix-routed hostname inherits its
+// prefix map along with everything else it copies. Swap that order and this
+// test is the one that catches it — every other test in this file talks to
+// the primary hostname directly and would keep passing.
+func TestPathFoldOrderAliasInheritsPrefixMap(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+ upsertAlias(t, reg, "ti-to.alias.example", "ti.to")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ code, body := getHostPath(t, srv.URL, "ti-to.alias.example", "/flipped")
+ if code != http.StatusOK || body != "go" {
+ t.Fatalf("alias /flipped: status=%d body=%q, want 200/go — the alias must inherit ti.to's prefix map, which requires path routes to fold BEFORE aliases in Reload", code, body)
+ }
+}
+
+// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The
+// flipped and unflipped URL spaces must be identical, which is the whole
+// point of a seamless migration.
+func TestPathRouteDoesNotStripThePrefix(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(r.URL.Path))
+ }))
+ t.Cleanup(golive.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf")
+ if body != "/paulca/awesomeconf" {
+ t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body)
+ }
+}
+
+// TestPathPreservesInboundHost: the upstream sees the hostname the browser
+// asked for, matching the alias contract. Tito Go must be able to build
+// absolute URLs that keep the visitor on ti.to.
+func TestPathPreservesInboundHost(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, seenHost := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ getHostPath(t, srv.URL, "ti.to", "/paulca")
+ if *seenHost != "ti.to" {
+ t.Fatalf("upstream saw Host %q, want ti.to", *seenHost)
+ }
+}
+
+// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the
+// live map must serve the hostname's own route, never 404. This is the
+// failure direction the whole design depends on.
+func TestPathRowForMissingTargetFallsThrough(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "vanished.example")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ code, body := getHostPath(t, srv.URL, "ti.to", "/paulca")
+ if code != http.StatusOK || body != "classic" {
+ t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body)
+ }
+}
+
+// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on
+// the platform today has no prefix map and must behave exactly as before.
+func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "plain")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ for _, p := range []string{"/", "/anything", "/deep/path/here"} {
+ code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p)
+ if code != http.StatusOK || body != "plain" {
+ t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body)
+ }
+ }
+}
+
+// TestFirstSegment is the matcher itself, tested directly because every
+// routing decision in this feature rests on it.
+func TestFirstSegment(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"/paulca", "paulca"},
+ {"/paulca/", "paulca"},
+ {"/paulca/awesomeconf", "paulca"},
+ {"/paulca/awesomeconf/admin", "paulca"},
+ {"/", ""},
+ {"", ""},
+ {"paulca", ""}, // not an absolute path; never matches
+ {"//", ""},
+ } {
+ if got := firstSegment(tc.in); got != tc.want {
+ t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+// getHostPath issues GET path against srv with an explicit Host header.
+func getHostPath(t *testing.T, srvURL, host, path string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequest("GET", srvURL+path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Host = host
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp.StatusCode, string(b)
+}