edge: carry the door mark across a signed hop so door callers stay unlimited (#358)
A door request backhauled to the instance's home box arrived there as an
ordinary hop keyed on the sending box's address, so every door caller on
that box shared one bucket and could be limited. The sending edge now adds
X-Carlos-Hop-Door, an HMAC under the mesh key in its own domain ("door\x00"
before the canonical host), when the request came through its door. The
home edge reads it only after the hop itself verifies, marks the context
(hopDoorFrom), and the limiter exempts it. The header carries no caller
identity and is deleted in every acceptHop case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+311
−23
internal/edge/door.go+4 −2internal/edge/edge.go+17 −4internal/edge/homemap.go+35 −8internal/edge/hopdoor_test.go+212 −0internal/edge/mesh.go+36 −3internal/edge/ratelimit.go+7 −6
diff --git a/internal/edge/door.go b/internal/edge/door.go| index c9438f5bb..47a7fa86f 100644 |
| --- a/internal/edge/door.go |
| +++ b/internal/edge/door.go |
| @@ -25,8 +25,9 @@ package edge |
| // the gate's login re-resolution, before the gate, the cache and the |
| // dispatch. |
| // |
| -// Nothing carries the mark across a hop. A backhauled door request is an |
| -// ordinary hop, and acceptHop is unchanged. |
| +// The mark itself never crosses a hop. A backhauled door request carries |
| +// only a signed X-Carlos-Hop-Door saying it is a door call (signHopDoor, |
| +// mesh.go), which keeps it out of the home box's rate limit and nothing more. |
| import ( |
| "context" |
| @@ -106,6 +107,7 @@ func (e *Edge) doorRequest(r *http.Request) (*http.Request, bool) { |
| // The mesh's vocabulary is never a door caller's to speak. |
| r.Header.Del(hopHeader) |
| r.Header.Del(hopAuthHeader) |
| + r.Header.Del(hopDoorHeader) |
| tok, err := doorkey.ParseToken(raw) |
| if err != nil { |
| return nil, false |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 1fee007b2..e4beee12c 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -1465,7 +1465,8 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // 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 |
| // the two branches below are skipped for it outright. Only serveDoor on |
| - // this box sets the mark; nothing carries it across a hop. |
| + // this box sets the mark. A hop carries only the fact of a door call, for |
| + // the rate limit (hopDoorFrom), never this mark. |
| door, isDoor := doorCallerFrom(r.Context()) |
| if isDoor && e.doorReservedHost(host) { |
| e.unknownHost(w, r) |
| @@ -2953,8 +2954,15 @@ func (e *Edge) backhaulTo(w http.ResponseWriter, r *http.Request, host, eip, box |
| // The MAC is over the CONCRETE request host — never the wildcard map key |
| // that may have matched it — because the home edge verifies against its own |
| // literal request host (hopMAC canonicalizes both, mesh.go). |
| - auth := signHop(key, host, time.Now()) |
| - backhaulProxy(host, eip, box, auth, e.backhaulTransport(eip), e.logThrottled).ServeHTTP(w, r) |
| + now := time.Now() |
| + auth := signHop(key, host, now) |
| + // A door call keeps its exemption from the home box's rate limit |
| + // (hopDoorKey, homemap.go). Only the fact crosses, never the caller. |
| + var doorAuth string |
| + if _, ok := doorCallerFrom(r.Context()); ok { |
| + doorAuth = signHopDoor(key, host, now) |
| + } |
| + backhaulProxy(host, eip, box, auth, doorAuth, e.backhaulTransport(eip), e.logThrottled).ServeHTTP(w, r) |
| return true |
| } |
| @@ -3066,7 +3074,7 @@ func (e *Edge) backhaulTransport(eip string) *http.Transport { |
| // failed request for the whole outage. Keyed "backhaul-error "+box (per home |
| // box, not per host — a wildcard home behind one unreachable box would |
| // otherwise mint a fresh throttle bucket per invented hostname). |
| -func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper, logThrottled func(key, msg string)) *httputil.ReverseProxy { |
| +func backhaulProxy(host, eip, box, auth, doorAuth string, tr http.RoundTripper, logThrottled func(key, msg string)) *httputil.ReverseProxy { |
| return &httputil.ReverseProxy{ |
| Rewrite: func(pr *httputil.ProxyRequest) { |
| setForwarded(pr) |
| @@ -3083,6 +3091,11 @@ func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper, logThrottl |
| pr.Out.Host = pr.In.Host // the home edge routes on the real hostname |
| pr.Out.Header.Set(hopHeader, "1") |
| pr.Out.Header.Set(hopAuthHeader, auth) |
| + if doorAuth != "" { |
| + pr.Out.Header.Set(hopDoorHeader, doorAuth) |
| + } else { |
| + pr.Out.Header.Del(hopDoorHeader) |
| + } |
| // The same discipline every other Rewrite here keeps, and needed for |
| // the same reason one hop further along: the home edge scrubs it |
| // again before the app, but a header this edge forwards is a header |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index da513e36c..3b3e047fe 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -100,6 +100,7 @@ const edgesPrefix = "control/edges/" |
| const ( |
| hopHeader = "X-Carlos-Hop" |
| hopAuthHeader = "X-Carlos-Hop-Auth" |
| + hopDoorHeader = "X-Carlos-Hop-Door" |
| ) |
| // edgeRoleHome is the only role v1 writes, and the only one that routes. |
| @@ -1045,15 +1046,29 @@ func hopVerified(ctx context.Context) bool { |
| return v |
| } |
| +// hopDoorKey marks a verified hop whose sender vouched, with |
| +// X-Carlos-Hop-Door, that the request came through its door. It never stands |
| +// alone: acceptHop sets it only beside hopVerifiedKey. It carries no caller, |
| +// so it exempts the request from the rate limit (ratelimit.go) and grants |
| +// nothing the door's own checks decide. |
| +type hopDoorKey struct{} |
| + |
| +func hopDoorFrom(ctx context.Context) bool { |
| + v, _ := ctx.Value(hopDoorKey{}).(bool) |
| + return v |
| +} |
| + |
| // acceptHop is the whole of hop handling on the request path, and it runs |
| // before the route lookup so that BOTH of its outcomes are decided ahead of |
| // anything that serves: |
| // |
| -// no hop header → nothing happens at all (the overwhelming case: one |
| -// Header.Get on a request that is not a hop). |
| +// no hop header → nothing happens at all (the overwhelming case: a |
| +// Header.Get per hop header on a request that is not |
| +// a hop). |
| // verified hop → the mark goes on the context, the inbound |
| // X-Forwarded-For is believed, and the caller serves |
| -// the route locally or refuses (staleHopRefusal). |
| +// the route locally or refuses (staleHopRefusal). A |
| +// door mark that verifies adds hopDoorKey. |
| // unverified hop → every forwarded header is stripped and the sender is |
| // an ordinary client from here on. |
| // |
| @@ -1075,28 +1090,40 @@ func (e *Edge) acceptHop(r *http.Request, host string) (*http.Request, bool) { |
| // for exactly this reason (purge_header.go): an app that reflects what |
| // it was sent turns a client-supplied edge header into an |
| // app-DECLARED one, and no tenant's reflection bug should be able to |
| - // put hop vocabulary back on the wire. The Get stays the fast path's |
| - // single lookup — the Del only happens on a request that actually |
| - // carries the stray header, which is never, in the ordinary case. |
| + // put hop vocabulary back on the wire. The Gets stay the fast path's |
| + // only lookups — a Del only happens on a request that actually |
| + // carries a stray header, which is never, in the ordinary case. |
| if r.Header.Get(hopAuthHeader) != "" { |
| r.Header.Del(hopAuthHeader) |
| } |
| + if r.Header.Get(hopDoorHeader) != "" { |
| + r.Header.Del(hopDoorHeader) |
| + } |
| return r, false |
| } |
| auth := r.Header.Get(hopAuthHeader) |
| + door := r.Header.Get(hopDoorHeader) |
| r.Header.Del(hopHeader) |
| r.Header.Del(hopAuthHeader) |
| + r.Header.Del(hopDoorHeader) |
| // verifyHopEitherKey, never verifyHop: the single-key form cannot accept a |
| // signer that has already moved to .next, and a rotation is precisely when |
| // refusing hops would hurt. Keys come from the mirror's cache — a hop must |
| // never cost a bucket read. |
| current, next := e.homes.Load().meshKeys() |
| - if auth == "" || !verifyHopEitherKey(current, next, host, auth, time.Now()) { |
| + now := time.Now() |
| + if auth == "" || !verifyHopEitherKey(current, next, host, auth, now) { |
| stripForwardedHeaders(r) |
| return r, false |
| } |
| - return r.WithContext(context.WithValue(r.Context(), hopVerifiedKey{}, true)), true |
| + ctx := context.WithValue(r.Context(), hopVerifiedKey{}, true) |
| + // The door mark is read only here, after the hop verified: on any other |
| + // request it is a header a client made up. |
| + if door != "" && verifyHopDoorEitherKey(current, next, host, door, now) { |
| + ctx = context.WithValue(ctx, hopDoorKey{}, true) |
| + } |
| + return r.WithContext(ctx), true |
| } |
| // stripForwardedHeaders discards every claim a request makes about who sent |
diff --git a/internal/edge/hopdoor_test.go b/internal/edge/hopdoor_test.go| new file mode 100644 |
| index 000000000..6772455db |
| --- /dev/null |
| +++ b/internal/edge/hopdoor_test.go |
| @@ -0,0 +1,212 @@ |
| +package edge |
| + |
| +// The door mark across a hop (#358): a door request backhauled to the |
| +// instance's home box arrives there as a hop from the sending box, and the |
| +// rate limiter must still see a door caller. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/doorkey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The door MAC and the hop MAC are over different bytes under the same key, |
| +// so neither value verifies as the other. |
| +func TestHopDoorDomainSeparated(t *testing.T) { |
| + key := bytes.Repeat([]byte{1}, 32) |
| + next := bytes.Repeat([]byte{2}, 32) |
| + const host = "shop.example.com" |
| + now := time.Now() |
| + |
| + if !verifyHopDoorEitherKey(key, nil, host, signHopDoor(key, host, now), now) { |
| + t.Fatal("a door mark must verify under the key that signed it") |
| + } |
| + if !verifyHopDoorEitherKey(key, next, host, signHopDoor(next, host, now), now) { |
| + t.Fatal("a door mark signed under .next must verify while both keys are mirrored") |
| + } |
| + if !verifyHopDoorEitherKey(key, next, "SHOP.example.com.", signHopDoor(key, host, now), now) { |
| + t.Fatal("the door mark must canonicalize the host as the hop does") |
| + } |
| + if verifyHopDoorEitherKey(key, nil, "other.example.com", signHopDoor(key, host, now), now) { |
| + t.Fatal("a door mark minted for one host verified for another") |
| + } |
| + if verifyHopDoorEitherKey(key, nil, host, signHopDoor(key, host, now.Add(-2*hopSkew)), now) { |
| + t.Fatal("a door mark outside the skew window verified") |
| + } |
| + if verifyHopDoorEitherKey(key, nil, host, signHopDoor(bytes.Repeat([]byte{3}, 32), host, now), now) { |
| + t.Fatal("a door mark under an unmirrored key verified") |
| + } |
| + |
| + if verifyHopDoorEitherKey(key, next, host, signHop(key, host, now), now) { |
| + t.Fatal("a hop signature verified as a door mark") |
| + } |
| + if verifyHopEitherKey(key, next, host, signHopDoor(key, host, now), now) { |
| + t.Fatal("a door mark verified as a hop signature") |
| + } |
| +} |
| + |
| +// The door mark goes on the context only beside a verified hop, and the |
| +// header is deleted whatever happens to the request. |
| +func TestAcceptHopSetsDoorMark(t *testing.T) { |
| + const host = "app.test" |
| + e, _, key := hopEdge(t, "annie", host) |
| + now := time.Now() |
| + door := signHopDoor(key, host, now) |
| + |
| + cases := []struct { |
| + name string |
| + hdr map[string]string |
| + wantVerified bool |
| + wantDoor bool |
| + }{ |
| + {"verified hop with a door mark", |
| + map[string]string{hopHeader: "1", hopAuthHeader: signHop(key, host, now), hopDoorHeader: door}, true, true}, |
| + {"verified hop without a door mark", |
| + map[string]string{hopHeader: "1", hopAuthHeader: signHop(key, host, now)}, true, false}, |
| + {"verified hop with a door mark for another host", |
| + map[string]string{hopHeader: "1", hopAuthHeader: signHop(key, host, now), hopDoorHeader: signHopDoor(key, "other.test", now)}, true, false}, |
| + {"verified hop with the hop signature as its door mark", |
| + map[string]string{hopHeader: "1", hopAuthHeader: signHop(key, host, now), hopDoorHeader: signHop(key, host, now)}, true, false}, |
| + {"verified hop with a garbage door mark", |
| + map[string]string{hopHeader: "1", hopAuthHeader: signHop(key, host, now), hopDoorHeader: "0:deadbeef"}, true, false}, |
| + {"unverified hop with a valid door mark", |
| + map[string]string{hopHeader: "1", hopAuthHeader: "0:deadbeef", hopDoorHeader: door}, false, false}, |
| + {"hop marker without auth, with a valid door mark", |
| + map[string]string{hopHeader: "1", hopDoorHeader: door}, false, false}, |
| + {"door mark as the hop auth", |
| + map[string]string{hopHeader: "1", hopAuthHeader: door, hopDoorHeader: door}, false, false}, |
| + {"no hop marker, valid door mark", |
| + map[string]string{hopDoorHeader: door}, false, false}, |
| + {"stray auth and door mark, no hop marker", |
| + map[string]string{hopAuthHeader: signHop(key, host, now), hopDoorHeader: door}, false, false}, |
| + } |
| + for _, c := range cases { |
| + r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil) |
| + for k, v := range c.hdr { |
| + r.Header.Set(k, v) |
| + } |
| + r, verified := e.acceptHop(r, host) |
| + if verified != c.wantVerified { |
| + t.Errorf("%s: verified = %v, want %v", c.name, verified, c.wantVerified) |
| + } |
| + if got := hopDoorFrom(r.Context()); got != c.wantDoor { |
| + t.Errorf("%s: hopDoorFrom = %v, want %v", c.name, got, c.wantDoor) |
| + } |
| + for _, h := range []string{hopHeader, hopAuthHeader, hopDoorHeader} { |
| + if v := r.Header.Values(h); len(v) != 0 { |
| + t.Errorf("%s: %s survived acceptHop as %q", c.name, h, v) |
| + } |
| + } |
| + } |
| + if hopDoorFrom(context.Background()) { |
| + t.Fatal("an unmarked context reads as a door hop") |
| + } |
| +} |
| + |
| +// End to end over two edges: a door caller on betty reaches an instance homed |
| +// on annie whose bucket for betty's address is empty, and is served. A public |
| +// client at that same address, over the same hop, is refused. |
| +func TestBackhauledDoorCallExempt(t *testing.T) { |
| + e2eQuietLogs(t) |
| + t.Setenv("CARLOS_RATE_LIMIT", "enforce") |
| + bucket := t.TempDir() |
| + seed := release.NewDirStore(bucket) |
| + ca := newE2ECA(t) |
| + if err := MintMeshKey(context.Background(), seed); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, host := range []string{e2eHomedHost, e2eSenderHost} { |
| + ca.publish(t, seed, host) |
| + } |
| + appSrv, app := newDoorUpstream(t, e2eHomeMarker) |
| + senderSrv, _ := newDoorUpstream(t, e2eSenderMarker) |
| + a := newBackhaulEdge(t, bucket, "annie", ca, nil, nil) |
| + if err := a.edge.reg.Upsert(registry.Route{Host: e2eHomedHost, Kind: "instance", Addr: appSrv.Listener.Addr().String(), |
| + Status: "ready", Account: "acme", App: "shop", RateLimit: `{"rate":1,"burst":1}`}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.edge.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + b := newBackhaulEdge(t, bucket, "betty", ca, nil, map[string]string{e2eSenderHost: senderSrv.Listener.Addr().String()}) |
| + |
| + aEIP, port, err := net.SplitHostPort(a.tlsAddr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bEIP, _, _ := net.SplitHostPort(b.tlsAddr) |
| + oldPort, oldCAs := backhaulPort, backhaulRootCAs |
| + backhaulPort, backhaulRootCAs = port, ca.pool |
| + t.Cleanup(func() { backhaulPort, backhaulRootCAs = oldPort, oldCAs }) |
| + putJSON(t, seed, "control/edges/annie.json", fmt.Sprintf( |
| + `{"v":1,"box":"annie","eip":%q,"hosts":{%q:{"role":"home","account":"acme","app":"shop"}}}`, aEIP, e2eHomedHost)) |
| + putJSON(t, seed, "control/edges/betty.json", fmt.Sprintf( |
| + `{"v":1,"box":"betty","eip":%q,"hosts":{%q:{"role":"home","account":"acme","app":"other"}}}`, bEIP, e2eSenderHost)) |
| + for _, be := range []*backhaulEdge{a, b} { |
| + if err := be.mirror.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + refreshHomeMap(t, be.homes) |
| + } |
| + |
| + fx := newDoorKeyFixture(t) |
| + raw, entry := fx.mint(t, "acme", "shop") |
| + putJSON(t, seed, doorkey.RecordKey("acme", "shop"), string(fx.record(t, "acme", "shop", 1, entry))) |
| + b.edge.doorKeys = fx.mirror(b.store) |
| + refreshDoorKeys(t, b.edge.doorKeys) |
| + b.edge.doorAddr = doorTestAddr |
| + // A public client at betty's own address, which is what the door |
| + // presents: both reach annie as the same client. |
| + b.peerAs.Store(doorTestAddr) |
| + |
| + public := func() *http.Response { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, "https://"+e2eHomedHost+"/", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp, err := b.client.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + return resp |
| + } |
| + if resp := public(); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("first public request = %d, want 200", resp.StatusCode) |
| + } |
| + if resp := public(); resp.StatusCode != http.StatusTooManyRequests { |
| + t.Fatalf("second public request = %d, want 429 (the bucket is empty)", resp.StatusCode) |
| + } |
| + |
| + for i := range 3 { |
| + req := httptest.NewRequest(http.MethodGet, "http://"+e2eHomedHost+"/", nil) |
| + req.Host = e2eHomedHost |
| + req.RemoteAddr = "@" |
| + req.Header.Set(doorkey.Header, raw) |
| + rec := httptest.NewRecorder() |
| + b.edge.serveDoor(rec, req) |
| + if rec.Code != http.StatusOK || rec.Body.String() != e2eHomeMarker { |
| + t.Fatalf("door request %d over the hop = %d %q, want 200 from the home edge's app", i, rec.Code, rec.Body.String()) |
| + } |
| + _, hdr, _ := app.seen() |
| + for _, h := range []string{hopHeader, hopAuthHeader, hopDoorHeader} { |
| + if v := hdr.Values(h); len(v) != 0 { |
| + t.Fatalf("door request %d: %s = %q reached the app", i, h, v) |
| + } |
| + } |
| + } |
| + |
| + if resp := public(); resp.StatusCode != http.StatusTooManyRequests { |
| + t.Fatalf("public request after the door calls = %d, want 429", resp.StatusCode) |
| + } |
| +} |
diff --git a/internal/edge/mesh.go b/internal/edge/mesh.go| index 14d13b48b..54d21b992 100644 |
| --- a/internal/edge/mesh.go |
| +++ b/internal/edge/mesh.go |
| @@ -187,7 +187,7 @@ func signingKey(current, next []byte) []byte { |
| // is not a request host under any canonicalization. |
| func signHop(key []byte, host string, now time.Time) string { |
| ts := strconv.FormatInt(now.Unix(), 10) |
| - return ts + ":" + hex.EncodeToString(hopMAC(key, host, ts)) |
| + return ts + ":" + hex.EncodeToString(hopMAC(key, "", host, ts)) |
| } |
| // verifyHop checks an X-Carlos-Hop-Auth header against a single key: the |
| @@ -197,6 +197,12 @@ func signHop(key []byte, host string, now time.Time) string { |
| // so no byte-wise or string-equality shortcut is taken anywhere in this |
| // path. |
| func verifyHop(key []byte, host, header string, now time.Time) bool { |
| + return verifyHopMAC(key, "", host, header, now) |
| +} |
| + |
| +// verifyHopMAC is verifyHop for either MAC domain: "" for the hop itself, |
| +// hopDoorDomain for the door mark. |
| +func verifyHopMAC(key []byte, domain, host, header string, now time.Time) bool { |
| ts, sig, ok := strings.Cut(header, ":") |
| if !ok || ts == "" || sig == "" { |
| return false |
| @@ -216,7 +222,7 @@ func verifyHop(key []byte, host, header string, now time.Time) bool { |
| if err != nil { |
| return false |
| } |
| - return hmac.Equal(got, hopMAC(key, host, ts)) |
| + return hmac.Equal(got, hopMAC(key, domain, host, ts)) |
| } |
| // verifyHopEitherKey is the dual-accept read side of a rotation: it accepts |
| @@ -235,11 +241,38 @@ func verifyHopEitherKey(current, next []byte, host, header string, now time.Time |
| return false |
| } |
| +// hopDoorDomain separates the door mark's MAC from the hop's. A request host |
| +// can never contain a NUL (net/http refuses one), so no host the hop MAC |
| +// covers can begin with this prefix, and neither value verifies as the other |
| +// under the shared key. |
| +const hopDoorDomain = "door\x00" |
| + |
| +// signHopDoor produces the value of the X-Carlos-Hop-Door header, which a |
| +// sending edge adds to a hop when the request came through its door |
| +// (door.go). Same key, host and timestamp format as signHop, in its own MAC |
| +// domain. It says only that the request is a door call: the caller's account |
| +// and app stay on the sending box. |
| +func signHopDoor(key []byte, host string, now time.Time) string { |
| + ts := strconv.FormatInt(now.Unix(), 10) |
| + return ts + ":" + hex.EncodeToString(hopMAC(key, hopDoorDomain, host, ts)) |
| +} |
| + |
| +// verifyHopDoorEitherKey is verifyHopEitherKey for the door mark. acceptHop |
| +// consults it only once the hop itself has verified. |
| +func verifyHopDoorEitherKey(current, next []byte, host, header string, now time.Time) bool { |
| + if len(next) > 0 && verifyHopMAC(next, hopDoorDomain, host, header, now) { |
| + return true |
| + } |
| + return len(current) > 0 && verifyHopMAC(current, hopDoorDomain, host, header, now) |
| +} |
| + |
| // hopMAC is the MAC signHop and verifyHop both compute — one place so the |
| // two can never drift out of sync on what bytes are actually signed, and |
| // the one choke point that canonicalizes host before it enters the MAC. |
| -func hopMAC(key []byte, host, ts string) []byte { |
| +// domain comes first: "" for the hop, hopDoorDomain for the door mark. |
| +func hopMAC(key []byte, domain, host, ts string) []byte { |
| mac := hmac.New(sha256.New, key) |
| + mac.Write([]byte(domain)) |
| mac.Write([]byte(canonicalizeHopHost(host))) |
| mac.Write([]byte("\n")) |
| mac.Write([]byte(ts)) |
diff --git a/internal/edge/ratelimit.go b/internal/edge/ratelimit.go| index e57fab738..72551febe 100644 |
| --- a/internal/edge/ratelimit.go |
| +++ b/internal/edge/ratelimit.go |
| @@ -115,16 +115,17 @@ func (e *Edge) parseRateSettings(rt registry.Route) ratelimit.Settings { |
| // and when it is not, the caller must call it once the request is done; it |
| // gives the slot back if the slot writer has not already. |
| // |
| -// Not judged: anything but an instance, a door caller (the box itself), and |
| -// a request that arrived over a fleet tunnel. On the fleet side of a tunnel |
| -// every request comes from the hub, so keying on it would put every client |
| -// in one bucket; the hub has the route row and the real client, and limits |
| -// there (its route is viaTunnel). |
| +// Not judged: anything but an instance, a door caller (the box itself, on |
| +// this box or on the box that backhauled it, hopDoorFrom), and a request |
| +// that arrived over a fleet tunnel. On the fleet side of a tunnel every |
| +// request comes from the hub, so keying on it would put every client in one |
| +// bucket; the hub has the route row and the real client, and limits there |
| +// (its route is viaTunnel). |
| func (e *Edge) rateAdmit(w http.ResponseWriter, r *http.Request, up upstream, holdSlot bool) (release func(), ok bool) { |
| if e.rateMode == RateOff || up.route.Kind != "instance" { |
| return nil, true |
| } |
| - if _, door := doorCallerFrom(r.Context()); door || fromTunnel(r.Context()) { |
| + if _, door := doorCallerFrom(r.Context()); door || hopDoorFrom(r.Context()) || fromTunnel(r.Context()) { |
| return nil, true |
| } |
| client, ok := ratelimit.ClientKey(clientIP(r)) |