From ca1fd6030f7f233c807b4ce7c43f4c6e4c9c3c02 Mon Sep 17 00:00:00 2001 From: Dhi Aurrahman Date: Thu, 8 Oct 2026 15:47:28 +0700 Subject: [PATCH 1/5] up: hold request bodies and trailers during admission callouts --- e2e/async_callout_test.go | 10 ++++++++++ up/filter.go | 15 +++++++++++++++ up/grpc_test.go | 40 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/e2e/async_callout_test.go b/e2e/async_callout_test.go index bf9597c..a8b473b 100644 --- a/e2e/async_callout_test.go +++ b/e2e/async_callout_test.go @@ -29,6 +29,16 @@ func (s *AsyncCalloutSuite) TestGet_calloutBodyReturnedAsLocalResponse() { s.Require().Equal("checked", body) } +func (s *AsyncCalloutSuite) TestPost_headerCalloutStillBlocksForwarding() { + req, err := http.NewRequest(http.MethodPost, asyncCalloutAddr+"/checked", strings.NewReader("must remain paused")) + s.Require().NoError(err) + resp := mustDo(s.T(), req) + body := readBody(s.T(), resp) + s.Require().Equal(http.StatusOK, resp.StatusCode) + s.Require().Equal("ok", resp.Header.Get("x-async-callout")) + s.Require().Equal("checked", body) +} + func (s *AsyncCalloutSuite) TestGet_calloutMutatesAndForwards() { // The filter issues a callout; the callback sets x-callout-result and does // NOT send a local response. The request is forwarded to the echo upstream diff --git a/up/filter.go b/up/filter.go index d6dc36d..cbfe954 100644 --- a/up/filter.go +++ b/up/filter.go @@ -471,6 +471,12 @@ func (f *filter) OnRequestHeaders(headers shared.HeaderMap, endOfStream bool) sh // non-empty, and falls back to body otherwise. func (f *filter) OnRequestBody(body shared.BodyBuffer, endOfStream bool) shared.BodyStatus { if f.requestBodyHandler == nil { + // StopIteration on headers still permits body callbacks. Returning + // Continue here would release headers to the router before admission + // completes, even if the callout later sends a local denial. + if f.calloutState.Load() == calloutStatePaused || f.stopped { + return shared.BodyStatusStopAndBuffer + } return shared.BodyStatusContinue } if f.bufferBody && !endOfStream { @@ -538,6 +544,15 @@ func (f *filter) OnRequestBody(body shared.BodyBuffer, endOfStream bool) shared. return shared.BodyStatusContinue } +// OnRequestTrailers must not release a request whose admission callout is still +// pending. Envoy can deliver trailers after stopped headers/body callbacks. +func (f *filter) OnRequestTrailers(_ shared.HeaderMap) shared.TrailersStatus { + if f.calloutState.Load() == calloutStatePaused || f.stopped { + return shared.TrailersStatusStop + } + return shared.TrailersStatusContinue +} + func requestBodyReplacementBuffer(handle shared.HttpFilterHandle, current shared.BodyBuffer) shared.BodyBuffer { if buf := handle.BufferedRequestBody(); buf != nil && buf.GetSize() > 0 { return buf diff --git a/up/grpc_test.go b/up/grpc_test.go index 0802540..f081186 100644 --- a/up/grpc_test.go +++ b/up/grpc_test.go @@ -2,9 +2,11 @@ package up import ( "encoding/binary" + "strconv" "testing" "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared" + "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared/fake" "github.com/stretchr/testify/require" "github.com/dio/transit/up/testutil" @@ -168,3 +170,41 @@ func TestWriterGRPCCallout_wrapsHTTPStream(t *testing.T) { }, gotHeaders) require.Equal(t, []byte("request"), decodeGRPCBody([]shared.UnsafeEnvoyBuffer{{Ptr: &gotBody[0], Len: uint64(len(gotBody))}})) } + +func TestHeaderGRPCCalloutHoldsBodyUntilDecision(t *testing.T) { + for _, deny := range []bool{false, true} { + t.Run(strconv.FormatBool(deny), func(t *testing.T) { + var callback shared.HttpStreamCallback + h := testutil.NewFilterHandle(testutil.WithHTTPStreamFunc(func(_ string, _ [][2]string, _ []byte, _ bool, _ uint64, cb shared.HttpStreamCallback) (shared.HttpCalloutInitResult, uint64) { + callback = cb + return shared.HttpCalloutInitSuccess, 1 + })) + f := &filter{handle: h, handler: func(w *Writer, _ *Request) { + _, err := w.GRPCCallout(GRPCCalloutRequest{Cluster: "rls", Method: "/rls/Check"}, func(GRPCCalloutResponse) { + if deny { + w.SendLocalResponse(429, []byte("denied")) + } + }) + require.NoError(t, err) + }} + require.Equal(t, shared.HeadersStatusStop, f.OnRequestHeaders(h.RequestHeaders(), false)) + require.Equal(t, shared.BodyStatusStopAndBuffer, f.OnRequestBody(fake.NewFakeBodyBuffer([]byte("part")), false)) + require.Equal(t, shared.BodyStatusStopAndBuffer, f.OnRequestBody(fake.NewFakeBodyBuffer([]byte("rest")), true)) + require.Zero(t, h.ContinuedReq) + require.Equal(t, shared.TrailersStatusStop, f.OnRequestTrailers(nil)) + callback.OnHttpStreamComplete(1) + if deny { + require.Zero(t, h.ContinuedReq) + require.Len(t, h.LocalResponses, 1) + require.EqualValues(t, 429, h.LocalResponses[0].Status) + require.Equal(t, shared.TrailersStatusStop, f.OnRequestTrailers(nil)) + require.Equal(t, shared.BodyStatusStopAndBuffer, f.OnRequestBody(fake.NewFakeBodyBuffer(nil), true)) + } else { + require.Equal(t, 1, h.ContinuedReq) + require.Empty(t, h.LocalResponses) + require.Equal(t, shared.TrailersStatusContinue, f.OnRequestTrailers(nil)) + require.Equal(t, shared.BodyStatusContinue, f.OnRequestBody(fake.NewFakeBodyBuffer(nil), true)) + } + }) + } +} From 80a00eb2b1c07a1bf685334b138faa2857dfa11f Mon Sep 17 00:00:00 2001 From: Dhi Aurrahman Date: Thu, 8 Oct 2026 15:47:29 +0700 Subject: [PATCH 2/5] examples: add reusable RLS admission module and native comparison --- .github/workflows/ci.yml | 1 + examples/ratelimit/FRASER.md | 149 +++++++++++++ examples/ratelimit/Makefile | 40 ++++ examples/ratelimit/README.md | 172 +++++++++++++++ examples/ratelimit/cmd/main.go | 26 +++ examples/ratelimit/config.go | 94 ++++++++ examples/ratelimit/config.json | 10 + examples/ratelimit/config_test.go | 112 ++++++++++ examples/ratelimit/e2e/e2e_test.go | 206 ++++++++++++++++++ .../ratelimit/e2e/testdata/envoy.tmpl.yaml | 64 ++++++ examples/ratelimit/envoy-native.yaml | 75 +++++++ examples/ratelimit/envoy.yaml | 64 ++++++ examples/ratelimit/ratelimit.go | 131 +++++++++++ examples/ratelimit/ratelimit_test.go | 136 ++++++++++++ examples/ratelimit/rls-config.yaml | 9 + 15 files changed, 1289 insertions(+) create mode 100644 examples/ratelimit/FRASER.md create mode 100644 examples/ratelimit/Makefile create mode 100644 examples/ratelimit/README.md create mode 100644 examples/ratelimit/cmd/main.go create mode 100644 examples/ratelimit/config.go create mode 100644 examples/ratelimit/config.json create mode 100644 examples/ratelimit/config_test.go create mode 100644 examples/ratelimit/e2e/e2e_test.go create mode 100644 examples/ratelimit/e2e/testdata/envoy.tmpl.yaml create mode 100644 examples/ratelimit/envoy-native.yaml create mode 100644 examples/ratelimit/envoy.yaml create mode 100644 examples/ratelimit/ratelimit.go create mode 100644 examples/ratelimit/ratelimit_test.go create mode 100644 examples/ratelimit/rls-config.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e3bf25..adef283 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,7 @@ jobs: - run: make -C examples/mcp-profile-gateway e2e - run: make -C examples/mcp-profile-router e2e - run: make -C examples/observability e2e + - run: make -C examples/ratelimit e2e - run: make -C examples/request-ui e2e - run: make -C examples/sse-tap e2e - run: make -C examples/spa e2e diff --git a/examples/ratelimit/FRASER.md b/examples/ratelimit/FRASER.md new file mode 100644 index 0000000..a92c475 --- /dev/null +++ b/examples/ratelimit/FRASER.md @@ -0,0 +1,149 @@ +# Fraser adoption decision + +Use a separate admission filter after TARS identity resolution and before +`aidiscovery`. Keep the native RLS path available. Prefer the module when Fraser +needs independently delivered descriptor policy or application-specific +admission decisions; a custom quota-rule source alone does not require it. + +```mermaid +flowchart LR + C[Client] --> T[TARS authentication and identity] + T --> L[Rate-limit admission] + L -->|allowed| G[aidiscovery guardrails] + G --> P[Routing and provider execution] + L -->|gRPC through Envoy cluster| R[dio/rls] + R --> D[Redis] + Q[Quota configuration source] --> R + S[Client-policy configuration source] --> L +``` + +`L` is either the native Envoy filter or this dynamic module. A local caller +quota denial returns 429 before guardrail work. A provider-attempt quota check +would instead belong inside routing execution and may advance a chain. These +are different contracts; this example implements only the first. + +## Evidence and branch boundaries + +Inspected Fraser `origin/adopt-orange` at `c6f1edc98` and the separate current +`tars-dataplane` implementation on main at `a32c4d172`: + +- Main `tars-dataplane/filter.go`, `OnRequestHeaders`: authentication and + project/gateway authorization precede stamping `x-tars-id`, `x-tars-user`, + `x-tars-customer`, `x-tars-workspace`, and project/gateway selectors. Client + guardrail selectors are removed before trusted values are installed. +- Main `svc/internal/liaison/handlers/routetypeguardrails/handler.go` inserts + the `aidiscovery` filter through an EnvoyPatchPolicy. TARS and guardrails + currently share `libcomposer.so`, with distinct filter names. +- `adopt-orange` `svc/test/e2e/mod/tars/local/tars_local_test.go`, + `injectLocalRLS`: tests inject the native Envoy RLS filter, using `orange` + domain and `[key_id=, dim=rpm]`. This is test wiring, not a Plum + implementation or proof of production activation. +- Both inspected Fraser branches have + `svc/internal/liaison/resources/eg/backendtrafficpolicy.go`, + `buildRateLimitSpec`: selectors use `x-tars-id`; token limits charge response + metadata such as `io.envoy.ai_gateway.llm_input_token`. + +## Why choose the module for TARS + +The strongest reason is **code-owned descriptor assembly**, not simply access +to metadata. Main TARS already emits `tars.api_key_id`, `user_id`, `customer_id`, +`workspace_id`, `project_id` (when resolved), and `router_id`. A Fraser adapter +can consume these in `Config.BuildDescriptors`, select the applicable scopes +and validate prerequisites. The generic module still owns the RLS callout and +admission decision. Share Envoy metadata/string filter state across separately +loaded modules, not pointers into a package-local Go object registry. + +Native Envoy also supports metadata descriptor actions. For a fixed mapping, +this is simple and does not need a new xDS server or per-tenant xDS updates: + +```yaml +rate_limits: + - actions: + - metadata: + descriptor_key: key_id + metadata_key: + key: tars + path: [{key: router_id}] + source: DYNAMIC + - generic_key: + descriptor_key: dim + descriptor_value: rpm +``` + +Configure the native filter and these route actions once. Metadata values vary +per request. RLS quota values reload through the service's loader independently. +Changing the mapping/filter/cluster live needs Envoy configuration delivery +(typically Envoy Gateway/Liaison's existing xDS path); static bootstrap works +without xDS but requires reloading/restarting Envoy for those changes. Native +missing-metadata behavior can skip a descriptor, so enforce required identity +upstream. The module explicitly rejects missing descriptor input. + +Recommendation: start with native for stable mappings. Choose the module when +Fraser-owned conditional logic, custom validation, or a separately refreshed +descriptor-policy source earns the additional code and deployment surface. + +| Concern | Benefit of an independent module | Qualification | +|---|---|---| +| Identity-to-descriptor mapping | Consume TARS-authenticated headers or string filter state through an explicit interface; no duplicate authentication | Native request-header actions can already consume trusted headers | +| Descriptor-policy updates | Publish one validated immutable snapshot without regenerating every route policy | RLS quota rules already reload independently with either client | +| Reuse across dataplanes | The same small admission component can sit behind current TARS or a Plum-based pipeline | Each host still needs to supply the agreed trusted identity fields | +| Failure and decision behavior | Version/test missing-identity, unavailable-service and future decision behavior as a small component | We now own correctness, latency, observability and compatibility work that native Envoy provides | +| Release ownership | Separate source/package and potentially artifact release from TARS and guardrails | Envoy ABI and shared-library coexistence must be proven against the target image | + +The practical simplification is: + +```text +current route-coupled policy: + Valet/config → Worker/CRDs → Liaison BackendTrafficPolicies → Envoy config + +proposed module client policy: + source adapter → validate → immutable snapshot → request descriptors + +service quota policy, common to both clients: + dio/rls provider.Loader → validated rule snapshot → Redis-backed enforcement +``` + +That does not remove Envoy configuration: filter insertion, the RLS cluster, +HTTP/2, TLS and upstream authentication remain Envoy-owned. It also does not +make module and RLS policy publication atomic. Agree descriptor schema/version +and roll out compatible RLS rules before enabling new descriptor shapes; +unmatched descriptors may be treated as unlimited by the service. + +## Ordering and preserved invariants + +1. Authenticate and authorize first; never use caller-supplied tenancy as quota + identity. Stamp or expose trusted descriptors before this filter runs. +2. Check caller admission before `aidiscovery` to avoid paying for guardrail + calls on denied requests. Both native and module paths can do this. +3. For model-specific policy, confirm the model has been resolved before the + check. A JSON-body model is not inherently available in the headers phase; + this example intentionally does not add an AI-body parser. +4. Count one admission check per incoming request. Do not retry incrementing + RLS calls automatically or charge the same request through both native and + module filters. +5. Guardrail rejection still consumes an admitted request unit. Token/cost + accounting requires a separate response/usage contract. +6. Keep quota checks on an Envoy-managed cluster, with a bounded deadline and + explicit open/closed service-error policy. + +## What must precede a Fraser rollout + +- Preserve existing token-budget semantics. This module sends `hits_addend=1` + and has no response token accounting, so it is not a replacement for the + existing token-cost BackendTrafficPolicies. +- Add explicit filter-order tests and verify generated Envoy configuration: + TARS identity → admission → aidiscovery → provider routing. Existing patch + priorities/index-zero insertions must not accidentally invert that order. +- Build/load alongside Composer with the actual Envoy and SDK pins. This is an + Envoy `c-shared` dynamic module, not a Go `plugin` for Composer's plugin loader. + Do not pass `libratelimit.so` to a loader expecting Go factory exports. +- Verify bypass paths deliberately: health/model-discovery endpoints, MCP, + WebSocket upgrades and any alternate listeners. Do not require nonexistent + identity indiscriminately, or introduce a broad skip that bypasses admission. +- Test 429/error response formatting and TARS identity/correlation headers. + This example returns plain text and does not yet propagate RLS headers. +- Qualify `dio/rls` separately: its current local source compiles but has no + tests; Redis-error handling and provider refresh behavior need their own + correctness/availability review. Protocol compatibility is not readiness. + +No Fraser integration or production deployment is part of this example. diff --git a/examples/ratelimit/Makefile b/examples/ratelimit/Makefile new file mode 100644 index 0000000..2bc0180 --- /dev/null +++ b/examples/ratelimit/Makefile @@ -0,0 +1,40 @@ +## examples/ratelimit/Makefile - build and test targets for the ratelimit example. +## +## Run from this directory: make +## Or from the project root: make -C examples/ratelimit + +EXAMPLE := ratelimit +OUTPUT := libratelimit.so + +ROOT := $(abspath $(CURDIR)/../..) +ENVOY_BIN ?= $(ROOT)/.bin/envoy + +include $(ROOT)/scripts/zig.mk + +.PHONY: build test e2e run clean download-envoy + +build: $(ZIG_BIN) + cd $(ROOT)/examples && $(ZIG_BUILD_ENV) GOWORK=off go build -trimpath -buildmode=c-shared \ + -o $(EXAMPLE)/$(OUTPUT) ./$(EXAMPLE)/cmd + @echo "built: $(OUTPUT)" + +test: + cd $(ROOT)/examples && GOWORK=off go test ./$(EXAMPLE) + +e2e: build + cd $(ROOT)/examples && ENVOY_BIN=$(ENVOY_BIN) GOWORK=off go test ./$(EXAMPLE)/e2e/... -v -timeout=60s -count=1 + +run: build $(ENVOY_BIN) + RATELIMIT_CONFIG=$(CURDIR)/config.json \ + GODEBUG=cgocheck=0 \ + ENVOY_DYNAMIC_MODULES_SEARCH_PATH=$(CURDIR) \ + $(ENVOY_BIN) -c $(CURDIR)/envoy.yaml --log-level warning + +clean: + rm -f $(OUTPUT) $(OUTPUT:.so=.h) + +download-envoy: + $(MAKE) -C $(ROOT) download-envoy + +$(ENVOY_BIN): + $(MAKE) -C $(ROOT) download-envoy diff --git a/examples/ratelimit/README.md b/examples/ratelimit/README.md new file mode 100644 index 0000000..a529b86 --- /dev/null +++ b/examples/ratelimit/README.md @@ -0,0 +1,172 @@ +# Rate-limit admission module + +An independently loadable `libratelimit.so` using Envoy's RLS v3 +`ShouldRateLimit` API. Derived from `../grpc-callout`, with configurable +descriptors, immutable policies, explicit failure handling, and a configuration +source boundary. This lives in Transit as an example first; its runtime package +has no dependency on other examples, Plum, Orange, or Fraser. + +See [Fraser adoption analysis](FRASER.md) for placement before aidiscovery, +native-versus-module tradeoffs, and the remaining token-budget integration. + +```text +authenticated request + → ratelimit (capture policy, construct descriptors) + → Envoy HTTP/2 cluster → RLS ShouldRateLimit → Redis counters + ← OK: continue / OVER_LIMIT: 429 / service error: configured open or closed +``` + +## Native or extension + +Both paths speak the same RLS protocol and can use `github.com/dio/rls` or +`envoyproxy/ratelimit` as the backend: + +| Path | Configuration | Use | +|---|---|---| +| Native Envoy | `envoy-native.yaml`; descriptors in route actions | Existing Envoy admission behavior without a custom binary | +| Dynamic module | `envoy.yaml` plus `config.json` | Custom descriptor/configuration delivery inside Envoy | + +The configs use domain `orange` and descriptor entries `key_id`, then `dim=rpm`, +matching Fraser `adopt-orange`'s injected native RLS e2e configuration. This +example does not change Fraser's production filter chain. + +These paths are alternative deployments, not exact behavioral substitutes: +the module rejects missing descriptor values with 400; native Envoy can omit +the descriptor when a required header is absent. Authenticate before either +filter and make the trusted identity available before its headers callback. +Do not accept a caller-selected `x-tars-id` as authenticated identity. The +module also supports `filter_state` for identity set by an earlier filter. + +## Build and run + +```sh +make -C examples/ratelimit test +make -C examples/ratelimit e2e +make -C examples/ratelimit run +``` + +The run config expects an HTTP upstream on `127.0.0.1:10001` and an h2c RLS on +`127.0.0.1:10002`. The listener is `127.0.0.1:10000`. For `dio/rls`, put +`rls-config.yaml` in its `RLS_CONFIG_DIR`, set `RLS_GRPC_ADDR=127.0.0.1:10002`, +and point `RLS_REDIS_ADDR` at Redis. This rule allows 60 requests per minute per +`key_id`. That service also accepts `RLS_CONFIG_URL` and has a `provider.Loader` +extension point for custom quota configuration. + +```sh +curl -i http://127.0.0.1:10000/ -H 'x-tars-id: demo' -d hello +``` + +To use the native path, run Envoy with `-c examples/ratelimit/envoy-native.yaml`; +no module or `RATELIMIT_CONFIG` is needed. + +The module command reads the file named by `RATELIMIT_CONFIG` once during +library initialization. `make run` supplies `config.json`. Missing or invalid +initial config prevents registration and therefore prevents Envoy from loading +the requested filter. There is no implicit file watching or remote fetch. +`filter_config` is empty in this example; client policy comes from that file. + +## Client policy + +See `config.json` for the schema. `cluster` and `domain` are required. +`timeout_ms` defaults to 50, with a maximum of 60000. `failure_mode` is `closed` +(default) or `open`. Each descriptor is an ordered array of entries; each entry +requires a key and exactly one nonempty `value`, lowercase `header`, or +`filter_state` source. Missing dynamic values reject the request with 400 rather +than silently skipping quota enforcement. Every admitted check uses +`hits_addend=1`. + +`OVER_LIMIT` always returns 429. Callout initialization, transport, gRPC status, +decode, and unknown response-code failures return 503 in closed mode and +continue in open mode. Missing configuration always returns 503. Each request +uses one captured policy throughout its asynchronous callout. + +This is request admission: no body parsing or full-body requirement, per-attempt provider selection, +chain advancement, retries of quota checks, token/cost accounting, or response +header propagation from RLS. An admission denial terminates the request. Chain +integration needs a separate decision contract; it must not turn a caller quota +denial into a provider fallback. + +Envoy buffers arriving body data while the asynchronous quota decision is +pending. The Transit header-only callout path must hold both body and trailers +until that decision; this example includes that core fix and regression tests. + +## Custom configuration source later + +Descriptor assembly can be code-owned rather than declarative. An embedding +application supplies `Config.BuildDescriptors` instead of `Config.Descriptors`, +then calls `Compile`. For example, retain the existing RLS `key_id` meaning +(TARS router identity) while reading trusted metadata rather than a header: + +```go +config.BuildDescriptors = func(w *up.Writer, _ *up.Request) ([]*rlscommonv3.RateLimitDescriptor, error) { + id, ok := w.GetMetadataString(up.MetadataSourceDynamic, "tars", "router_id") + if !ok || id.String() == "" { + return nil, fmt.Errorf("trusted router identity unavailable") + } + return []*rlscommonv3.RateLimitDescriptor{{ + Entries: []*rlscommonv3.RateLimitDescriptor_Entry{ + {Key: "key_id", Value: id.String()}, + {Key: "dim", Value: "rpm"}, + }, + }}, nil +} +policy, err := ratelimit.Compile(config) // config.Descriptors must be empty +``` + +`rlscommonv3` is the Envoy `extensions/common/ratelimit/v3` protobuf package. +The callback must be concurrent-safe, nonblocking and return owned descriptors. +The module handles transport and decisions. An error or empty/invalid descriptor +result returns 400, including in fail-open mode; it never silently skips the +check. A custom source decoder can parse policy inputs, attach the builder, and +call `Compile`. Functions are not serialized into JSON. + +There are two separate configuration boundaries: + +1. **RLS quota rules:** `dio/rls`'s `provider.Loader` supplies limits. Both native + and extension clients work with that source unchanged. +2. **Module client policy:** Transit `up.ConfigSource` supplies bytes that + `ratelimit.Decode` validates before publication. The request path only reads + `PipelineConfig.Snapshot()`; it never performs configuration I/O. + +The embedding application can replace the static source without editing +enforcement: + +```go +pc := up.NewPollingConfig(customSource, ratelimit.Decode, up.PollOptions{}) +// Use a bounded context for initial loading. Abort startup on error. +if err := pc.RefreshOnce(initialLoadContext); err != nil { + return err +} +// Register during module initialization. +up.Register("ratelimit", ratelimit.NewHandler(pc)) +// Start from the application's lifecycle owner and call stop before destruction. +stop := pc.Start(applicationContext) +``` + +The application owns source startup/shutdown and serializes refresh writers. +Invalid fetches or decoded updates retain the last good policy. Policies copy +input slices and expose no mutable fields; builder closures must keep their +own captured inputs immutable or synchronized. No shared package-global policy or +poller is installed by this library. Remote module sources must use +Envoy-managed egress; this example adds no direct network client. + +For a separate repository, move the package and `cmd/`, update the command's +import path, and add a `go.mod` pinning Transit, RLS protobufs and protobuf Go. +Adapt the example build/test helpers there; the enforcement API and config +schema need not change. Keep Redis and the RLS server outside the Envoy module. + +## Verification scope + +Unit/race coverage checks validation, descriptor mapping, response/error +decisions, immutable snapshots, last-good retention and concurrent reads during +refresh. Envoy e2e uses a protocol-compatible gRPC test service to check actual +forwarding, denial, deadlines, non-OK gRPC status, unknown RLS codes and missing +clusters in both failure modes. These tests do not establish Redis correctness +or deployment readiness of a separate RLS service. + +A local smoke on 2026-10-08 also exercised both checked-in Envoy configurations +against `dio/rls` and an isolated Redis process: with one request per hour, +each returned 200 then 429 and forwarded exactly one request. Transit root +race tests and root Envoy e2e passed. The locally tested Envoy was +`0d6e3c60aa55` (1.39.0-dev); release-version and Composer coexistence acceptance +remain separate work. diff --git a/examples/ratelimit/cmd/main.go b/examples/ratelimit/cmd/main.go new file mode 100644 index 0000000..52a9b4b --- /dev/null +++ b/examples/ratelimit/cmd/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "os" + + _ "github.com/dio/transit/down/abi_impl" + "github.com/dio/transit/examples/ratelimit" + "github.com/dio/transit/up" +) + +func init() { + data, err := os.ReadFile(os.Getenv("RATELIMIT_CONFIG")) + if err != nil { + log.Printf("ratelimit: read RATELIMIT_CONFIG: %v", err) + return + } + policy, err := ratelimit.Decode(data) + if err != nil { + log.Printf("ratelimit: invalid config: %v", err) + return + } + up.Register("ratelimit", ratelimit.NewHandler(up.NewStaticConfig(policy))) +} + +func main() {} diff --git a/examples/ratelimit/config.go b/examples/ratelimit/config.go new file mode 100644 index 0000000..40f57a7 --- /dev/null +++ b/examples/ratelimit/config.go @@ -0,0 +1,94 @@ +package ratelimit + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" +) + +// Config describes the RLS client. Quota rules and counters belong to the RLS. +type Config struct { + Cluster string `json:"cluster"` + Authority string `json:"authority,omitempty"` + Domain string `json:"domain"` + TimeoutMillis uint64 `json:"timeout_ms,omitempty"` + FailureMode string `json:"failure_mode,omitempty"` + Descriptors [][]Entry `json:"descriptors"` + // BuildDescriptors replaces declarative descriptors for programmatic hosts. + // It must be safe across worker threads and must not block or start I/O. + BuildDescriptors DescriptorBuilder `json:"-"` +} + +// Entry obtains a descriptor value from exactly one source. Headers and filter +// state must already be available when this filter's headers callback runs. +type Entry struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` + Header string `json:"header,omitempty"` + FilterState string `json:"filter_state,omitempty"` +} + +// Policy is an immutable, validated snapshot. Construct it with Compile or Decode. +type Policy struct{ config Config } + +// Compile validates and copies config so callers cannot mutate a live policy. +func Compile(c Config) (*Policy, error) { + if strings.TrimSpace(c.Cluster) == "" || strings.TrimSpace(c.Domain) == "" { + return nil, fmt.Errorf("cluster and domain are required") + } + if c.TimeoutMillis == 0 { + c.TimeoutMillis = 50 + } + if c.TimeoutMillis > 60000 { + return nil, fmt.Errorf("timeout_ms must be at most 60000") + } + if c.FailureMode == "" { + c.FailureMode = "closed" + } + if c.FailureMode != "closed" && c.FailureMode != "open" { + return nil, fmt.Errorf("failure_mode must be closed or open") + } + if (len(c.Descriptors) == 0) == (c.BuildDescriptors == nil) { + return nil, fmt.Errorf("provide either descriptors or BuildDescriptors") + } + descriptors := make([][]Entry, len(c.Descriptors)) + for i, entries := range c.Descriptors { + if len(entries) == 0 { + return nil, fmt.Errorf("descriptor %d is empty", i) + } + descriptors[i] = append([]Entry(nil), entries...) + for j, entry := range entries { + sources := 0 + for _, value := range []string{entry.Value, entry.Header, entry.FilterState} { + if value != "" { + sources++ + } + } + if strings.TrimSpace(entry.Key) == "" || sources != 1 { + return nil, fmt.Errorf("descriptor %d entry %d needs a key and exactly one nonempty value, header or filter_state", i, j) + } + if entry.Header != strings.ToLower(entry.Header) || strings.ContainsAny(entry.Header, " \t\r\n") { + return nil, fmt.Errorf("descriptor %d entry %d header must be lowercase without whitespace", i, j) + } + } + } + c.Descriptors = descriptors + return &Policy{config: c}, nil +} + +// Decode is a validating decoder for up.PipelineConfig. Invalid updates leave +// its last good snapshot in place. The JSON schema is independent of its source. +func Decode(data []byte) (*Policy, error) { + var c Config + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&c); err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("expected one JSON config object") + } + return Compile(c) +} diff --git a/examples/ratelimit/config.json b/examples/ratelimit/config.json new file mode 100644 index 0000000..c19a157 --- /dev/null +++ b/examples/ratelimit/config.json @@ -0,0 +1,10 @@ +{ + "cluster": "rls-service", + "domain": "orange", + "timeout_ms": 50, + "failure_mode": "closed", + "descriptors": [[ + {"key": "key_id", "header": "x-tars-id"}, + {"key": "dim", "value": "rpm"} + ]] +} diff --git a/examples/ratelimit/config_test.go b/examples/ratelimit/config_test.go new file mode 100644 index 0000000..2a6e2e8 --- /dev/null +++ b/examples/ratelimit/config_test.go @@ -0,0 +1,112 @@ +package ratelimit + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/dio/transit/up" + "github.com/stretchr/testify/require" +) + +func validConfig() Config { + return Config{Cluster: "rls", Domain: "orange", Descriptors: [][]Entry{{ + {Key: "key_id", Header: "x-tars-id"}, {Key: "dim", Value: "rpm"}, + }}} +} + +func TestCompileOwnsSnapshot(t *testing.T) { + c := validConfig() + p, err := Compile(c) + require.NoError(t, err) + c.Descriptors[0][0].Key = "changed" + require.Equal(t, "key_id", p.config.Descriptors[0][0].Key) + require.Equal(t, "closed", p.config.FailureMode) + require.EqualValues(t, 50, p.config.TimeoutMillis) +} + +func TestValidation(t *testing.T) { + for name, change := range map[string]func(*Config){ + "no cluster": func(c *Config) { c.Cluster = "" }, + "no domain": func(c *Config) { c.Domain = "" }, + "unbounded timeout": func(c *Config) { c.TimeoutMillis = 60001 }, + "bad failure mode": func(c *Config) { c.FailureMode = "ignore" }, + "no descriptors": func(c *Config) { c.Descriptors = nil }, + "empty descriptor": func(c *Config) { c.Descriptors[0] = nil }, + "no key": func(c *Config) { c.Descriptors[0][0].Key = "" }, + "no value source": func(c *Config) { c.Descriptors[0][0].Header = "" }, + "ambiguous source": func(c *Config) { c.Descriptors[0][0].Value = "fixed" }, + "uppercase header": func(c *Config) { c.Descriptors[0][0].Header = "X-User" }, + } { + t.Run(name, func(t *testing.T) { + c := validConfig() + change(&c) + _, err := Compile(c) + require.Error(t, err) + }) + } + for _, input := range []string{`null`, `{}`, `{"typo":true}`, `{} {}`} { + _, err := Decode([]byte(input)) + require.Error(t, err) + } +} + +func TestCustomSourceKeepsLastGoodPolicy(t *testing.T) { + c := validConfig() + data, err := json.Marshal(c) + require.NoError(t, err) + var sourceErr error + pc := up.NewPollingConfig(func(context.Context) ([]byte, error) { + return data, sourceErr + }, Decode, up.PollOptions{}) + require.Nil(t, pc.Snapshot()) + require.NoError(t, pc.RefreshOnce(context.Background())) + old := pc.Snapshot() + data = []byte(`{"domain":"broken"}`) + require.Error(t, pc.RefreshOnce(context.Background())) + require.Same(t, old, pc.Snapshot()) + sourceErr = errors.New("source unavailable") + require.Error(t, pc.RefreshOnce(context.Background())) + require.Same(t, old, pc.Snapshot()) + sourceErr = nil + c.Domain, c.FailureMode = "new", "open" + data, err = json.Marshal(c) + require.NoError(t, err) + require.NoError(t, pc.RefreshOnce(context.Background())) + require.Equal(t, "new", pc.Snapshot().config.Domain) + require.Equal(t, "orange", old.config.Domain) + require.Equal(t, "closed", old.config.FailureMode) +} + +func TestConcurrentSourceRefreshAndRequests(t *testing.T) { + var raw atomic.Value + c := validConfig() + first, err := json.Marshal(c) + require.NoError(t, err) + c.Domain = "new" + second, err := json.Marshal(c) + require.NoError(t, err) + raw.Store(first) + pc := up.NewPollingConfig(func(context.Context) ([]byte, error) { return raw.Load().([]byte), nil }, Decode, up.PollOptions{}) + require.NoError(t, pc.RefreshOnce(context.Background())) + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + for range 100 { + p := pc.Snapshot() + req, err := p.request(func(string) string { return "alice" }, func(string) (string, bool) { return "", false }) + if err != nil || req.GetDomain() != p.config.Domain { + t.Error("request did not use one complete policy") + } + } + }) + } + for i := range 100 { + raw.Store([][]byte{first, second}[i%2]) + require.NoError(t, pc.RefreshOnce(context.Background())) + } + wg.Wait() +} diff --git a/examples/ratelimit/e2e/e2e_test.go b/examples/ratelimit/e2e/e2e_test.go new file mode 100644 index 0000000..11c08a5 --- /dev/null +++ b/examples/ratelimit/e2e/e2e_test.go @@ -0,0 +1,206 @@ +package e2e + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/dio/transit/examples/internal/e2etest" + "github.com/dio/transit/examples/ratelimit" + rlsv3 "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +//go:embed testdata/envoy.tmpl.yaml +var envoyTemplate string + +func TestAdmission(t *testing.T) { + for _, mode := range []string{"closed", "open"} { + t.Run(mode, func(t *testing.T) { + h := start(t, mode, "rls-service") + for _, tc := range []struct { + key string + want int + }{ + {"alice", 200}, {"deny", 429}, {"", 400}, + {"error", failureStatus(mode)}, {"unknown", failureStatus(mode)}, + {"slow", failureStatus(mode)}, + } { + t.Run(tc.key, func(t *testing.T) { + before := h.upstreamHits.Load() + resp, body := h.request(t, tc.key, "original body") + require.Equal(t, tc.want, resp.StatusCode, body) + if tc.want == http.StatusOK { + require.Equal(t, "original body", body) + require.Equal(t, before+1, h.upstreamHits.Load()) + } else { + require.Equal(t, before, h.upstreamHits.Load(), "rejected request reached upstream") + } + }) + } + resp, _ := h.request(t, "inspect", "") + require.Equal(t, 200, resp.StatusCode) + h.rls.mu.Lock() + req := h.rls.last + h.rls.mu.Unlock() + require.NotNil(t, req) + require.Equal(t, "orange", req.Domain) + require.EqualValues(t, 1, req.HitsAddend) + require.Len(t, req.Descriptors, 1) + require.Len(t, req.Descriptors[0].Entries, 2) + require.Equal(t, "key_id", req.Descriptors[0].Entries[0].Key) + require.Equal(t, "inspect", req.Descriptors[0].Entries[0].Value) + require.Equal(t, "dim", req.Descriptors[0].Entries[1].Key) + require.Equal(t, "rpm", req.Descriptors[0].Entries[1].Value) + t.Run("chunked with trailers", func(t *testing.T) { + before := h.upstreamHits.Load() + req, err := http.NewRequest(http.MethodPost, h.url, strings.NewReader("chunked body")) + require.NoError(t, err) + req.ContentLength = -1 + req.Trailer = http.Header{"X-End": {"done"}} + req.Header.Set("x-tars-id", "slow") + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, failureStatus(mode), resp.StatusCode) + if mode == "closed" { + require.Equal(t, before, h.upstreamHits.Load()) + } else { + require.Equal(t, "chunked body", string(body)) + } + }) + }) + } +} + +func TestMissingCluster(t *testing.T) { + for _, mode := range []string{"closed", "open"} { + t.Run(mode, func(t *testing.T) { + h := start(t, mode, "missing-cluster") + resp, _ := h.request(t, "alice", "payload") + require.Equal(t, failureStatus(mode), resp.StatusCode) + h.rls.mu.Lock() + defer h.rls.mu.Unlock() + require.Nil(t, h.rls.last) + if mode == "closed" { + require.Zero(t, h.upstreamHits.Load()) + } + }) + } +} + +func failureStatus(mode string) int { + if mode == "open" { + return http.StatusOK + } + return http.StatusServiceUnavailable +} + +type harness struct { + url string + upstreamHits atomic.Int64 + rls *recordingRLS +} + +func start(t *testing.T, mode, cluster string) *harness { + t.Helper() + _, file, _, _ := runtime.Caller(0) + examplesRoot := filepath.Join(filepath.Dir(file), "../..") + bin := e2etest.EnvoyBin(examplesRoot) + if _, err := os.Stat(bin); err != nil { + t.Skipf("Envoy not found: %v", err) + } + require.NoError(t, e2etest.CheckSharedLibrary(examplesRoot, "ratelimit", "libratelimit.so")) + h := &harness{rls: &recordingRLS{}} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.upstreamHits.Add(1) + _, _ = io.Copy(w, r.Body) + })) + t.Cleanup(upstream.Close) + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + srv := grpc.NewServer() + rlsv3.RegisterRateLimitServiceServer(srv, h.rls) + go func() { _ = srv.Serve(l) }() + t.Cleanup(srv.Stop) + + config := ratelimit.Config{ + Cluster: cluster, Domain: "orange", TimeoutMillis: 100, FailureMode: mode, + Descriptors: [][]ratelimit.Entry{{{Key: "key_id", Header: "x-tars-id"}, {Key: "dim", Value: "rpm"}}}, + } + data, err := json.Marshal(config) + require.NoError(t, err) + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, data, 0600)) + ports := struct{ ProxyPort, AdminPort, UpstreamPort, RLSPort int }{ + e2etest.FreePort(), e2etest.FreePort(), + upstream.Listener.Addr().(*net.TCPAddr).Port, l.Addr().(*net.TCPAddr).Port, + } + cfgPath := e2etest.WriteEnvoyConfig("transit-ratelimit-e2e", envoyTemplate, ports) + stop, ok := e2etest.StartEnvoy(bin, cfgPath, filepath.Join(examplesRoot, "ratelimit"), ports.AdminPort, + []string{"RATELIMIT_CONFIG=" + configPath}) + require.True(t, ok, "Envoy failed to start") + t.Cleanup(stop) + h.url = fmt.Sprintf("http://127.0.0.1:%d/", ports.ProxyPort) + return h +} + +func (h *harness) request(t *testing.T, key, body string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, h.url, strings.NewReader(body)) + require.NoError(t, err) + if key != "" { + req.Header.Set("x-tars-id", key) + } + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp, string(data) +} + +type recordingRLS struct { + rlsv3.UnimplementedRateLimitServiceServer + mu sync.Mutex + last *rlsv3.RateLimitRequest +} + +func (s *recordingRLS) ShouldRateLimit(ctx context.Context, req *rlsv3.RateLimitRequest) (*rlsv3.RateLimitResponse, error) { + s.mu.Lock() + s.last = req + s.mu.Unlock() + switch req.GetDescriptors()[0].GetEntries()[0].GetValue() { + case "deny": + return &rlsv3.RateLimitResponse{OverallCode: rlsv3.RateLimitResponse_OVER_LIMIT}, nil + case "error": + return nil, status.Error(codes.Unavailable, "test failure") + case "unknown": + return &rlsv3.RateLimitResponse{}, nil + case "slow": + <-ctx.Done() + return nil, status.Error(codes.Canceled, "callout canceled") + default: + return &rlsv3.RateLimitResponse{OverallCode: rlsv3.RateLimitResponse_OK}, nil + } +} diff --git a/examples/ratelimit/e2e/testdata/envoy.tmpl.yaml b/examples/ratelimit/e2e/testdata/envoy.tmpl.yaml new file mode 100644 index 0000000..a99a1ad --- /dev/null +++ b/examples/ratelimit/e2e/testdata/envoy.tmpl.yaml @@ -0,0 +1,64 @@ +static_resources: + listeners: + - name: ratelimit + address: + socket_address: { address: 127.0.0.1, port_value: {{.ProxyPort}} } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ratelimit + http_filters: + - name: ratelimit + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter + dynamic_module_config: + name: ratelimit + filter_name: ratelimit + filter_config: + "@type": type.googleapis.com/google.protobuf.StringValue + value: '{}' + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: ratelimit + virtual_hosts: + - name: local + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: upstream } + + clusters: + - name: upstream + connect_timeout: 1s + type: STATIC + load_assignment: + cluster_name: upstream + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: 127.0.0.1, port_value: {{.UpstreamPort}} } + + - name: rls-service + connect_timeout: 1s + type: STATIC + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: rls-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: 127.0.0.1, port_value: {{.RLSPort}} } + +admin: + address: + socket_address: { address: 127.0.0.1, port_value: {{.AdminPort}} } diff --git a/examples/ratelimit/envoy-native.yaml b/examples/ratelimit/envoy-native.yaml new file mode 100644 index 0000000..244244b --- /dev/null +++ b/examples/ratelimit/envoy-native.yaml @@ -0,0 +1,75 @@ +static_resources: + listeners: + - name: ratelimit + address: + socket_address: { address: 127.0.0.1, port_value: 10000 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ratelimit + http_filters: + - name: envoy.filters.http.ratelimit + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit + domain: orange + failure_mode_deny: true + timeout: 0.05s + rate_limit_service: + grpc_service: + envoy_grpc: { cluster_name: rls-service } + transport_api_version: V3 + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: ratelimit + virtual_hosts: + - name: local + domains: ["*"] + routes: + - match: { prefix: "/" } + route: + cluster: upstream + rate_limits: + - actions: + - request_headers: + header_name: x-tars-id + descriptor_key: key_id + skip_if_absent: false + - generic_key: + descriptor_key: dim + descriptor_value: rpm + + clusters: + - name: upstream + connect_timeout: 1s + type: STATIC + load_assignment: + cluster_name: upstream + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: 127.0.0.1, port_value: 10001 } + + - name: rls-service + connect_timeout: 1s + type: STATIC + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: rls-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: 127.0.0.1, port_value: 10002 } + +admin: + address: + socket_address: { address: 127.0.0.1, port_value: 9901 } diff --git a/examples/ratelimit/envoy.yaml b/examples/ratelimit/envoy.yaml new file mode 100644 index 0000000..1359fc7 --- /dev/null +++ b/examples/ratelimit/envoy.yaml @@ -0,0 +1,64 @@ +static_resources: + listeners: + - name: ratelimit + address: + socket_address: { address: 127.0.0.1, port_value: 10000 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ratelimit + http_filters: + - name: ratelimit + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter + dynamic_module_config: + name: ratelimit + filter_name: ratelimit + filter_config: + "@type": type.googleapis.com/google.protobuf.StringValue + value: '{}' + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: ratelimit + virtual_hosts: + - name: local + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: upstream } + + clusters: + - name: upstream + connect_timeout: 1s + type: STATIC + load_assignment: + cluster_name: upstream + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: 127.0.0.1, port_value: 10001 } + + - name: rls-service + connect_timeout: 1s + type: STATIC + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: rls-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: 127.0.0.1, port_value: 10002 } + +admin: + address: + socket_address: { address: 127.0.0.1, port_value: 9901 } diff --git a/examples/ratelimit/ratelimit.go b/examples/ratelimit/ratelimit.go new file mode 100644 index 0000000..b3b9cf0 --- /dev/null +++ b/examples/ratelimit/ratelimit.go @@ -0,0 +1,131 @@ +// Package ratelimit implements request admission through Envoy's RLS v3 API. +// Configuration delivery is supplied by the embedding application; the request +// path reads one immutable snapshot and sends gRPC only through an Envoy cluster. +package ratelimit + +import ( + "fmt" + "net/http" + + rlscommonv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" + rlsv3 "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + "google.golang.org/protobuf/proto" + + "github.com/dio/transit/up" +) + +// DescriptorBuilder assembles request-scoped descriptors from trusted context. +// Return owned protobuf values; do not mutate them after returning. Empty or +// invalid results and errors reject admission, even in service fail-open mode. +type DescriptorBuilder func(*up.Writer, *up.Request) ([]*rlscommonv3.RateLimitDescriptor, error) + +// NewHandler creates a header-phase admission filter. The caller owns source +// refresh and shutdown. No config, including a zero Policy, fails closed. +func NewHandler(config *up.PipelineConfig[*Policy]) up.HandlerFunc { + return func(w *up.Writer, r *up.Request) { + var policy *Policy + if config != nil { + policy = config.Snapshot() + } + if policy == nil || policy.config.Cluster == "" { + reject(w, http.StatusServiceUnavailable, "rate limit config unavailable") + return + } + req, err := policy.requestFor(w, r) + if err != nil { + reject(w, http.StatusBadRequest, "rate limit descriptor unavailable") + return + } + body, err := proto.Marshal(req) + if err != nil { + reject(w, http.StatusInternalServerError, "rate limit request encoding failed") + return + } + _, err = w.GRPCCallout(up.GRPCCalloutRequest{ + Cluster: policy.config.Cluster, + Authority: policy.config.Authority, + Method: rlsv3.RateLimitService_ShouldRateLimit_FullMethodName, + Message: body, + TimeoutMillis: policy.config.TimeoutMillis, + }, func(response up.GRPCCalloutResponse) { + // Keep the captured policy, including failure mode, across refreshes. + switch code := policy.responseStatus(response); code { + case http.StatusTooManyRequests: + reject(w, code, "rate limit exceeded") + case http.StatusServiceUnavailable: + reject(w, code, "rate limit service unavailable") + } + }) + if err != nil && policy.config.FailureMode == "closed" { + reject(w, http.StatusServiceUnavailable, "rate limit service unavailable") + } + } +} + +func (p *Policy) requestFor(w *up.Writer, r *up.Request) (*rlsv3.RateLimitRequest, error) { + if p.config.BuildDescriptors == nil { + return p.request(r.Header, w.GetFilterState) + } + descriptors, err := p.config.BuildDescriptors(w, r) + if err != nil { + return nil, err + } + if len(descriptors) == 0 { + return nil, fmt.Errorf("descriptor builder returned no descriptors") + } + for _, descriptor := range descriptors { + if len(descriptor.GetEntries()) == 0 { + return nil, fmt.Errorf("descriptor builder returned an empty descriptor") + } + for _, entry := range descriptor.GetEntries() { + if entry.GetKey() == "" || entry.GetValue() == "" { + return nil, fmt.Errorf("descriptor builder returned an invalid entry") + } + } + } + return &rlsv3.RateLimitRequest{Domain: p.config.Domain, HitsAddend: 1, Descriptors: descriptors}, nil +} + +func (p *Policy) request(header func(string) string, state func(string) (string, bool)) (*rlsv3.RateLimitRequest, error) { + req := &rlsv3.RateLimitRequest{Domain: p.config.Domain, HitsAddend: 1} + for _, entries := range p.config.Descriptors { + descriptor := &rlscommonv3.RateLimitDescriptor{} + for _, entry := range entries { + value := entry.Value + if entry.Header != "" { + value = header(entry.Header) + } + if entry.FilterState != "" { + value, _ = state(entry.FilterState) + } + if value == "" { + return nil, fmt.Errorf("descriptor %q is unavailable", entry.Key) + } + descriptor.Entries = append(descriptor.Entries, &rlscommonv3.RateLimitDescriptor_Entry{Key: entry.Key, Value: value}) + } + req.Descriptors = append(req.Descriptors, descriptor) + } + return req, nil +} + +func (p *Policy) responseStatus(response up.GRPCCalloutResponse) int { + if response.Result == up.HTTPCalloutSuccess && response.GRPCStatus == 0 { + var result rlsv3.RateLimitResponse + if err := proto.Unmarshal(response.Body, &result); err == nil { + switch result.GetOverallCode() { + case rlsv3.RateLimitResponse_OK: + return 0 + case rlsv3.RateLimitResponse_OVER_LIMIT: + return http.StatusTooManyRequests + } + } + } + if p.config.FailureMode == "open" { + return 0 + } + return http.StatusServiceUnavailable +} + +func reject(w *up.Writer, code int, message string) { + w.SendLocalResponse(code, []byte(message), [2]string{"content-type", "text/plain"}) +} diff --git a/examples/ratelimit/ratelimit_test.go b/examples/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..97434d9 --- /dev/null +++ b/examples/ratelimit/ratelimit_test.go @@ -0,0 +1,136 @@ +package ratelimit + +import ( + "fmt" + "net/http" + "testing" + + "github.com/dio/transit/up" + "github.com/dio/transit/up/testutil" + rlscommonv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" + rlsv3 "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestCodeBuilderReadsTrustedMetadata(t *testing.T) { + c := validConfig() + c.Descriptors = nil + c.BuildDescriptors = func(w *up.Writer, _ *up.Request) ([]*rlscommonv3.RateLimitDescriptor, error) { + id, ok := w.GetMetadataString(up.MetadataSourceDynamic, "tars", "router_id") + if !ok { + return nil, fmt.Errorf("missing trusted router identity") + } + return []*rlscommonv3.RateLimitDescriptor{{Entries: []*rlscommonv3.RateLimitDescriptor_Entry{ + {Key: "key_id", Value: id.String()}, {Key: "dim", Value: "rpm"}, + }}}, nil + } + p, err := Compile(c) + require.NoError(t, err) + h := testutil.NewFilterHandle(testutil.WithHeaders(map[string]string{"x-tars-id": "spoofed"})) + h.SetMetadata("tars", "router_id", "trusted") + req, err := p.requestFor(up.NewWriter(h), up.NewRequest(h.RequestHeaders(), "ratelimit")) + require.NoError(t, err) + require.Equal(t, "trusted", req.Descriptors[0].Entries[0].Value) + require.Equal(t, "orange", req.Domain) + require.EqualValues(t, 1, req.HitsAddend) + missing := testutil.NewFilterHandle() + _, err = p.requestFor(up.NewWriter(missing), up.NewRequest(missing.RequestHeaders(), "ratelimit")) + require.Error(t, err) + c.Descriptors = validConfig().Descriptors + _, err = Compile(c) + require.Error(t, err, "must not silently combine code and configured descriptors") +} + +func TestCodeBuilderCannotSilentlySkipAdmission(t *testing.T) { + for _, result := range [][]*rlscommonv3.RateLimitDescriptor{ + nil, {nil}, {{}}, {{Entries: []*rlscommonv3.RateLimitDescriptor_Entry{nil}}}, + {{Entries: []*rlscommonv3.RateLimitDescriptor_Entry{{Key: "key", Value: ""}}}}, + } { + c := validConfig() + c.Descriptors = nil + c.FailureMode = "open" + c.BuildDescriptors = func(*up.Writer, *up.Request) ([]*rlscommonv3.RateLimitDescriptor, error) { return result, nil } + p, err := Compile(c) + require.NoError(t, err) + h := testutil.NewFilterHandle() + NewHandler(up.NewStaticConfig(p))(up.NewWriter(h), up.NewRequest(h.RequestHeaders(), "ratelimit")) + require.Len(t, h.LocalResponses, 1) + require.EqualValues(t, 400, h.LocalResponses[0].Status) + } +} + +func TestDescriptorSources(t *testing.T) { + c := validConfig() + c.Descriptors = append(c.Descriptors, []Entry{{Key: "tenant", FilterState: "auth.tenant"}}) + p, err := Compile(c) + require.NoError(t, err) + req, err := p.request(func(name string) string { + require.Equal(t, "x-tars-id", name) + return "alice" + }, func(name string) (string, bool) { + require.Equal(t, "auth.tenant", name) + return "tenant-a", true + }) + require.NoError(t, err) + require.Equal(t, "orange", req.Domain) + require.EqualValues(t, 1, req.HitsAddend) + require.Len(t, req.Descriptors, 2) + require.Equal(t, "alice", req.Descriptors[0].Entries[0].Value) + require.Equal(t, "rpm", req.Descriptors[0].Entries[1].Value) + require.Equal(t, "tenant-a", req.Descriptors[1].Entries[0].Value) + _, err = p.request(func(string) string { return "alice" }, func(string) (string, bool) { return "", false }) + require.Error(t, err) +} + +func TestResponseDecisions(t *testing.T) { + ok, err := proto.Marshal(&rlsv3.RateLimitResponse{OverallCode: rlsv3.RateLimitResponse_OK}) + require.NoError(t, err) + denied, err := proto.Marshal(&rlsv3.RateLimitResponse{OverallCode: rlsv3.RateLimitResponse_OVER_LIMIT}) + require.NoError(t, err) + for _, mode := range []string{"closed", "open"} { + c := validConfig() + c.FailureMode = mode + p, err := Compile(c) + require.NoError(t, err) + failure := http.StatusServiceUnavailable + if mode == "open" { + failure = 0 + } + for _, tc := range []struct { + name string + response up.GRPCCalloutResponse + want int + }{ + {"allow", up.GRPCCalloutResponse{Body: ok}, 0}, + {"deny", up.GRPCCalloutResponse{Body: denied}, http.StatusTooManyRequests}, + {"reset", up.GRPCCalloutResponse{Result: up.HTTPCalloutReset}, failure}, + {"grpc error", up.GRPCCalloutResponse{GRPCStatus: 14, Body: ok}, failure}, + {"malformed", up.GRPCCalloutResponse{Body: []byte{0xff}}, failure}, + {"empty unknown", up.GRPCCalloutResponse{}, failure}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { require.Equal(t, tc.want, p.responseStatus(tc.response)) }) + } + } +} + +func TestMissingConfigAndDescriptorRejectBeforeCallout(t *testing.T) { + p, err := Compile(validConfig()) + require.NoError(t, err) + for _, tc := range []struct { + name string + pc *up.PipelineConfig[*Policy] + status int + }{ + {"missing config", nil, 503}, + {"zero policy", up.NewStaticConfig(&Policy{}), 503}, + {"missing descriptor", up.NewStaticConfig(p), 400}, + } { + t.Run(tc.name, func(t *testing.T) { + h := testutil.NewFilterHandle() + NewHandler(tc.pc)(up.NewWriter(h), up.NewRequest(h.RequestHeaders(), "ratelimit")) + require.Len(t, h.LocalResponses, 1) + require.EqualValues(t, tc.status, h.LocalResponses[0].Status) + }) + } +} diff --git a/examples/ratelimit/rls-config.yaml b/examples/ratelimit/rls-config.yaml new file mode 100644 index 0000000..1cc4406 --- /dev/null +++ b/examples/ratelimit/rls-config.yaml @@ -0,0 +1,9 @@ +domain: orange +descriptors: + - key: key_id + descriptors: + - key: dim + value: rpm + rate_limit: + unit: minute + requests_per_unit: 60 From 32aec72bc63ade0998f43118f30c091be9e9caa2 Mon Sep 17 00:00:00 2001 From: Dhi Aurrahman Date: Thu, 8 Oct 2026 15:53:12 +0700 Subject: [PATCH 3/5] docs: distinguish Fraser main admission integration from adopt-orange --- examples/ratelimit/FRASER.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/examples/ratelimit/FRASER.md b/examples/ratelimit/FRASER.md index a92c475..9fccd6b 100644 --- a/examples/ratelimit/FRASER.md +++ b/examples/ratelimit/FRASER.md @@ -25,7 +25,7 @@ are different contracts; this example implements only the first. ## Evidence and branch boundaries Inspected Fraser `origin/adopt-orange` at `c6f1edc98` and the separate current -`tars-dataplane` implementation on main at `a32c4d172`: +`tars-dataplane` implementation on `origin/main` at `573004cbf`: - Main `tars-dataplane/filter.go`, `OnRequestHeaders`: authentication and project/gateway authorization precede stamping `x-tars-id`, `x-tars-user`, @@ -147,3 +147,18 @@ unmatched descriptors may be treated as unlimited by the service. correctness/availability review. Protocol compatibility is not readiness. No Fraser integration or production deployment is part of this example. + +## Targeting origin/main independently + +Do not merge `adopt-orange` or introduce Plum to adopt this module on main. +Main's Composer entrypoint embeds TARS and guardrails directly through the +Envoy Go SDK. It currently pins SDK `b579d07d3ad7`; this Transit example was +tested with Envoy/SDK `0d6e3c60aa55`. A Transit dependency bump is therefore +neither necessary nor sufficient for main's integration. + +If product requirements call for code-built descriptors or independently +reloaded client policy, qualify a separate Envoy-loaded `libratelimit.so` on +main's exact Envoy build, alongside `libcomposer.so`, then add explicit Liaison +filter placement and identity/order tests. If the requirements are only request +quotas over existing trusted headers or metadata, prefer the native filter. +Both can call the same RLS and use its custom quota configuration source. From 45a2fecdc01b07fd301d815f2baf0895987d2299 Mon Sep 17 00:00:00 2001 From: Dhi Aurrahman Date: Fri, 9 Oct 2026 16:46:29 +0700 Subject: [PATCH 4/5] ci: run rate-limit admission unit tests --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adef283..af7d2cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: - name: example tests run: | make -C examples/hello test + make -C examples/ratelimit test make -C examples/sse-tap test make -C examples/request-ui test make -C examples/lb-policy test From aadf0c92f2d5177828ff448d33e4d7108b4b1475 Mon Sep 17 00:00:00 2001 From: Dhi Aurrahman Date: Fri, 9 Oct 2026 16:47:53 +0700 Subject: [PATCH 5/5] examples: format rate-limit test imports --- examples/ratelimit/config_test.go | 3 ++- examples/ratelimit/e2e/e2e_test.go | 5 +++-- examples/ratelimit/ratelimit_test.go | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/ratelimit/config_test.go b/examples/ratelimit/config_test.go index 2a6e2e8..f8291a6 100644 --- a/examples/ratelimit/config_test.go +++ b/examples/ratelimit/config_test.go @@ -8,8 +8,9 @@ import ( "sync/atomic" "testing" - "github.com/dio/transit/up" "github.com/stretchr/testify/require" + + "github.com/dio/transit/up" ) func validConfig() Config { diff --git a/examples/ratelimit/e2e/e2e_test.go b/examples/ratelimit/e2e/e2e_test.go index 11c08a5..cecda39 100644 --- a/examples/ratelimit/e2e/e2e_test.go +++ b/examples/ratelimit/e2e/e2e_test.go @@ -18,13 +18,14 @@ import ( "testing" "time" - "github.com/dio/transit/examples/internal/e2etest" - "github.com/dio/transit/examples/ratelimit" rlsv3 "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/dio/transit/examples/internal/e2etest" + "github.com/dio/transit/examples/ratelimit" ) //go:embed testdata/envoy.tmpl.yaml diff --git a/examples/ratelimit/ratelimit_test.go b/examples/ratelimit/ratelimit_test.go index 97434d9..5f0de50 100644 --- a/examples/ratelimit/ratelimit_test.go +++ b/examples/ratelimit/ratelimit_test.go @@ -5,12 +5,13 @@ import ( "net/http" "testing" - "github.com/dio/transit/up" - "github.com/dio/transit/up/testutil" rlscommonv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" rlsv3 "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + + "github.com/dio/transit/up" + "github.com/dio/transit/up/testutil" ) func TestCodeBuilderReadsTrustedMetadata(t *testing.T) {