carlos / platform Public

edge: capture/decide/expose geo headers; setGeo on every proxy; door and rejected hop get none (geoip spec §8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3b60bf4b8a0a67d07fa075dbc6c862695ac33ee3 parent ec776ba
4 files changed, +445 −3
  • internal/edge/backhaul_e2e_test.go +10 −3
  • internal/edge/edge.go +23 −0
  • internal/edge/geoipheaders.go +58 −0
  • internal/edge/geoipheaders_test.go +354 −0
diff --git a/internal/edge/backhaul_e2e_test.go b/internal/edge/backhaul_e2e_test.go
index d492b374d..b841e47f9 100644
--- a/internal/edge/backhaul_e2e_test.go
+++ b/internal/edge/backhaul_e2e_test.go
@@ -62,6 +62,7 @@ import (
"golang.org/x/crypto/acme/autocert"
+ "github.com/carlosframework/platform/internal/geoip"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -101,10 +102,11 @@ const (
func e2eApp(t *testing.T, marker string) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintf(w, "%s|xff=%s|xfh=%s|hop=%s|auth=%s", marker,
+ fmt.Fprintf(w, "%s|xff=%s|xfh=%s|hop=%s|auth=%s|geo=%s|hopgeo=%s", marker,
strings.Join(r.Header.Values("X-Forwarded-For"), " + "),
r.Header.Get("X-Forwarded-Host"),
- r.Header.Get(hopHeader), r.Header.Get(hopAuthHeader))
+ r.Header.Get(hopHeader), r.Header.Get(hopAuthHeader),
+ r.Header.Get(geoip.HeaderCountry), r.Header.Get(geoip.HeaderHop))
}))
t.Cleanup(ts.Close)
return ts
@@ -113,12 +115,13 @@ func e2eApp(t *testing.T, marker string) *httptest.Server {
// e2eSeen is one parsed e2eApp response.
type e2eSeen struct {
marker, xff, xfh, hop, auth string
+ geoCountry, hopGeo string
}
func e2eParseApp(t *testing.T, body string) e2eSeen {
t.Helper()
parts := strings.Split(body, "|")
- if len(parts) != 5 {
+ if len(parts) != 7 {
t.Fatalf("body %q did not come from the tenant app at all", body)
}
seen := e2eSeen{marker: parts[0]}
@@ -133,6 +136,10 @@ func e2eParseApp(t *testing.T, body string) e2eSeen {
seen.hop = v
case "auth":
seen.auth = v
+ case "geo":
+ seen.geoCountry = v
+ case "hopgeo":
+ seen.hopGeo = v
}
}
return seen
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 5c6e44534..698aea30f 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -54,6 +54,7 @@ import (
"github.com/carlosframework/platform/internal/edgesfile"
"github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/geoip"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -1359,6 +1360,14 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
+ // GeoIP step 1 (geoip spec §8.1): capture and strip every geo header
+ // before ANY branch. Nothing trusts the captured values until step 3,
+ // after acceptHop and the tunnel mark have said whether the transport is
+ // one we believe. hopPresent is read here because acceptHop deletes the
+ // hop header: a request that ARRIVED claiming to be a hop and was not
+ // verified must get no geo at all (its RemoteAddr is the sending edge).
+ capturedGeo := geoip.Capture(r.Header)
+ hopPresent := r.Header.Get(hopHeader) != ""
// The edge-local door's mark (door.go), read before anything answers. A
// door caller never reaches the steering probe, the tunnel hub or the
// store door: those names are unknown hosts to it in any spelling, and
@@ -1414,6 +1423,14 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// On a request carrying no X-Carlos-Hop this is one Header.Get and
// nothing else.
r, hopped := e.acceptHop(r, host)
+ // GeoIP steps 3-4 (geoip spec §8.1, §8.2): decide once, keep it on the
+ // context for setGeo and analytics, and expose the app-visible headers on
+ // r.Header so the cache's Vary matching sees exactly what the app will
+ // get. Before every branch below: backhaul, door scope, gate, upgrade,
+ // cache.
+ geoAns := e.decideGeo(r, capturedGeo, hopPresent, hopped, isDoor)
+ r = r.WithContext(context.WithValue(r.Context(), geoResultKey{}, geoAns))
+ geoAns.WriteApp(r.Header)
up, ok := e.lookupUpstreamPath(host, r.URL.Path)
// A local match through a one-label "*.<parent>" key (a route
@@ -2245,6 +2262,7 @@ func unixProxy(socketPath string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
+ setGeo(pr, false) // geoip spec §8.3: after hop-header removal, from the request's one answer
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = "carlos" // placeholder; the unix dialer ignores it
pr.Out.Host = pr.In.Host // backend sees the real hostname
@@ -2271,6 +2289,7 @@ func tcpProxy(addr string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
+ setGeo(pr, false) // geoip spec §8.3: after hop-header removal, from the request's one answer
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = addr
pr.Out.Host = pr.In.Host // backend sees the real hostname
@@ -2359,6 +2378,7 @@ func multiProxy(host string, p *proberSet) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
+ setGeo(pr, false) // geoip spec §8.3: after hop-header removal, from the request's one answer
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = multiDialHost // placeholder; the dialer below ignores it
pr.Out.Host = pr.In.Host // backend sees the real hostname
@@ -2446,6 +2466,7 @@ func tunnelProxy(name string, hub *TunnelHub) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
+ setGeo(pr, true) // geoip spec §8.3: after hop-header removal, from the request's one answer
pr.Out.URL.Scheme = "https"
pr.Out.URL.Host = pr.In.Host // h2 :authority = the real hostname
pr.Out.Host = pr.In.Host
@@ -2501,6 +2522,7 @@ func fleetProxy(fleet, boxLabel string, hub *TunnelHub) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
+ setGeo(pr, true) // geoip spec §8.3: after hop-header removal, from the request's one answer
pr.Out.URL.Scheme = "https"
pr.Out.URL.Host = pr.In.Host // h2 :authority = the real hostname
pr.Out.Host = pr.In.Host
@@ -2875,6 +2897,7 @@ func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper, logThrottl
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
+ setGeo(pr, true) // geoip spec §8.3: after hop-header removal, from the request's one answer
pr.Out.URL.Scheme = "https"
pr.Out.URL.Host = host // SNI + verification + pool key; the dial is the transport's
pr.Out.Host = pr.In.Host // the home edge routes on the real hostname
diff --git a/internal/edge/geoipheaders.go b/internal/edge/geoipheaders.go
new file mode 100644
index 000000000..993e90d51
--- /dev/null
+++ b/internal/edge/geoipheaders.go
@@ -0,0 +1,58 @@
+package edge
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/http/httputil"
+
+ "github.com/carlosframework/platform/internal/geoip"
+)
+
+// geoResultKey carries the request's one geo answer (spec §8.2): computed
+// at ingress or adopted from a trusted transport, read by setGeo, the cache
+// path (through r.Header) and analytics.
+type geoResultKey struct{}
+
+func geoAnswerFrom(ctx context.Context) (geoip.Answer, bool) {
+ a, ok := ctx.Value(geoResultKey{}).(geoip.Answer)
+ 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 }
+
+// decideGeo is step 3 of spec §8.1. hopPresent says the request ARRIVED
+// carrying X-Carlos-Hop (captured before acceptHop strips it); hopped says
+// acceptHop verified it.
+func (e *Edge) decideGeo(r *http.Request, c geoip.Captured, hopPresent, hopped, isDoor bool) geoip.Answer {
+ switch {
+ case isDoor:
+ return geoip.Off() // RemoteAddr is this box (door.go)
+ case hopped:
+ return c.Adopt() // the sending edge looked up; never trust our own peer
+ case hopPresent:
+ return geoip.Off() // rejected hop: RemoteAddr is the sending edge (codex R2-1)
+ case fromTunnel(r.Context()):
+ return c.Adopt() // the hub looked up (spec §8.4)
+ }
+ ip := net.ParseIP(clientIP(r))
+ return geoip.FromSnapshot(e.geoLookup(ip))
+}
+
+// setGeo is every Rewrite's geo step (spec §8.3): after hop-by-hop removal,
+// delete every geo header from the outbound request and write the answer
+// back from the context. forwardsToEdge adds X-Carlos-Hop-Geo for the next
+// edge; an app never receives it.
+func setGeo(pr *httputil.ProxyRequest, forwardsToEdge bool) {
+ geoip.StripAll(pr.Out.Header)
+ a, ok := geoAnswerFrom(pr.In.Context())
+ if !ok {
+ a = geoip.Off() // scheduler calls through UnixProxy and anything else that never passed ServeHTTP
+ }
+ a.WriteApp(pr.Out.Header)
+ if forwardsToEdge {
+ pr.Out.Header.Set(geoip.HeaderHop, a.HopValue())
+ }
+}
diff --git a/internal/edge/geoipheaders_test.go b/internal/edge/geoipheaders_test.go
new file mode 100644
index 000000000..bdf5d59a6
--- /dev/null
+++ b/internal/edge/geoipheaders_test.go
@@ -0,0 +1,354 @@
+package edge
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/http/httputil"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/geoip"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+var geoHeaderNames = []string{geoip.HeaderCountry, geoip.HeaderSubdivision, geoip.HeaderLat, geoip.HeaderLon, geoip.HeaderSource, geoip.HeaderHop}
+
+// geoEchoBackend answers with the geo headers it received, as JSON.
+func geoEchoBackend(t *testing.T) *httptest.Server {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got := map[string]string{}
+ for _, k := range geoHeaderNames {
+ if v := r.Header.Get(k); v != "" {
+ got[k] = v
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(got)
+ }))
+ t.Cleanup(ts.Close)
+ return ts
+}
+
+// geoEdge serves host from a geo-echo backend.
+func geoEdge(t *testing.T, host string) *Edge {
+ t.Helper()
+ backend := geoEchoBackend(t)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: host, 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)
+ }
+ t.Cleanup(e.probers.Stop)
+ return e
+}
+
+// spyGeo makes every lookup a hit for country and counts the calls.
+func spyGeo(e *Edge, country string) *atomic.Int64 {
+ var n atomic.Int64
+ e.geoLookup = func(net.IP) geo.Snapshot {
+ n.Add(1)
+ return geo.Snapshot{Loaded: true, Found: true, Source: "TestDB-" + country,
+ Result: geo.Result{Country: country, Point: geo.Point{Lat: 1.234, Lon: 5.678}, HasPoint: true}}
+ }
+ return &n
+}
+
+// serveGeo drives one request through ServeHTTP and returns the headers the
+// app saw.
+func serveGeo(t *testing.T, e *Edge, r *http.Request) (int, map[string]string) {
+ t.Helper()
+ w := httptest.NewRecorder()
+ e.ServeHTTP(w, r)
+ got := map[string]string{}
+ if w.Code == http.StatusOK {
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("backend body %q: %v", w.Body.String(), err)
+ }
+ }
+ return w.Code, got
+}
+
+func geoReq(host, from string) *http.Request {
+ r := httptest.NewRequest("GET", "http://"+host+"/", nil)
+ r.RemoteAddr = net.JoinHostPort(from, "4444")
+ return r
+}
+
+func TestDirectRequestGetsGeoHeadersFromTheRealReader(t *testing.T) {
+ e := geoEdge(t, "app.test")
+ db, err := geo.OpenMMDB("../geo/testdata/GeoIP2-City-Test.mmdb")
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.geo.Swap(db)
+ code, h := serveGeo(t, e, geoReq("app.test", "81.2.69.142"))
+ if code != 200 || h[geoip.HeaderCountry] != "GB" || h[geoip.HeaderSubdivision] != "GB-ENG" ||
+ h[geoip.HeaderSource] != "GeoIP2-City" || h[geoip.HeaderLat] != "51.51" || h[geoip.HeaderLon] != "-0.09" {
+ t.Fatalf("app saw %v", h)
+ }
+ if _, leaked := h[geoip.HeaderHop]; leaked {
+ t.Fatal("X-Carlos-Hop-Geo reached the app")
+ }
+}
+
+func TestNoDatabaseMeansNoHeaders(t *testing.T) {
+ e := geoEdge(t, "app.test")
+ r := geoReq("app.test", "81.2.69.142")
+ r.Header.Set(geoip.HeaderCountry, "KP")
+ if _, h := serveGeo(t, e, r); len(h) != 0 {
+ t.Fatalf("app saw %v with no database loaded", h)
+ }
+}
+
+// Review Focus 1.
+func TestForgedGeoHeadersNeverReachTheApp(t *testing.T) {
+ e := geoEdge(t, "app.test")
+ spyGeo(e, "GB")
+ for name, mutate := range map[string]func(*http.Request){
+ "plain forgery": func(r *http.Request) {
+ r.Header.Set(geoip.HeaderCountry, "KP")
+ r.Header.Set(geoip.HeaderSource, "forged")
+ r.Header.Set(geoip.HeaderHop, "hit;src=forged")
+ },
+ "connection nomination": func(r *http.Request) {
+ r.Header.Set("Connection", "X-Carlos-Geo-Country, X-Carlos-Geo-Source")
+ },
+ } {
+ r := geoReq("app.test", "81.2.69.142")
+ mutate(r)
+ _, h := serveGeo(t, e, r)
+ if h[geoip.HeaderCountry] != "GB" || h[geoip.HeaderSource] != "TestDB-GB" {
+ t.Errorf("%s: app saw %v, want the edge's own GB answer", name, h)
+ }
+ if _, leaked := h[geoip.HeaderHop]; leaked {
+ t.Errorf("%s: hop header reached the app", name)
+ }
+ }
+}
+
+// meshGeoEdge is geoEdge plus a minted mesh key, so hop requests can be
+// signed (homemap_test.go's hopEdge, with a geo-echo backend).
+func meshGeoEdge(t *testing.T, host string) (*Edge, []byte) {
+ t.Helper()
+ e := geoEdge(t, host)
+ s := meshTestStore(t)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatal(err)
+ }
+ m := newHomeMap(s, "home")
+ refreshHomeMap(t, m)
+ e.homes.Store(m)
+ key, _ := m.meshKeys()
+ return e, key
+}
+
+func TestVerifiedHopAdoptsTheSendersAnswerWithoutLookingUp(t *testing.T) {
+ e, key := meshGeoEdge(t, "app.test")
+ spy := spyGeo(e, "XX") // a lookup here would show as XX
+ r := geoReq("app.test", "198.51.100.7") // the sending edge's address
+ r.Header.Set(hopHeader, "1")
+ r.Header.Set(hopAuthHeader, signHop(key, "app.test", time.Now()))
+ r.Header.Set("X-Forwarded-For", "81.2.69.142")
+ r.Header.Set(geoip.HeaderHop, "hit;src=GeoLite2-City")
+ r.Header.Set(geoip.HeaderCountry, "FR")
+ code, h := serveGeo(t, e, r)
+ if code != 200 || h[geoip.HeaderCountry] != "FR" || h[geoip.HeaderSource] != "GeoLite2-City" {
+ t.Fatalf("app saw %d %v, want the adopted FR", code, h)
+ }
+ if spy.Load() != 0 {
+ t.Fatal("the home edge looked the visitor up instead of adopting")
+ }
+}
+
+// Review Focus 2, codex R2-1.
+func TestRejectedHopIsServedWithNoGeo(t *testing.T) {
+ e, _ := meshGeoEdge(t, "app.test")
+ spy := spyGeo(e, "XX")
+ r := geoReq("app.test", "81.2.69.142")
+ r.Header.Set(hopHeader, "1")
+ r.Header.Set(hopAuthHeader, signHop([]byte("not-the-mesh-key-not-the-mesh-key"), "app.test", time.Now()))
+ r.Header.Set(geoip.HeaderHop, "hit;src=GeoLite2-City")
+ r.Header.Set(geoip.HeaderCountry, "FR")
+ code, h := serveGeo(t, e, r)
+ if code != 200 {
+ t.Fatalf("a rejected hop must still be served (as today), got %d", code)
+ }
+ if len(h) != 0 || spy.Load() != 0 {
+ t.Fatalf("rejected hop: app saw %v, lookups %d; want nothing and no lookup", h, spy.Load())
+ }
+}
+
+func TestDecideGeoDoorIsOffWithoutALookup(t *testing.T) {
+ e := geoEdge(t, "app.test")
+ spy := spyGeo(e, "GB")
+ a := e.decideGeo(geoReq("app.test", "81.2.69.142"), geoip.Captured{}, false, false, true)
+ if a.State != geoip.StateOff || spy.Load() != 0 {
+ t.Fatalf("door = %+v, lookups %d", a, spy.Load())
+ }
+}
+
+func TestDoorCallReachesTheAppWithNoGeo(t *testing.T) {
+ s := newDoorScene(t)
+ spy := spyGeo(s.e, "GB")
+ rec := s.do(t, "GET", doorOwnHost, "/", s.key, map[string]string{geoip.HeaderCountry: "KP"}, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("door call = %d %s", rec.Code, rec.Body.String())
+ }
+ if spy.Load() != 0 {
+ t.Fatal("a door call was looked up: it would locate the box itself")
+ }
+ s.own.mu.Lock()
+ hdr := s.own.hdr
+ s.own.mu.Unlock()
+ for _, k := range geoHeaderNames {
+ if v := hdr.Get(k); v != "" {
+ t.Errorf("door call reached the app with %s: %q", k, v)
+ }
+ }
+}
+
+func TestSetGeoForBackhaulCarriesStateAndAppsNeverSeeIt(t *testing.T) {
+ in := httptest.NewRequest("GET", "http://app.test/", nil)
+ for _, c := range []struct {
+ a geoip.Answer
+ forward bool
+ hop string
+ }{
+ {geoip.Answer{State: geoip.StateHit, Country: "GB", Source: "S"}, true, "hit;src=S"},
+ {geoip.Off(), true, "off"}, // a door call being backhauled
+ {geoip.Answer{State: geoip.StateHit, Country: "GB", Source: "S"}, false, ""},
+ } {
+ pr := &httputil.ProxyRequest{In: in.WithContext(context.WithValue(in.Context(), geoResultKey{}, c.a)), Out: in.Clone(context.Background())}
+ pr.Out.Header.Set(geoip.HeaderHop, "hit;src=forged")
+ pr.Out.Header.Set(geoip.HeaderCountry, "KP")
+ setGeo(pr, c.forward)
+ if got := pr.Out.Header.Get(geoip.HeaderHop); got != c.hop {
+ t.Errorf("%+v forward=%v: hop = %q, want %q", c.a, c.forward, got, c.hop)
+ }
+ if got := pr.Out.Header.Get(geoip.HeaderCountry); got == "KP" {
+ t.Errorf("%+v: forged country survived setGeo", c.a)
+ }
+ }
+ // A request that never passed ServeHTTP (the scheduler's UnixProxy) gets nothing.
+ pr := &httputil.ProxyRequest{In: in, Out: in.Clone(context.Background())}
+ pr.Out.Header.Set(geoip.HeaderCountry, "KP")
+ setGeo(pr, false)
+ if pr.Out.Header.Get(geoip.HeaderCountry) != "" {
+ t.Fatal("no context answer must mean no geo headers")
+ }
+}
+
+func TestBackhaulDeliversTheSendersAnswer(t *testing.T) {
+ s := newBackhaulScene(t)
+ s.converge(t)
+ homeSpy, senderSpy := spyGeo(s.a.edge, "XX"), spyGeo(s.b.edge, "GB")
+ _, body := s.get(t, s.b, "https://"+e2eHomedHost+"/", nil, true)
+ seen := e2eParseApp(t, body)
+ if seen.geoCountry != "GB" || seen.hopGeo != "" {
+ t.Fatalf("home app saw country=%q hopGeo=%q, want GB and no hop header", seen.geoCountry, seen.hopGeo)
+ }
+ if homeSpy.Load() != 0 || senderSpy.Load() != 1 {
+ t.Fatalf("lookups home=%d sender=%d, want 0 and 1 (ingress computes, spec §8.2)", homeSpy.Load(), senderSpy.Load())
+ }
+}
+
+// Review Focus 1: Vary on the geo header picks the right variant.
+func TestCacheVariesOnTheEdgesCountry(t *testing.T) {
+ var hits atomic.Int64
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", geoip.HeaderCountry)
+ w.Write([]byte("country=" + r.Header.Get(geoip.HeaderCountry)))
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ reg.Upsert(registry.Route{Host: "app.test", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"})
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ db, _ := geo.OpenMMDB("../geo/testdata/GeoIP2-City-Test.mmdb")
+ e.geo.Swap(db)
+ get := func(from string, forged string) string {
+ r := geoReq("app.test", from)
+ if forged != "" {
+ r.Header.Set(geoip.HeaderCountry, forged)
+ }
+ w := httptest.NewRecorder()
+ e.ServeHTTP(w, r)
+ return w.Body.String()
+ }
+ if b := get("81.2.69.142", ""); b != "country=GB" {
+ t.Fatalf("GB = %q", b)
+ }
+ if b := get("216.160.83.56", ""); b != "country=US" {
+ t.Fatalf("US = %q", b)
+ }
+ if b := get("81.2.69.142", "US"); b != "country=GB" {
+ t.Fatalf("a forged header picked another country's variant: %q", b)
+ }
+ if hits.Load() != 2 || cachedVariants(e, "app.test", "/") != 2 {
+ t.Fatalf("hits=%d variants=%d, want 2 and 2", hits.Load(), cachedVariants(e, "app.test", "/"))
+ }
+}
+
+func TestUpgradeRequestCarriesGeo(t *testing.T) {
+ // The backend answers the upgrade with the country it was sent, in a
+ // 101 response header (ReverseProxy copies those back to the client).
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ http.Error(w, "not hijackable", http.StatusInternalServerError)
+ return
+ }
+ conn, buf, err := hj.Hijack()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Seen-Country: " +
+ r.Header.Get(geoip.HeaderCountry) + "\r\n\r\n")
+ buf.Flush()
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "app.test", 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)
+ }
+ t.Cleanup(e.probers.Stop)
+ spyGeo(e, "GB")
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ io.WriteString(conn, "GET / HTTP/1.1\r\nHost: app.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"+
+ "X-Carlos-Geo-Country: KP\r\n\r\n")
+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != http.StatusSwitchingProtocols || resp.Header.Get("X-Seen-Country") != "GB" {
+ t.Fatalf("upgrade = %d, backend saw country %q, want 101 and GB", resp.StatusCode, resp.Header.Get("X-Seen-Country"))
+ }
+}