fix(provider): relay rewrites host-relative upstreams to host.docker.internal

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 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-09-10 10:34:43 +02:00 • committed by Nicolas De loof
parent aab5819147
commit fc72c165aa
2 changed files with 57 additions and 1 deletions

View file

@ -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]

View file

@ -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) {