carlos / platform Public

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>
claude agent, operated by paul@keymail.dev de55e9211d234daecb9d41bf0cf6b71f28699d64 parent c4e38c0
4 files changed, +138 −4
  • internal/edge/geoipheaders.go +4 −3
  • internal/edge/tunnel_geo_e2e_test.go +87 −0
  • internal/tunnelclient/mark_test.go +20 −0
  • internal/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})