tunnelclient: mark every tunnelled request; the remote edge adopts the hub's geo answer (geoip spec §8.4, D2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+138
−4
internal/edge/geoipheaders.go+4 −3internal/edge/tunnel_geo_e2e_test.go+87 −0internal/tunnelclient/mark_test.go+20 −0internal/tunnelclient/tunnelclient.go+27 −1
CI cancelled — superseded by a newer push — run details
diff --git a/internal/edge/geoipheaders.go b/internal/edge/geoipheaders.go| index 993e90d51..963b40fae 100644 |
| --- a/internal/edge/geoipheaders.go |
| +++ b/internal/edge/geoipheaders.go |
| @@ -7,6 +7,7 @@ import ( |
| "net/http/httputil" |
| "github.com/carlosframework/platform/internal/geoip" |
| + "github.com/carlosframework/platform/internal/tunnelclient" |
| ) |
| // geoResultKey carries the request's one geo answer (spec §8.2): computed |
| @@ -19,9 +20,9 @@ func geoAnswerFrom(ctx context.Context) (geoip.Answer, bool) { |
| return a, ok |
| } |
| -// fromTunnel reports the tunnel client's mark (Task 10 sets it to |
| -// tunnelclient.FromTunnel). A var so this file builds before Task 10. |
| -var fromTunnel = func(ctx context.Context) bool { return false } |
| +// fromTunnel is the tunnel client's mark (geoip spec §8.4, D2): set on |
| +// every request tunnelclient serves, never by anything a client sends. |
| +var fromTunnel = tunnelclient.FromTunnel |
| // decideGeo is step 3 of spec §8.1. hopPresent says the request ARRIVED |
| // carrying X-Carlos-Hop (captured before acceptHop strips it); hopped says |
diff --git a/internal/edge/tunnel_geo_e2e_test.go b/internal/edge/tunnel_geo_e2e_test.go| new file mode 100644 |
| index 000000000..2847f61cc |
| --- /dev/null |
| +++ b/internal/edge/tunnel_geo_e2e_test.go |
| @@ -0,0 +1,87 @@ |
| +package edge |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/geoip" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// tunnelGeoScene is a hub edge routing demo.example over a real tunnelclient |
| +// connection to a remote box whose own Edge serves the app (geoip spec §8.4, |
| +// D2). It returns the hub, the remote edge, and a request func. |
| +func tunnelGeoScene(t *testing.T) (hub, remote *Edge, get func() map[string]string) { |
| + t.Helper() |
| + hubReg := tunnelRoute(t, registry.Route{Host: "demo.example", Kind: "instance", Status: "ready", Tunnel: "pi-box"}) |
| + hub, err := New(hubReg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(hub.probers.Stop) |
| + srv := httptest.NewServer(hub) |
| + t.Cleanup(srv.Close) |
| + u, _ := url.Parse(srv.URL) |
| + th := NewTunnelHub(u.Hostname(), map[string]string{"pi-box": "sekrit"}) |
| + hub.EnableTunnels(th) |
| + if err := hub.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + remote = geoEdge(t, "demo.example") // the remote box's own edge, geo-echo backend |
| + stop := startFakeRemoteHost(t, srv.URL, "pi-box", "sekrit", remote) |
| + t.Cleanup(stop) |
| + waitForTunnelState(t, th, "pi-box", "connected", e2eReconnectBound) |
| + get = func() map[string]string { |
| + t.Helper() |
| + req, _ := http.NewRequest("GET", srv.URL+"/", nil) |
| + req.Host = "demo.example" |
| + req.Header.Set(geoip.HeaderCountry, "KP") // a forgery at the hub's door |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + got := map[string]string{} |
| + if err := json.Unmarshal(b, &got); err != nil { |
| + t.Fatalf("remote app body %q: %v", b, err) |
| + } |
| + return got |
| + } |
| + return hub, remote, get |
| +} |
| + |
| +func TestTunnelDeliversTheHubsAnswerAndTheRemoteNeverLooksUp(t *testing.T) { |
| + hub, remote, get := tunnelGeoScene(t) |
| + hubSpy, remoteSpy := spyGeo(hub, "GB"), spyGeo(remote, "XX") |
| + h := get() |
| + if h[geoip.HeaderCountry] != "GB" || h[geoip.HeaderSource] != "TestDB-GB" { |
| + t.Fatalf("remote app saw %v, want the hub's GB answer", h) |
| + } |
| + if _, leaked := h[geoip.HeaderHop]; leaked { |
| + t.Fatal("X-Carlos-Hop-Geo reached the remote app") |
| + } |
| + if hubSpy.Load() != 1 || remoteSpy.Load() != 0 { |
| + t.Fatalf("lookups hub=%d remote=%d, want 1 and 0 (ingress computes)", hubSpy.Load(), remoteSpy.Load()) |
| + } |
| +} |
| + |
| +// A miss at the hub crosses as a miss: the remote app gets no headers and |
| +// the remote does not look up for itself. |
| +func TestTunnelCarriesAMissAsAMiss(t *testing.T) { |
| + hub, remote, get := tunnelGeoScene(t) |
| + remoteSpy := spyGeo(remote, "XX") |
| + hub.geoLookup = func(_ net.IP) geo.Snapshot { return geo.Snapshot{Loaded: true, Source: "TestDB"} } |
| + if h := get(); len(h) != 0 { |
| + t.Fatalf("a hub miss reached the remote app as %v", h) |
| + } |
| + if remoteSpy.Load() != 0 { |
| + t.Fatal("the remote looked up after the hub missed") |
| + } |
| +} |
diff --git a/internal/tunnelclient/mark_test.go b/internal/tunnelclient/mark_test.go| new file mode 100644 |
| index 000000000..dac2cf531 |
| --- /dev/null |
| +++ b/internal/tunnelclient/mark_test.go |
| @@ -0,0 +1,20 @@ |
| +package tunnelclient |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +func TestMarkIsOnlyOnMarkedHandlers(t *testing.T) { |
| + var saw bool |
| + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { saw = FromTunnel(r.Context()) }) |
| + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) |
| + if saw { |
| + t.Fatal("an unmarked handler saw the mark") |
| + } |
| + Mark(h).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) |
| + if !saw { |
| + t.Fatal("Mark did not mark") |
| + } |
| +} |
diff --git a/internal/tunnelclient/tunnelclient.go b/internal/tunnelclient/tunnelclient.go| index 92b4680f6..e4718c3fe 100644 |
| --- a/internal/tunnelclient/tunnelclient.go |
| +++ b/internal/tunnelclient/tunnelclient.go |
| @@ -117,7 +117,7 @@ func Loop(ctx context.Context, cfg Config) { |
| setState(StateConnected) |
| logf("tunnel connected as %q", cfg.Name) |
| connectedAt := time.Now() |
| - serveUntilDead(ctx, conn, cfg.Handler) // blocks until the conn dies |
| + serveUntilDead(ctx, conn, Mark(cfg.Handler)) // blocks until the conn dies |
| setState(StateReconnecting) |
| logf("tunnel lost — re-dialing") |
| // A connection that stayed up a while earns a fast retry (the |
| @@ -261,6 +261,32 @@ func Dial(ctx context.Context, rawURL, name, token string) (net.Conn, error) { |
| return conn, nil |
| } |
| +type tunnelKey struct{} |
| + |
| +// FromTunnel reports whether a request was served over a tunnel this client |
| +// dialled: the hub computed its GeoIP answer, so the edge adopts the hub's |
| +// headers and does not observe the request for analytics (the hub already |
| +// did). Set on every tunnelled request whatever the URL's scheme (geoip spec |
| +// D2: on a plaintext tunnel an on-path attacker can already inject any |
| +// request, so withholding geo would protect nothing and would make the hub's |
| +// cache disagree with what the app saw). Nothing outside this package can |
| +// set it. |
| +func FromTunnel(ctx context.Context) bool { |
| + v, _ := ctx.Value(tunnelKey{}).(bool) |
| + return v |
| +} |
| + |
| +type markedHandler struct{ h http.Handler } |
| + |
| +func (m markedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + m.h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tunnelKey{}, true))) |
| +} |
| + |
| +// Mark wraps h so every request it serves carries the tunnel mark. Loop |
| +// applies it; a caller using Serve directly applies it itself if it wants |
| +// the mark. |
| +func Mark(h http.Handler) http.Handler { return markedHandler{h} } |
| + |
| // Serve serves handler over the inverted conn until it dies. |
| func Serve(conn net.Conn, handler http.Handler) { |
| (&http2.Server{}).ServeConn(conn, &http2.ServeConnOpts{Handler: handler}) |