feat: per-request forward routing via optional sdk.RequestRouter#104
Open
arnaugiralt wants to merge 21 commits into
Open
feat: per-request forward routing via optional sdk.RequestRouter#104arnaugiralt wants to merge 21 commits into
arnaugiralt wants to merge 21 commits into
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds a httputil.ReverseProxy per configured forward target with header rewriting (strip inbound Authorization, inject bearer when configured), TLS 1.3 minimum, and defense-in-depth sanitization of sensitive response headers. Consolidates the sensitive-headers default list into internal/security so both vendor and forward paths share a single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a forwardProxies registry on the Server, populated from proxy.Config.ForwardTargets at construction. Wires the YAML-parsed forward_targets through chaperone.go into the runtime proxy config so production deployments actually see them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Type-asserts the configured plugin against sdk.RequestRouter in NewServer and stores the reference on the Server. Per-request cost is one nil check; plugins that do not implement the interface are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-request routing branch in handleProxy. When the plugin implements sdk.RequestRouter and returns a RouteAction with a ForwardTo target, the matching forward proxy serves the request and the credential-injection flow is skipped. Unknown target names yield 500 with a generic error. Extracts parseAndValidateTarget and routeAndMaybeForward helpers to keep handleProxy under the funlen ceiling. Logs the routing decision (action=forward|credentials) for both paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three Prometheus metrics:
- chaperone_route_decisions_total{action, target}
- chaperone_forward_target_duration_seconds{target}
- chaperone_forward_target_errors_total{target, kind}
Routing decisions are counted at both forward and credential paths.
Forward target duration is observed for all requests via deferred
observation in ServeHTTP. Errors are classified into timeout, tls,
connection, and other via ordered error inspection; 5xx target
responses are not counted as errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Route.Data: map[string]string for matching against Data entries in TransactionContext (e.g., ResellerId, TenantId). Each entry must match via GlobMatch; missing keys, wrong-type values, and empty strings all yield non-match. Data entries contribute to Specificity so more-specific routes win. Updates routesMayOverlap to treat shared Data keys as shared dimensions (disjoint literals prove non-overlap) and routeString to render Data entries in deterministic key order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a sealed Action interface with two implementations: CredentialAction (existing flow) and ForwardAction (new). Refactors routeEntry to hold an Action; preserves Handle(route, provider) as a back-compat wrapper and adds HandleForward(route, target). GetCredentials uses defensive sentinels: ErrUnexpectedForwardAction if the matched route is a ForwardAction (RouteRequest should have short-circuited), ErrNilCredentialProvider for guards against nil providers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RouteRequest returns a non-nil RouteAction with ForwardTo only when the matched route is a ForwardAction. CredentialAction matches and no-match cases fall through (return nil), preserving the existing GetCredentials path. Reuses Mux.match for routing logic — DRY. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entials Adds LoadMuxFromConfig that builds a Mux from a MuxConfig and a provider lookup table. Each route declares exactly one of forward or credentials; mutual exclusion is enforced at load time with the offending location named in the error. Fallback supports credentials only — fallback forwarding is rejected because a silent fallback-forward would catch every unmatched request without credentials. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mux exposes ForwardReferences() listing every forward_target name its routes reference. NewServer type-asserts the plugin against that optional interface; unknown references fail fast with the offending name in the error. forward_targets entries that are never referenced produce a warning log line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drives a real proxy.Server end-to-end via an inline RequestRouter stub. Verifies header propagation, bearer-token injection, inbound Authorization stripping, response body/status/headers propagation, sensitive header sanitization, 502 on unreachable target, and that GetCredentials is never invoked on the forward path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…routes Single Chaperone server across three requests with one plugin that implements both sdk.Plugin and sdk.RequestRouter. Requests with ResellerId matching migrated-* are forwarded to the Company B fake; legacy resellers fall through to credential injection against a vendor fake. Verifies hit counts on both targets, header propagation versus stripping, and the route_decisions_total metric counts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- SDK reference: documents the optional RequestRouter interface, RouteAction semantics, and the VerifyRouter compliance helper. - Configuration reference: documents the forward_targets section with auth modes, validation rules, and an example. - Contrib plugins reference: documents Mux.HandleForward, the Route.Data matcher, MuxConfig / LoadMuxFromConfig, and the new sentinel errors. - Plugin development guide: brief subsection on forwarding requests with pointers to the SDK and contrib references. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions Tests that swapped slog.Default() with a bytes.Buffer-backed handler raced under -race when production goroutines logged concurrently with assertion-time buf.String() reads. Adds a syncBuffer + captureLogs helper that wraps bytes.Buffer in a sync.Mutex, and migrates every internal/proxy test that captured logs onto the new helper. Test-only change; no production code modified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bare http.Transport{} previously used for forward targets silently
dropped HTTP/2 (ForceAttemptHTTP2), the dialer defaults, idle-conn
pooling (MaxIdleConns, IdleConnTimeout), TLSHandshakeTimeout, and
ExpectContinueTimeout — all of which the vendor path inherits via
httputil.NewSingleHostReverseProxy's reliance on DefaultTransport.
Clone DefaultTransport and override only ResponseHeaderTimeout and
TLSClientConfig (TLS 1.3 min, verify on). Adds a unit test that asserts
the inherited fields are non-zero and the overrides are in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty target names were previously accepted at registration and only
caught at cross-validation time (or, for programmatic registrations
without cfg.ForwardTargets, fell through to RouteAction{ForwardTo: ""}
which the proxy core treats as fall-through — a silent misroute).
Panic at registration with a clear message; config-driven users still
get the cleaner LoadMuxFromConfig validation error before reaching
HandleForward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rget The design states that Connect-Request-ID reaches the forward target for trace continuity. Until now this worked only because no code path strips it — proof-by-non-action. Add an explicit assertion to the propagation subtest so a future director refactor cannot silently break trace continuity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gosec's SSRF taint analysis flags fp.proxy.ServeHTTP(w, r) because the inbound request r is user-controlled, but the destination URL is fixed at startup from forward_targets config (set on fp.target and applied by the director). The inbound request cannot influence where the proxy dials. Mirrors the same #nosec G704 annotation used by the vendor reverse proxy in internal/proxy/server.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new optional SDK capability,
sdk.RequestRouter, that lets a plugin make a per-request decision: forward this request to a different upstream, or fall through to credential injection and the vendor call (today's behavior). The contribMuximplements it. The proxy core gains one branch inhandleProxyto honor a forward action.This unlocks two related use cases with a single mechanism:
Pure forwarding is the degenerate case: a single wildcard route with a
forwardaction. Existing plugins that don't implementRequestRouterare completely unaffected.Request flow
flowchart TB A[Request arrives over mTLS] --> B[Trace ID + Logging + Metrics] B --> C[Panic recovery] C --> D[Allow-list validates<br/>X-Connect-Target-URL] D --> E[Parse TransactionContext] E --> F{Plugin implements<br/>RequestRouter?} F -- no --> G[injectCredentials] F -- yes --> H[RouteRequest tx req] H --> I{action.ForwardTo<br/>set?} I -- empty / nil --> G I -- non-empty --> J[Look up<br/>forwardProxies name] J --> K{Found?} K -- no --> L[500 internal<br/>configuration error] K -- yes --> M[Strip inbound Authorization<br/>Add bearer token<br/>Preserve X-Connect-*] M --> N[Stream to forward target] N --> O[Sanitize sensitive<br/>response headers] O --> P[Return to Connect] G --> Q[Forward to vendor URL<br/>with creds] Q --> R[ModifyResponse] R --> PThe router runs before
injectCredentialsand after the allow-list. Allow-list semantics are unchanged:X-Connect-Target-URLis validated for every request, including forwarded ones, because the customer's system reads that header to know which vendor to call.Migration matcher in action
sequenceDiagram autonumber actor Reseller as Reseller A<br/>(legacy) actor ResellerM as Reseller B<br/>(migrated) participant Connect as Connect Platform participant Chap as Chaperone participant Mux as contrib.Mux participant CompB as Company B System participant Vendor as Vendor API Reseller->>Connect: Action Connect->>Chap: mTLS request<br/>ResellerId=legacy-99 Chap->>Mux: RouteRequest Mux-->>Chap: nil (fall-through) Chap->>Chap: GetCredentials → CompanyA token Chap->>Vendor: Authorization: Bearer A-token Vendor-->>Chap: 200 Chap-->>Connect: 200 ResellerM->>Connect: Action Connect->>Chap: mTLS request<br/>ResellerId=migrated-001 Chap->>Mux: RouteRequest Mux-->>Chap: RouteAction{ForwardTo: "company-b"} Chap->>CompB: Forward with X-Connect-*<br/>Authorization: Bearer cb-token CompB->>CompB: Inject CompanyB creds<br/>Apply filters CompB->>Vendor: Call vendor Vendor-->>CompB: response CompB->>CompB: Filter response CompB-->>Chap: filtered response Chap-->>Connect: filtered responseSame Chaperone instance, same plugin, two outcomes — selected per request by inspecting
tx.Data["ResellerId"].What's in this PR
SDK additions (
sdk/)classDiagram class Plugin { <<interface>> +CredentialProvider +CertificateSigner +ResponseModifier } class RequestRouter { <<optional interface>> +RouteRequest(ctx, tx, req) RouteAction, error } class RouteAction { +ForwardTo string } class Mux { +Handle(route, provider) +HandleForward(route, target) +RouteRequest(ctx, tx, req) RouteAction, error +GetCredentials(ctx, tx, req) Credential, error +ForwardReferences() []string } Plugin <|.. Mux RequestRouter <|.. Mux RequestRouter ..> RouteActionsdk.RequestRouter— optional interface; nil-check at startup.sdk.RouteAction{ForwardTo string}— empty / nil = fall-through.compliance.VerifyRouter— contract test for plugins that implement the interface.Proxy core (
internal/proxy/)ForwardProxytype — one per configured target, built at startup, reused per request.Authorization, adds bearer if configured, preservesX-Connect-*andConnect-Request-ID.InsecureSkipVerify: false. Transport now cloneshttp.DefaultTransportso HTTP/2, idle-conn pooling, dialer defaults, and stdlib timeouts apply — matching the vendor path.handleProxygains one branch: type-assert plugin againstRequestRouter, callRouteRequest, dispatch to forward proxy on a non-emptyForwardTo.{"error":"internal configuration error"}.forward_target; unused targets log a warning.Configuration (
internal/config/)build-dev.Contrib mux (
plugins/contrib/)Route.Data— newmap[string]stringmatcher againstTransactionContext.Data. Missing keys, wrong-type values, and empty strings all yield non-match. Counts toward route specificity.Actioninterface —CredentialAction(existing flow) andForwardAction(new).routeEntry.actionreplaces the oldrouteEntry.provider.Mux.HandleForward(route, target)— registers a forward route. Panics on empty target name. Mutually exclusive withHandleper route.Mux.RouteRequest— satisfiessdk.RequestRouter. Returns non-nilRouteActiononly forForwardActionmatches.Mux.ForwardReferences()— lists every forward-target name referenced by routes, used by the proxy for startup cross-validation.LoadMuxFromConfig— builds aMuxfromMuxConfig(YAML-shaped). Validates mutual exclusion offorward:vscredentials:per route; rejectsFallback.Forward(fallback must be a credentials provider — explicit, not silent).Observability (
internal/telemetry/)Three new Prometheus metrics:
chaperone_route_decisions_totalaction,targetaction ∈ {forward, credentials}. Emptytargetfor credentials path.chaperone_forward_target_duration_secondstargetchaperone_forward_target_errors_totaltarget,kindkind ∈ {connection, timeout, tls, other}via ordered error classification.Plus a structured log line per request:
Documentation (
docs/)docs/reference/sdk.md—RequestRouter,RouteAction,VerifyRoutersections.docs/reference/configuration.md—forward_targetssection.docs/reference/contrib-plugins.md—HandleForward,Route.Data,MuxConfig, sentinel errors.docs/guides/plugin-development.md— forwarding-requests subsection.Test infrastructure
syncBuffer+captureLogs(t)helper ininternal/proxy/to thread-safely captureslogoutput. Migrated every priorbytes.Buffer-backed log test in the package. Pure test-only change; uncovered by-raceonce the feature work added concurrent log assertions.Test plan
Connect-Request-IDexplicitly asserted to propagate to forward targets.make test,make test-race,make lint,make fmt,make vet,make license-check,make buildall green.make gosec— passes for everything on this branch.Backward compatibility
RequestRouterretain today's behavior with zero changes.forward_targetsare valid.Mux.Handle(route, provider)signature unchanged; refactor to action variants is internal.sdk/v1.<next>.0(additive, source-compatible).Security notes
TestForwardProxy_BearerToken_NotInLogOutput.Authorizationfrom Connect is stripped before forwarding so we don't leak Connect's auth posture to the customer system.Authorization,Set-Cookie, etc.) are stripped from forwarded responses as defense in depth — even though the customer system is trusted.X-Connect-Target-URLfor forwarded requests (the customer's system reads it).Out of scope / deferred
auth.type: mtlsfollow-up.ModifyResponseon forward routeshttputil.ReverseProxydefaults.