From fc72c165aaa242ba1d9ffe0d737dd3ce8e9fd551 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 10 Sep 2026 10:34:43 +0200 Subject: [PATCH] fix(provider): relay rewrites host-relative upstreams to host.docker.internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider legitimately expresses its endpoints from the host's perspective — "localhost:5734" is where its resource listens, on the machine compose runs on. But the relay dials from its own network namespace, where loopback names the relay container itself: routes were passed verbatim, so every connection died on the relay's own empty loopback while the host.docker.internal ExtraHosts mapping provisioned for exactly this purpose sat unused. relayRoutesSpec now rewrites host-relative upstreams (localhost, any loopback IP, unspecified or empty host) to host.docker.internal before rendering; LAN IPs and DNS names still pass verbatim. The rewrite happens before the identity hash, so existing relays carrying the old routes are recreated on the next up. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- pkg/compose/relay.go | 29 ++++++++++++++++++++++++++++- pkg/compose/relay_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/pkg/compose/relay.go b/pkg/compose/relay.go index 645763633..d2f851d87 100644 --- a/pkg/compose/relay.go +++ b/pkg/compose/relay.go @@ -21,6 +21,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net" "os" "sort" "strings" @@ -50,6 +51,8 @@ func relayImage() string { // relayRoutesSpec renders endpoints as the relay's RELAY_ROUTES value, // canonically ordered so it doubles as the identity the relay label hashes. +// Upstreams are rewritten for the relay's vantage point (relayUpstream) +// before rendering, so the identity follows what the relay actually dials. func relayRoutesSpec(endpoints map[int]string) string { ports := make([]int, 0, len(endpoints)) for port := range endpoints { @@ -58,11 +61,35 @@ func relayRoutesSpec(endpoints map[int]string) string { sort.Ints(ports) routes := make([]string, 0, len(ports)) for _, port := range ports { - routes = append(routes, fmt.Sprintf("%d=%s", port, endpoints[port])) + routes = append(routes, fmt.Sprintf("%d=%s", port, relayUpstream(endpoints[port]))) } return strings.Join(routes, ",") } +// relayUpstream rewrites a host-relative upstream for the relay's vantage +// point. Providers express endpoints from the host's perspective — a loopback +// or unspecified address names the machine compose runs on — but the relay +// dials from its own network namespace, where those addresses name the relay +// container itself. host.docker.internal resolves natively on Docker Desktop +// and is provisioned through ExtraHosts (host-gateway) on plain Linux +// engines. Anything else (a LAN IP, a DNS name) is reachable as-is from the +// relay and passes verbatim. +func relayUpstream(endpoint string) string { + host, port, err := net.SplitHostPort(endpoint) + if err != nil { + // validated at parse time (parseEndpointMessage); keep verbatim + return endpoint + } + hostRelative := host == "" || strings.EqualFold(host, "localhost") + if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) { + hostRelative = true + } + if !hostRelative { + return endpoint + } + return net.JoinHostPort("host.docker.internal", port) +} + func relayIdentity(routes string) string { digest := sha256.Sum256([]byte(relayImage() + "|" + routes)) return hex.EncodeToString(digest[:])[:12] diff --git a/pkg/compose/relay_test.go b/pkg/compose/relay_test.go index 55767b22d..3b646afe6 100644 --- a/pkg/compose/relay_test.go +++ b/pkg/compose/relay_test.go @@ -67,6 +67,35 @@ func TestRelayRoutesSpec(t *testing.T) { assert.Assert(t, id1 != relayIdentity("80=host.docker.internal:49153")) } +// Providers express endpoints from the host's perspective, the relay dials +// from a container: host-relative addresses must be rewritten to +// host.docker.internal, everything else passes verbatim. +func TestRelayUpstream(t *testing.T) { + for endpoint, want := range map[string]string{ + "localhost:5734": "host.docker.internal:5734", + "LOCALHOST:5734": "host.docker.internal:5734", + "127.0.0.1:5734": "host.docker.internal:5734", + "127.1.2.3:5734": "host.docker.internal:5734", + "[::1]:5734": "host.docker.internal:5734", + "0.0.0.0:5734": "host.docker.internal:5734", + "[::]:5734": "host.docker.internal:5734", + ":5734": "host.docker.internal:5734", + "192.168.1.10:5734": "192.168.1.10:5734", + "[fdcb::2]:5734": "[fdcb::2]:5734", + "some.host.corp:5734": "some.host.corp:5734", + "host.docker.internal:5734": "host.docker.internal:5734", + } { + assert.Equal(t, relayUpstream(endpoint), want, "endpoint %q", endpoint) + } +} + +// The rewrite happens inside relayRoutesSpec, so the relay identity hashes +// what the relay actually dials. +func TestRelayRoutesSpecRewritesHostRelativeUpstreams(t *testing.T) { + routes := relayRoutesSpec(map[int]string{80: "localhost:5734"}) + assert.Equal(t, routes, "80=host.docker.internal:5734") +} + // The relay joins the networks of the services depending on the provider — // its consumers — and falls back to the project default network. func TestRelayNetworks(t *testing.T) {