dwara_core/config/mod.rs
1//! Configuration schema v1 for the gateway (frozen domain vocabulary).
2//!
3//! The types in this module model the source form of a dwara configuration
4//! file. Compilation into a runtime snapshot is a later concern
5//! (config-compile pipeline); here we define strict, self-describing types:
6//!
7//! - every struct rejects unknown fields (`deny_unknown_fields`),
8//! - field order follows declaration order on serialization,
9//! - the root type [`Gateway`] exports a JSON Schema via `schemars`.
10//!
11//! YAML <-> typed conversions are provided by [`parse_gateway`] (YAML text to
12//! typed value, with path-precise error messages) and [`gateway_to_yaml`]
13//! (typed value back to normalized YAML text). Round-trip guarantee:
14//! `parse_gateway(gateway_to_yaml(cfg))` always succeeds and yields a value
15//! that serializes to the identical normalized text (stable normalization,
16//! not byte-identity with the original input).
17//!
18//! Submodules carry the parts of the config CONTRACT that are more than
19//! serde shapes: [`credentials`] defines the credential selector/stored-hash
20//! formats every credential holder must agree on plus the `${...}`
21//! secret-reference grammar (DW-045), [`limits`] the numeric
22//! bounds validation enforces, [`net`] the trusted-proxy IP/CIDR
23//! grammar shared by validation and the runtime matchers,
24//! [`versioning`] the HTTP-date and media-type grammar of the API
25//! versioning aids (DW-048), and [`transforms`] the JSON-pointer
26//! grammar and shapes of the request/response transforms and
27//! security-header injection (DW-028). [`cache`] carries the
28//! route-scoped response-caching grammar (DW-037), and [`ai`] the
29//! AI provider/model grammar of the provider-adapter pack (DW-075).
30
31pub mod ai;
32pub mod cache;
33pub mod credentials;
34pub mod includes;
35pub mod limits;
36pub mod mesh;
37pub mod net;
38pub mod quotas;
39pub mod ssrf;
40pub mod transforms;
41pub mod versioning;
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46use cache::RouteCache;
47use transforms::{Masking, SecurityHeaders, Transforms};
48
49/// Error produced when a configuration document fails to parse.
50///
51/// Carries the dotted path of the offending node (e.g. `listeners[0].port`)
52/// and the underlying serde message.
53#[derive(Debug)]
54pub struct ConfigError {
55 pub path: String,
56 pub message: String,
57}
58
59impl std::fmt::Display for ConfigError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "config error at {}: {}", self.path, self.message)
62 }
63}
64
65impl std::error::Error for ConfigError {}
66
67/// CFG-03 (#239): the current config schema version. Increment when
68/// the schema gains a breaking change (removed fields, renamed
69/// fields, changed semantics). The `dwara lint` command warns when a
70/// config's `version` is older than this; `dwara migrate` upgrades
71/// configs to this version.
72pub const CURRENT_CONFIG_VERSION: u32 = 1;
73
74/// Default config version (assumed when `version` is absent from the
75/// YAML). The initial schema is version 1.
76fn default_config_version() -> u32 {
77 1
78}
79
80/// Whether the config version is the default (1). Used by
81/// `skip_serializing_if` to omit the field when it's the default.
82fn is_default_config_version(v: &u32) -> bool {
83 *v == 1
84}
85
86/// Root of a dwara configuration: one gateway process, N listeners, and one
87/// compiled config generation.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct Gateway {
91 /// CFG-03 (#239): config schema version. Defaults to 1 when
92 /// absent (the initial schema). The `dwara lint` command warns
93 /// when the version is older than the current schema version; the
94 /// `dwara migrate` command upgrades the config to the current
95 /// version.
96 #[serde(
97 default = "default_config_version",
98 skip_serializing_if = "is_default_config_version"
99 )]
100 pub version: u32,
101 /// Entry points the gateway binds.
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub listeners: Vec<Listener>,
104 /// Match rules routing requests to services.
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub routes: Vec<Route>,
107 /// Logical APIs being exposed.
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub services: Vec<Service>,
110 /// Load-balancing pools.
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub upstreams: Vec<Upstream>,
113 /// API caller identities.
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub consumers: Vec<Consumer>,
116 /// Named reusable rule bundles.
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub policies: Vec<Policy>,
119 /// Gateway-level (global) policy attachment (#123): names of policies
120 /// from the `policies` list that apply to EVERY request the gateway
121 /// serves — the LEAST specific link of the frozen precedence chain
122 /// consumer > route > service > listener > global. Named
123 /// `global_policies` (not `policies`, which is the policy REGISTRY at
124 /// this level) so the two roles cannot be confused. All applicable
125 /// levels' rules AND together (see the rate-limiter module docs), so a
126 /// global policy is an additional constraint on every request, not a
127 /// default that more specific levels replace. Global policies also
128 /// apply to UNROUTED traffic: a request whose path matches no route
129 /// is rate-limited by them before the 404 is answered (the reserved
130 /// paths /healthz, /readyz, and /metrics stay exempt).
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub global_policies: Vec<String>,
133 /// Gateway-level (global) authorization rules (#123): the least
134 /// specific link of the authorization precedence chain
135 /// consumer > route > service > listener > global (see [`Authz`] for
136 /// the rule semantics and the `authz` module for the merge: a deny at
137 /// ANY level wins; otherwise the most specific level with rules
138 /// governs). Applies to every request that resolved a route
139 /// (authorization runs after route resolution per the documented
140 /// request-path order). Same validation rules as route-level
141 /// authorization; see [`Authz`].
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub authorization: Option<Authz>,
144 /// Gateway-level default security headers baseline (SEC-09, #213):
145 /// applied to EVERY route that does not carry its own
146 /// `security_headers` block and has not set
147 /// `security_headers_opt_out: true`. Fail-secure defaults: a
148 /// route with no `security_headers` block inherits this posture
149 /// rather than shipping without HSTS/nosniff. A route that needs
150 /// to disable a header from the default sets its own
151 /// `security_headers` block (which fully replaces the default
152 /// for that route), or sets `security_headers_opt_out: true` to
153 /// opt out entirely. See [`SecurityHeaders`].
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub default_security_headers: Option<SecurityHeaders>,
156 /// SEC-08 (#212): Global WAF policy with CRS-compatible rules.
157 /// When enabled, the rules are evaluated on every request
158 /// (regardless of route-level WAF config). The anomaly score is
159 /// accumulated across all matching rules; the request is blocked
160 /// when the score exceeds the threshold. See [`GlobalWaf`].
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub waf: Option<GlobalWaf>,
163 /// IP addresses / CIDR ranges of proxies whose `X-Forwarded-For` claims
164 /// are trusted (gateway-level; the direct connection peer must be in
165 /// this list for an inbound XFF chain to be preserved and extended).
166 /// Each entry must be an IP address (e.g. `10.1.2.3`) or a CIDR
167 /// (e.g. `10.0.0.0/8`); anything else fails validation. Empty (the
168 /// default) trusts nobody: every proxied request carries an XFF of
169 /// exactly the direct peer, and inbound XFF values are discarded.
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub trusted_proxies: Vec<String>,
172 /// Gateway-level concurrency cap (DW-015): the maximum number of
173 /// requests admitted concurrently across the WHOLE gateway. Absent
174 /// (the default) is unlimited; 0 is invalid (validation rejects it —
175 /// omit the field for unlimited). Over-cap requests are rejected
176 /// immediately with 503 "gateway saturated" (no queueing). A slot is
177 /// reserved at request admission and released when the response body
178 /// completes (or the connection drops). The reserved paths
179 /// `/healthz` and `/readyz` bypass the cap so liveness/readiness
180 /// probes still answer under saturation.
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub max_concurrent_requests: Option<u32>,
183 /// Monitor mode (DW-041) for the gateway concurrency cap's load
184 /// shedding: when the cap is saturated and a request would be shed,
185 /// the would-shed is LOGGED and counted
186 /// (`dwara_policy_dry_run_total{phase="load_shed"}` + a
187 /// `dwara::policy` warn event) and the request is ADMITTED over the
188 /// cap instead — deliberately exceeding `max_concurrent_requests` so
189 /// an operator can observe what a cap would shed (and at which
190 /// priorities) before enforcing it. Only meaningful with a cap set;
191 /// validation rejects it on an uncapped gateway (it would be a
192 /// no-op flag that reads as coverage).
193 #[serde(default, skip_serializing_if = "is_false")]
194 pub load_shed_dry_run: bool,
195 /// JWT verification providers (DW-019): trusted token issuers whose
196 /// keys are fetched from a JWKS endpoint. Each provider independently
197 /// verifies `Authorization: Bearer` tokens (alg allowlist, iss/aud,
198 /// exp with leeway) and maps the token to a consumer — via the
199 /// provider's explicit `consumer` binding, or by matching a
200 /// consumer's `jwt` credential `issuer` against the token's `iss`
201 /// claim. Empty (the default): the gateway does not interpret Bearer
202 /// tokens and forwards `Authorization` upstream untouched.
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub jwt_providers: Vec<JwtProvider>,
205 /// Admin API listener (DW-022, decision 6): mTLS-ONLY management
206 /// endpoint. Absent (the default): no admin listener is started at
207 /// all — the gateway is admin-silent until an operator configures
208 /// one. When present, `tls` must carry all three files (server
209 /// cert, key, and the CA that client certificates must chain to);
210 /// a missing `client_ca_file` is rejected by validation — plaintext
211 /// admin is not a supported production shape. The dev fallback
212 /// (`DWARA_ADMIN_DEV=1`, dwara-bin) allows loopback-only plaintext
213 /// and is loudly dev-only. The admin listener's bind set is fixed
214 /// at startup; changes take effect on restart.
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub admin: Option<AdminConfig>,
217 /// Deliberate opt-in to running a gateway with ZERO routes (#129,
218 /// maintainer decision). Default false: validation rejects a
219 /// route-less config. An empty route set is schema-valid (every
220 /// collection defaults empty), and a truncated or torn config
221 /// write (truncate-then-save, common in naive editors) is
222 /// schema-valid too — publishing it would drop ALL routing
223 /// mid-run (every request 404s while the file "looks fine"). The
224 /// guard applies to cold start AND hot reload alike; a rejected
225 /// reload keeps the running generation serving. Set this to true
226 /// only for deliberate route-less shapes (e.g. a gateway whose
227 /// sole job is the admin API): with the flag set, unrouted
228 /// requests answer 404 after listener/global policy checks, per
229 /// the documented request-path order.
230 #[serde(default, skip_serializing_if = "is_false")]
231 pub allow_empty_routes: bool,
232 /// HMAC request-signing verification policy (DW-036): the
233 /// gateway-wide clock-skew window applied to the
234 /// `X-Dwara-Timestamp` header of every signed request. Absent
235 /// (the default) = [`DEFAULT_HMAC_CLOCK_SKEW_SECS`] (±5 minutes).
236 /// See the `security::authn` module docs for the full
237 /// signing contract (canonical string, headers, replay window).
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub hmac_auth: Option<HmacAuth>,
240 /// Alert/event webhook targets (DW-044): every gateway event whose
241 /// kind appears in a target's `events` list is POSTed to that
242 /// target's `url` as one JSON envelope (id, kind, timestamp,
243 /// gateway instance, payload). Delivery runs on a background task,
244 /// never on the request path; emission is a bounded non-blocking
245 /// hand-off (a full queue drops the event and counts the drop), so
246 /// webhook failures can never stall the dataplane. Header values
247 /// may be inline or `${...}` secret references (DW-045) — inline
248 /// values are redacted in every config echo. See the `events`
249 /// module docs for the envelope, the retry/budget shape, and the
250 /// egress posture. Empty (the default): no webhooks, and events
251 /// are still counted (emitted/dropped) but delivered nowhere.
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub webhooks: Vec<Webhook>,
254 /// The embedded analytics store (DW-043). Absent (the default):
255 /// no analytics database, request records go nowhere beyond the
256 /// Prometheus families and the sampled access log, and the admin
257 /// API's analytics endpoints answer 404. When present, the
258 /// gateway opens a SEPARATE SQLite file (never the state store's
259 /// database) at `path`, feeds every completed request's access
260 /// record to it on a fire-and-forget channel (a full channel
261 /// DROPS and counts — analytics can never slow the dataplane),
262 /// rolls raw records up through 1m/5m/1h/1d additive tables with
263 /// per-granularity retention, and serves the dashboard/Top-N/
264 /// structured-query endpoints from the admin listener. See the
265 /// `analytics` module docs for the write path and the bounded-disk
266 /// story. Part of startup wiring (the database opens once); the
267 /// DIMENSIONS list is read live from the current generation so a
268 /// reload can add or rename dimensions without a restart.
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub analytics: Option<AnalyticsConfig>,
271 /// Real-time access-record stream (DW-121, the opt-in firehose out):
272 /// every completed request's access record — not rollups, not the
273 /// discrete DW-044 ops events — streamed to an external sink in
274 /// flushed batches. Complements, never replaces, the embedded
275 /// analytics store: the two are configured independently and a
276 /// deployment can run either, both, or neither. Absent (the
277 /// default): no stream. The pipeline is fire-and-forget end to end
278 /// (a bounded channel that drops and counts — it can never slow or
279 /// block the dataplane). See the `events::stream` module docs for
280 /// the wire format, the batching contract, and the failure
281 /// isolation story. Startup wiring arms the pipeline (the channel
282 /// capacity is fixed at boot); the sink set — including the
283 /// enabled/disabled state — is live per config generation.
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub analytics_stream: Option<AnalyticsStreamConfig>,
286 /// The GeoIP database (DW-050): MaxMind-format .mmdb file backing
287 /// `authorization.geoip` country/ASN predicates. Absent: geo rules
288 /// are rejected by validation (an unevaluable gate is an authoring
289 /// error, not a silent pass).
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub geoip: Option<GeoipConfig>,
292 /// Bounded admission queues (DW-053): when the gateway concurrency
293 /// cap (`max_concurrent_requests`) is saturated, requests WAIT for a
294 /// permit up to `queue_timeout_ms` instead of being immediately
295 /// shed. This produces a graceful degradation curve (latency rises
296 /// before shedding begins) rather than the cliff of immediate
297 /// shedding. Absent (the default): the DW-016 behavior stands —
298 /// over-cap requests are shed immediately with 503. Requires
299 /// `max_concurrent_requests` (validation rejects an enabled queue
300 /// on an uncapped gateway, the same rule as `load_shed_dry_run`).
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub admission_queue: Option<AdmissionQueue>,
303 /// mTLS client-certificate consumer mapping (DW-035): maps a
304 /// VERIFIED client certificate (its SHA-256 fingerprint or subject
305 /// CN) to a consumer name, so a client presenting a valid mTLS
306 /// certificate is authenticated WITHOUT an API key, JWT, or other
307 /// header credential — the certificate IS the credential. The
308 /// certificate must still chain to a listener's `client_ca_file`
309 /// (verified at the TLS layer); this mapping only resolves an
310 /// ALREADY-VERIFIED certificate to a consumer. Distinct from the
311 /// per-consumer `mtls` credential (which binds a cert to ONE
312 /// consumer's credential list): this is a gateway-level table that
313 /// can map certificates independent of the consumer credential
314 /// registry. Absent (the default): mTLS certificates are matched
315 /// only through consumers' `mtls` credentials (the DW-019 path).
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub mtls_consumer_mapping: Option<MtlsConsumerMapping>,
318 /// Identity-forwarding headers (DW-035): when mTLS client auth is
319 /// used, the gateway forwards client certificate metadata to the
320 /// upstream as `X-Client-Cert-*` headers. These headers are
321 /// GATEWAY-SET: any inbound headers with these names are STRIPPED
322 /// from the client request before the gateway adds its own
323 /// (spoofing prevention — the "without headers spoofing" part of
324 /// the done-when). Absent (the default): no `X-Client-Cert-*`
325 /// headers are forwarded.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub mtls_forward_headers: Option<MtlsForwardHeaders>,
328 /// Enterprise license configuration (DW-032). Absent (the default):
329 /// the gateway runs in OSS mode — all features available are
330 /// OSS-only and no license verification occurs. When present, the
331 /// gateway verifies the license file at startup (if compiled with
332 /// the `ent` cargo feature) and gates enterprise features behind
333 /// the license's feature claims. The public key is NOT configured
334 /// here — it comes from the `DWARA_LICENSE_PUBLIC_KEY` env var (or
335 /// the compiled-in development key when unset), never
336 /// user-configurable, so an operator cannot substitute their own
337 /// key to forge a license. When the `ent` feature is NOT compiled
338 /// in, the block is accepted but inert (the gate is always
339 /// `LicenseGate::none()`).
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub license: Option<LicenseConfig>,
342 /// OIDC providers (DW-034): trusted OpenID Connect issuers whose
343 /// Bearer tokens are validated by token introspection (RFC 7662)
344 /// rather than local JWT signature verification. Each provider
345 /// independently introspects `Authorization: Bearer` tokens that did
346 /// not verify against any JWT provider (DW-019) and maps the
347 /// introspection result to a consumer via the provider's explicit
348 /// `consumer` binding. The gateway also supports the
349 /// authorization-code + PKCE flow (acting as an OIDC relying party),
350 /// token exchange (RFC 8693), and token revocation (RFC 7009). Empty
351 /// (the default): the gateway does not introspect Bearer tokens and
352 /// OIDC is inert. See the `security::oidc` module docs for the full
353 /// flow, caching, and fail-open/fail-closed posture.
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub oidc_providers: Vec<OidcProvider>,
356 /// Distributed Redis rate limiter (DW-031). Absent (the default):
357 /// the local in-memory GCRA limiter is used (one limit per
358 /// process). When present, the `ent` cargo feature is compiled in,
359 /// AND a valid license with the `redis_rate_limiter` feature claim
360 /// is loaded, the gateway uses a Redis-backed GCRA limiter so two
361 /// or more instances share one rate limit. The same GCRA algorithm
362 /// runs, but the bucket state lives in Redis and is updated
363 /// atomically via a Lua script in a single round-trip. When the
364 /// `ent` feature is NOT compiled in, or the license lacks the
365 /// claim, the block is accepted but inert (the local limiter is
366 /// used and a warning is logged). When Redis is unreachable,
367 /// `fail_open` (default true) lets requests through; `false`
368 /// rejects with 429.
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub redis_rate_limiter: Option<RedisRateLimiterConfig>,
371 /// Distributed Redis-backed consumer request quotas (DW-155, ent
372 /// feature). Absent (the default): the local SQLite-backed quota
373 /// checker is used (one counter per instance, so a fleet of N
374 /// instances enforces N x the configured cap). When present, the
375 /// `ent` cargo feature is compiled in, AND a valid license with the
376 /// `redis_quotas` feature claim is loaded, the gateway uses a
377 /// Redis-backed quota checker so two or more instances share one
378 /// counter per (consumer, budget, window) — a fleet of N instances
379 /// enforces the CONFIGURED cap. The same daily/monthly budget
380 /// semantics run, but the counters live in Redis and are updated
381 /// atomically via a Lua script in a single round-trip. When the
382 /// `ent` feature is NOT compiled in, or the license lacks the
383 /// claim, the block is accepted but inert (the local SQLite checker
384 /// is used and a warning is logged). When Redis is unreachable,
385 /// `fail_open` (default true) lets requests through; `false`
386 /// rejects with 429.
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub redis_quotas: Option<RedisQuotaConfig>,
389 /// Distributed Redis-backed response cache (SCALE-04, #183, ent
390 /// feature). Absent (the default): the local in-memory moka cache
391 /// is used (one cache per instance, so a fleet of N instances has
392 /// N x cold caches). When present, the `ent` cargo feature is
393 /// compiled in, AND a valid license with the `redis_cache` feature
394 /// claim is loaded, the gateway uses a Redis-backed response cache
395 /// so two or more instances share one cache — fleet-wide hit
396 /// ratios. A local moka tier can front Redis (two-tier with Pub/Sub
397 /// invalidation) or be disabled for a pure Redis cache. When the
398 /// `ent` feature is NOT compiled in, or the license lacks the
399 /// claim, the block is accepted but inert (the local moka cache is
400 /// used and a one-line notice is logged at startup).
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub redis_cache: Option<RedisCacheConfig>,
403 /// Config convergence (DW-054, enterprise feature). Absent (the
404 /// default): each gateway instance serves only its local config
405 /// generation and never watches remote instances. When present,
406 /// the `ent` cargo feature is compiled in, AND a valid license
407 /// with the `config_convergence` feature claim is loaded, the
408 /// gateway publishes its config generation to a shared backend
409 /// (Redis in v1; etcd/Consul are deferred behind the
410 /// `ConfigConvergenceBackend` trait) and polls for generations
411 /// published by other instances, converging to the highest
412 /// generation within the poll interval. A drift report is emitted
413 /// at `drift_check_interval_ms` when instances diverge. When the
414 /// `ent` feature is NOT compiled in, or the license lacks the
415 /// claim, the block is accepted but inert (the local file watcher
416 /// runs alone) and a one-line notice is logged at startup.
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub config_convergence: Option<ConfigConvergenceConfig>,
419 /// Proxy-wasm plugin definitions (DW-055). Each plugin is a .wasm
420 /// module loaded and compiled with every config generation
421 /// (checksum-keyed hot-swap) and run on the request pipeline
422 /// phases it declares. Routes reference plugins by name via their
423 /// `plugins` field; the dataplane dispatches them through the
424 /// unified plugin chain (DW-157). A plugin whose .wasm cannot be
425 /// read or compiled is marked crashed at load time (logged) and
426 /// routes referencing it answer 500 fail-closed.
427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub plugins: Vec<PluginConfig>,
429 /// SCALE-12 (#192): remote/signed plugin registry configuration.
430 /// When present, plugin `source.url` references are resolved
431 /// against the registry's base URL and signatures are verified
432 /// against the registry's pinned public keys. See
433 /// [`PluginRegistryConfig`].
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub plugin_registry: Option<PluginRegistryConfig>,
436 /// SCALE-12 (#191): global filter-chain ordering and dry-run
437 /// configuration. Per-route `filter_chain` overrides take
438 /// precedence. See [`FilterChainConfig`].
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub filter_chain: Option<FilterChainConfig>,
441 /// The AI provider-adapter pack (DW-075): the provider pool and the
442 /// model alias table behind every `ai` route action. Absent (the
443 /// default): no AI surface — an `ai` route action is rejected by
444 /// validation. When present, clients send OpenAI chat-completions
445 /// shaped requests to an `ai` route and the gateway translates to
446 /// the target provider's wire format (OpenAI, Anthropic, Gemini),
447 /// normalizing responses (and streaming deltas) back to the OpenAI
448 /// shape. See [`ai::AiConfig`] and the `ai` domain docs.
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub ai: Option<ai::AiConfig>,
451 /// Fleet operations (DW-098, enterprise feature). Absent (the
452 /// default): no fleet skew policy is enforced — the controller
453 /// accepts edges regardless of version. When present, the `ent`
454 /// cargo feature is compiled in, AND a valid license with the
455 /// `fleet_operations` feature claim is loaded, the controller
456 /// checks each connecting edge's version against the skew policy
457 /// and the admin API exposes `GET /fleet/skew` (per-edge skew
458 /// status) and `GET /fleet/status` (fleet-wide convergence +
459 /// version distribution). When the `ent` feature is NOT compiled
460 /// in, or the license lacks the claim, the block is accepted but
461 /// inert (the controller accepts all edges).
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub fleet: Option<FleetConfig>,
464 /// API lifecycle management (DW-110): the developer portal
465 /// (auto-generated from OpenAPI specs), environment profiles
466 /// (dev/staging/prod config overlays), and the API journey
467 /// recorder (request flow visualization for debugging). Absent
468 /// (the default): no lifecycle surface. The portal serving, profile
469 /// overlay application, and journey recording are scaffolded: the
470 /// block is accepted in every build and is inert until the runtime
471 /// wiring lands (validation
472 /// warns). See [`LifecycleConfig`].
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub lifecycle: Option<LifecycleConfig>,
475 /// Service mesh mode (DW-107, enterprise feature). Absent (the
476 /// default): the gateway runs as a regular reverse proxy. When
477 /// present, the `mesh` cargo feature is compiled in, AND a valid
478 /// license with the `service_mesh` feature claim is loaded, the
479 /// gateway runs as a sidecar in each pod: an init container
480 /// configures iptables/TPROXY redirects so all traffic to and from
481 /// the local application flows through the sidecar, which
482 /// terminates mTLS (inbound) and wraps mTLS (outbound) using
483 /// SPIFFE/SPIRE X.509 SVIDs fetched from the SPIRE Workload API.
484 /// When the `mesh` feature is NOT compiled in, or the license
485 /// lacks the claim, the block is accepted but inert (validation
486 /// warns, no sidecar listeners or SPIFFE client are wired). Ent-
487 /// only: validation warns when mesh is configured without the
488 /// `ent` feature. See [`mesh::MeshConfig`] and the `mesh` domain
489 /// module docs.
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub mesh: Option<mesh::MeshConfig>,
492 /// SSRF egress filter (SEC-13): when enabled, outbound connections
493 /// from webhook deliveries and OPA callouts are checked against a
494 /// deny set of private/loopback/link-local/metadata IP ranges.
495 /// An optional allowlist exempts specific CIDRs. Default disabled
496 /// (the v1 posture trusts operator-configured endpoints). See
497 /// [`ssrf::SsrfFilterConfig`] and [`ssrf::SsrfFilter`].
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub ssrf_filter: Option<ssrf::SsrfFilterConfig>,
500}
501
502/// Bounded admission queue config (DW-053, `gateway.admission_queue`).
503///
504/// When enabled and the concurrency cap is full, requests wait for a
505/// permit up to `queue_timeout_ms` instead of being immediately shed.
506/// The queue depth is bounded by `max_queue_size`; once full, further
507/// requests are shed immediately with 503 (the `queue_full` outcome).
508/// Per-priority splitting (`per_priority: true`, the default) reserves
509/// a fraction of queue capacity for high-priority requests so they are
510/// not starved by a low-priority queue fill.
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
512#[serde(deny_unknown_fields)]
513pub struct AdmissionQueue {
514 /// Master switch. Default false: the queue is inert and the
515 /// gateway sheds immediately on cap saturation (DW-016 behavior).
516 /// Requires `max_concurrent_requests` (validation rejects an
517 /// enabled queue on an uncapped gateway).
518 #[serde(default, skip_serializing_if = "is_false")]
519 pub enabled: bool,
520 /// Total number of requests that may wait for a permit across all
521 /// priority classes (1..=10000). Once the queue is at capacity,
522 /// further over-cap requests are shed immediately with 503
523 /// (the `queue_full` outcome).
524 pub max_queue_size: u32,
525 /// Maximum time a request waits for a permit, in milliseconds
526 /// (1..=10000). If the timeout expires before a permit becomes
527 /// available, the request is shed with 503 (the `timeout`
528 /// outcome) and a `Retry-After` header.
529 pub queue_timeout_ms: u64,
530 /// Split queue capacity across priority classes (default true).
531 /// When true, a fraction of `max_queue_size` is reserved for
532 /// high-priority requests (priority >= 8) so they are not starved
533 /// by a low-priority queue fill. When false, the queue is a single
534 /// shared pool (first-come-first-served).
535 #[serde(default = "default_true", skip_serializing_if = "is_false")]
536 pub per_priority: bool,
537}
538
539fn default_true() -> bool {
540 true
541}
542
543/// Default freshness target for live sketches (DW-092): 500 ms —
544/// sub-second freshness for the in-process rolling window.
545fn default_freshness_target() -> u64 {
546 500
547}
548
549/// Default baseline window count for the insights engine (DW-092):
550/// 1440 = 24 hours of 1-minute windows (one minute-of-day seasonal
551/// cycle).
552fn default_baseline_windows() -> u64 {
553 1440
554}
555
556/// mTLS client-certificate consumer mapping (DW-035,
557/// `gateway.mtls_consumer_mapping`).
558///
559/// Maps a VERIFIED client certificate to a consumer name by its
560/// SHA-256 fingerprint (lowercase colon-separated hex of the DER) or
561/// by subject CommonName. When enabled and a client presents a
562/// certificate that chains to a listener's `client_ca_file` but
563/// matches NO entry in this table, the gateway returns 401
564/// `mtls_consumer_not_mapped` (the certificate was verified but is not
565/// a known caller). A client that presents NO certificate on a
566/// listener with this mapping enabled falls through to the other
567/// authentication families (or 401 if the route requires auth and no
568/// family resolves).
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
570#[serde(deny_unknown_fields)]
571pub struct MtlsConsumerMapping {
572 /// Master switch. Default false: the mapping is inert and mTLS
573 /// certificates are matched only through consumers' `mtls`
574 /// credentials (the DW-019 path).
575 #[serde(default, skip_serializing_if = "is_false")]
576 pub enabled: bool,
577 /// Fingerprint-to-consumer entries. Each `fingerprint` is the
578 /// SHA-256 of the client certificate DER as lowercase
579 /// colon-separated hex (e.g. `ab:cd:ef:...`). The fingerprint is
580 /// the EXACT bytes the gateway computes from the presented
581 /// certificate, so a re-issued certificate needs a new entry.
582 #[serde(default, skip_serializing_if = "Vec::is_empty")]
583 pub consumers: Vec<MtlsFingerprintMapping>,
584 /// Subject-CN-to-consumer mapping (alternative to `consumers`):
585 /// maps the certificate's subject CommonName to a consumer name.
586 /// Binding by subject CN survives certificate re-issue under the
587 /// same CN; the certificate must still chain to a listener's
588 /// `client_ca_file`. Keys are exact, case-sensitive CN strings.
589 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
590 pub subject_cn_mapping: std::collections::BTreeMap<String, String>,
591}
592
593/// One fingerprint-to-consumer mapping entry (DW-035,
594/// `mtls_consumer_mapping.consumers[]`).
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
596#[serde(deny_unknown_fields)]
597pub struct MtlsFingerprintMapping {
598 /// SHA-256 fingerprint of the client certificate DER as lowercase
599 /// colon-separated hex (e.g. `ab:cd:ef:...`). Validated to be
600 /// 64 hex digits with optional colon separators.
601 pub fingerprint: String,
602 /// The consumer name this certificate resolves to. Must reference
603 /// a consumer in the `consumers` list (validation rejects an
604 /// unknown name).
605 pub consumer: String,
606}
607
608/// Identity-forwarding headers for mTLS client auth (DW-035,
609/// `gateway.mtls_forward_headers`).
610///
611/// When enabled, the gateway adds `X-Client-Cert-*` headers to the
612/// upstream request carrying metadata from the VERIFIED client
613/// certificate. These headers are GATEWAY-SET: any inbound headers
614/// whose names match the configured prefix are STRIPPED from the
615/// client request before the gateway adds its own, so a client cannot
616/// spoof certificate identity upstream.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
618#[serde(deny_unknown_fields)]
619pub struct MtlsForwardHeaders {
620 /// Master switch. Default false: no `X-Client-Cert-*` headers are
621 /// forwarded.
622 #[serde(default, skip_serializing_if = "is_false")]
623 pub enabled: bool,
624 /// Header name prefix (default `X-Client-Cert`). The gateway adds
625 /// `<prefix>-Fingerprint`, `<prefix>-Subject-CN`, `<prefix>-Issuer-CN`,
626 /// and `<prefix>-Not-After`. Inbound headers starting with this
627 /// prefix (case-insensitive) are stripped before injection.
628 #[serde(
629 default = "default_mtls_forward_prefix",
630 skip_serializing_if = "is_default_mtls_forward_prefix"
631 )]
632 pub prefix: String,
633}
634
635fn default_mtls_forward_prefix() -> String {
636 "X-Client-Cert".to_string()
637}
638
639fn is_default_mtls_forward_prefix(p: &str) -> bool {
640 p == "X-Client-Cert"
641}
642
643/// Enterprise license configuration (DW-032, `gateway.license`).
644///
645/// When present, the gateway verifies the license file at startup (if
646/// compiled with the `ent` cargo feature) and gates enterprise features
647/// behind the license's feature claims. The public key is NOT in this
648/// block — it comes from the `DWARA_LICENSE_PUBLIC_KEY` env var (or the
649/// compiled-in development key when unset), never user-configurable, so
650/// an operator cannot substitute their own key to forge a license. When
651/// the `ent` feature is NOT compiled in, the block is accepted but inert
652/// (the gate is always `LicenseGate::none()`).
653#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
654#[serde(deny_unknown_fields)]
655pub struct LicenseConfig {
656 /// Filesystem path of the license file (JSON: claims + Ed25519
657 /// signature). The file is read and verified at startup and on
658 /// every config reload. A missing file refuses to start at cold
659 /// start (exit 1); on reload, a missing file keeps the running
660 /// generation serving.
661 pub file: String,
662 /// Grace period in days after the license expires before the gate
663 /// degrades to OSS (default 7; validated to 0..=30). During the
664 /// grace window enterprise features still work and a warning is
665 /// logged. 0 means no grace (immediate degradation on expiry).
666 #[serde(
667 default = "default_license_grace_period_days",
668 skip_serializing_if = "is_default_license_grace_period_days"
669 )]
670 pub grace_period_days: u32,
671}
672
673fn default_license_grace_period_days() -> u32 {
674 crate::config::limits::DEFAULT_LICENSE_GRACE_PERIOD_DAYS
675}
676
677fn is_default_license_grace_period_days(v: &u32) -> bool {
678 *v == crate::config::limits::DEFAULT_LICENSE_GRACE_PERIOD_DAYS
679}
680
681/// Redis deployment topology (SCALE-11, #248). Controls how the
682/// gateway connects to Redis for the distributed rate limiter,
683/// quotas, and shared cache. The default `single` mode uses one
684/// Redis URL (the existing behavior). `sentinel` mode resolves the
685/// master through a Sentinel deployment. `cluster` mode connects
686/// to a Redis Cluster.
687#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
688#[serde(rename_all = "lowercase", deny_unknown_fields)]
689pub enum RedisTopology {
690 /// Single Redis instance (the default). Uses the `url` field
691 /// directly. No HA — if the instance is down, the fail-open
692 /// policy applies.
693 #[default]
694 Single,
695 /// Redis Sentinel: resolves the master via the Sentinel nodes
696 /// listed in `nodes`, using `master_name` to identify the
697 /// monitored instance. The gateway connects to the resolved
698 /// master via a `ConnectionManager` (automatic reconnection on
699 /// failover is handled by re-resolving on disconnect).
700 Sentinel,
701 /// Redis Cluster: connects to the cluster via the node URLs in
702 /// `nodes` (or `url` if `nodes` is empty). Uses the redis crate's
703 /// `ClusterClient` which handles slot mapping, MOVED/ASK
704 /// redirections, and cross-slot routing automatically. Lua
705 /// scripts use hash tags (`{key}`) to ensure all keys in a script
706 /// land on the same slot.
707 Cluster,
708}
709
710/// Redis HA and multi-region config (SCALE-11, #248). Embedded in
711/// each Redis-backed config block (rate limiter, quotas, cache) to
712/// control the connection topology and per-request timeout.
713#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
714#[serde(deny_unknown_fields)]
715pub struct RedisHaConfig {
716 /// Deployment topology (default `single`).
717 #[serde(default, skip_serializing_if = "is_default_redis_topology")]
718 pub topology: RedisTopology,
719 /// Additional node URLs for `sentinel` or `cluster` mode. For
720 /// `sentinel`, these are the Sentinel node URLs (e.g.
721 /// `redis://sentinel-1:26379`). For `cluster`, these are the
722 /// cluster node URLs (any subset of the cluster's nodes; the
723 /// client discovers the rest). When empty, the `url` field on
724 /// the parent config is used as the sole node.
725 #[serde(default, skip_serializing_if = "Vec::is_empty")]
726 pub nodes: Vec<String>,
727 /// The master name for `sentinel` mode (the `master_name` in
728 /// Sentinel config). Required when `topology = sentinel`; ignored
729 /// otherwise.
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub master_name: Option<String>,
732 /// Per-request timeout in milliseconds (default 500; validated to
733 /// 50..=30 000). Applied to each Redis command (Lua script calls,
734 /// GET/SET for cache). A timed-out command is treated as a backend
735 /// error and the fail-open policy applies. 0 means no per-request
736 /// timeout (use the connection's default).
737 #[serde(
738 default = "default_redis_request_timeout_ms",
739 skip_serializing_if = "is_default_redis_request_timeout_ms"
740 )]
741 pub request_timeout_ms: u64,
742}
743
744impl Default for RedisHaConfig {
745 fn default() -> Self {
746 RedisHaConfig {
747 topology: RedisTopology::Single,
748 nodes: Vec::new(),
749 master_name: None,
750 request_timeout_ms: default_redis_request_timeout_ms(),
751 }
752 }
753}
754
755fn is_default_redis_topology(t: &RedisTopology) -> bool {
756 *t == RedisTopology::default()
757}
758
759fn default_redis_request_timeout_ms() -> u64 {
760 500
761}
762
763fn is_default_redis_request_timeout_ms(v: &u64) -> bool {
764 *v == default_redis_request_timeout_ms()
765}
766
767/// Distributed Redis-backed GCRA rate limiter config (DW-031,
768/// `gateway.redis_rate_limiter`).
769///
770/// When present and the `ent` cargo feature is compiled in AND a valid
771/// license with the `redis_rate_limiter` feature claim is loaded, the gateway
772/// uses a Redis-backed GCRA rate limiter instead of the local in-memory
773/// one — so two or more gateway instances share one rate limit per key. The
774/// same stacked-window GCRA semantics run, but the per-key TAT lives in
775/// Redis and is updated atomically via a Lua script in a single round-trip.
776/// When the `ent` feature is NOT compiled in, or the license lacks the
777/// claim, the block is accepted but inert (the local GCRA limiter is used).
778/// When Redis is unreachable, `fail_open` (default true) lets requests
779/// through; set it to false to reject with 429 instead.
780#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
781#[serde(deny_unknown_fields)]
782pub struct RedisRateLimiterConfig {
783 /// Redis connection URL (e.g. `redis://127.0.0.1:6379` or
784 /// `redis-cluster://...`). The connection is established once at
785 /// startup and pooled via a multiplexed `ConnectionManager`.
786 pub url: String,
787 /// Fail-open behavior when Redis is unreachable (default true):
788 /// `true` allows the request (no rate limiting); `false` rejects
789 /// with 429. Fail-open is the safer default for availability — a
790 /// Redis outage should not take down the gateway — but operators
791 /// who need hard limits can set this to `false`.
792 #[serde(default = "default_true", skip_serializing_if = "is_true")]
793 pub fail_open: bool,
794 /// Prefix for rate-limit keys in Redis (default `dwara:rl:`). Each
795 /// rule's per-key bucket is stored as `{prefix}{key}:{window_index}`.
796 #[serde(
797 default = "default_redis_key_prefix",
798 skip_serializing_if = "is_default_redis_key_prefix"
799 )]
800 pub key_prefix: String,
801 /// Connection timeout in milliseconds (default 1000; validated to
802 /// 100..=30 000). The timeout applies to the initial connection
803 /// establishment at startup, not to per-request Lua script calls
804 /// (those use the pooled connection's own timeout).
805 #[serde(
806 default = "default_redis_connection_timeout_ms",
807 skip_serializing_if = "is_default_redis_connection_timeout_ms"
808 )]
809 pub connection_timeout_ms: u64,
810 /// TTL for rate-limit keys in Redis, in seconds (default 3600).
811 /// Stale keys auto-expire so a key that stops receiving traffic
812 /// does not accumulate forever. The per-window GCRA Lua script
813 /// also sets an EXPIRE based on the burst tolerance; this value is
814 /// a floor that ensures cleanup even for long-burst windows.
815 #[serde(
816 default = "default_redis_key_ttl_s",
817 skip_serializing_if = "is_default_redis_key_ttl_s"
818 )]
819 pub key_ttl_s: u64,
820 /// HA and topology config (SCALE-11, #248). Controls whether the
821 /// gateway connects to a single Redis, a Sentinel deployment, or a
822 /// Redis Cluster. Defaults to single-instance mode (the existing
823 /// behavior).
824 #[serde(default, skip_serializing_if = "is_default_redis_ha")]
825 pub ha: RedisHaConfig,
826}
827
828/// Sentinel: returns true when the `RedisHaConfig` is at its default
829/// (single topology, no nodes, no master name, default request
830/// timeout). Used by `skip_serializing_if` to keep the config output
831/// clean for the common single-Redis case.
832fn is_default_redis_ha(ha: &RedisHaConfig) -> bool {
833 ha == &RedisHaConfig::default()
834}
835
836/// Distributed Redis-backed consumer request quotas config (DW-155,
837/// `gateway.redis_quotas`).
838///
839/// When present and the `ent` cargo feature is compiled in AND a valid
840/// license with the `redis_quotas` feature claim is loaded, the gateway
841/// uses a Redis-backed quota checker instead of the local SQLite-backed
842/// one — so two or more gateway instances share one quota counter per
843/// (consumer, budget, window). The same daily/monthly budget semantics
844/// run, but the counters live in Redis and are updated atomically via a
845/// Lua script in a single round-trip. When the `ent` feature is NOT
846/// compiled in, or the license lacks the claim, the block is accepted
847/// but inert (the local SQLite checker is used). When Redis is
848/// unreachable, `fail_open` (default true) lets requests through; set
849/// it to false to reject with 429 instead.
850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
851#[serde(deny_unknown_fields)]
852pub struct RedisQuotaConfig {
853 /// Redis connection URL (e.g. `redis://127.0.0.1:6379` or
854 /// `redis-cluster://...`). The connection is established once at
855 /// startup and pooled via a multiplexed `ConnectionManager`.
856 /// May be the same Redis instance as the rate limiter
857 /// (`redis_rate_limiter.url`) — the key prefixes are distinct.
858 pub url: String,
859 /// Fail-open behavior when Redis is unreachable (default true):
860 /// `true` allows the request (no quota enforcement); `false`
861 /// rejects with 429. Fail-open is the safer default for
862 /// availability — a Redis outage should not take down the gateway
863 /// — but operators who need hard quotas can set this to `false`.
864 #[serde(default = "default_true", skip_serializing_if = "is_true")]
865 pub fail_open: bool,
866 /// Prefix for quota keys in Redis (default `dwara:quota:`). Each
867 /// (consumer, budget, window) counter is stored as
868 /// `{prefix}{consumer_id}:{budget}:{window_start}`.
869 #[serde(
870 default = "default_redis_quota_key_prefix",
871 skip_serializing_if = "is_default_redis_quota_key_prefix"
872 )]
873 pub key_prefix: String,
874 /// Connection timeout in milliseconds (default 1000; validated to
875 /// 100..=30 000). The timeout applies to the initial connection
876 /// establishment at startup, not to per-request Lua script calls.
877 #[serde(
878 default = "default_redis_connection_timeout_ms",
879 skip_serializing_if = "is_default_redis_connection_timeout_ms"
880 )]
881 pub connection_timeout_ms: u64,
882 /// HA and topology config (SCALE-11, #248). Same shape as
883 /// `redis_rate_limiter.ha`.
884 #[serde(default, skip_serializing_if = "is_default_redis_ha")]
885 pub ha: RedisHaConfig,
886}
887
888fn default_redis_quota_key_prefix() -> String {
889 "dwara:quota:".to_string()
890}
891
892fn is_default_redis_quota_key_prefix(p: &str) -> bool {
893 p == "dwara:quota:"
894}
895
896fn default_redis_key_prefix() -> String {
897 "dwara:rl:".to_string()
898}
899
900fn is_default_redis_key_prefix(p: &str) -> bool {
901 p == "dwara:rl:"
902}
903
904fn default_redis_connection_timeout_ms() -> u64 {
905 1000
906}
907
908fn is_default_redis_connection_timeout_ms(v: &u64) -> bool {
909 *v == 1000
910}
911
912fn default_redis_key_ttl_s() -> u64 {
913 3600
914}
915
916fn is_default_redis_key_ttl_s(v: &u64) -> bool {
917 *v == 3600
918}
919
920/// Distributed Redis-backed response cache config (SCALE-04, #183,
921/// `gateway.redis_cache`, ent feature).
922///
923/// When present and the `ent` cargo feature is compiled in AND a valid
924/// license with the `redis_cache` feature claim is loaded, the gateway
925/// uses a Redis-backed response cache instead of (or in addition to)
926/// the local in-memory moka cache — so two or more gateway instances
927/// share one response cache. Fleet-wide cache hit ratios replace N x
928/// per-instance cold caches, particularly impactful for canary and
929/// rolling-deploy scenarios where new instances start empty. When the
930/// `ent` feature is NOT compiled in, or the license lacks the claim,
931/// the block is accepted but inert (the local moka cache is used and a
932/// one-line notice is logged at startup).
933#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
934#[serde(deny_unknown_fields)]
935pub struct RedisCacheConfig {
936 /// Redis connection URL (e.g. `redis://127.0.0.1:6379` or
937 /// `redis-cluster://...`). The connection is established once at
938 /// startup with the configured timeout.
939 pub url: String,
940 /// Prefix for cache keys in Redis (default `dwara:cache:`). Each
941 /// cached response is stored as `{prefix}{key}`.
942 #[serde(
943 default = "default_redis_cache_key_prefix",
944 skip_serializing_if = "is_default_redis_cache_key_prefix"
945 )]
946 pub key_prefix: String,
947 /// Connection timeout in milliseconds (default 1000; validated to
948 /// 100..=30 000).
949 #[serde(
950 default = "default_redis_connection_timeout_ms",
951 skip_serializing_if = "is_default_redis_connection_timeout_ms"
952 )]
953 pub connection_timeout_ms: u64,
954 /// Whether to keep a local fronting cache (two-tier: local moka +
955 /// Redis). Default true: the local cache fronts Redis for hot keys,
956 /// and Redis Pub/Sub invalidation evicts local entries when another
957 /// instance purges a key. Set to false for a pure Redis cache (no
958 /// local tier).
959 #[serde(default = "default_true", skip_serializing_if = "is_true")]
960 pub local_tier: bool,
961 /// HA and topology config (SCALE-11, #248). Same shape as
962 /// `redis_rate_limiter.ha`.
963 #[serde(default, skip_serializing_if = "is_default_redis_ha")]
964 pub ha: RedisHaConfig,
965}
966
967fn default_redis_cache_key_prefix() -> String {
968 "dwara:cache:".to_string()
969}
970
971fn is_default_redis_cache_key_prefix(p: &str) -> bool {
972 p == "dwara:cache:"
973}
974
975/// Config convergence config (DW-054, `gateway.config_convergence`).
976///
977/// When present and the `ent` cargo feature is compiled in AND a valid
978/// license with the `config_convergence` feature claim is loaded, the
979/// gateway publishes its config generation to a shared backend and
980/// polls for generations published by other instances, converging to
981/// the highest generation within `poll_interval_ms`. The backend is
982/// the seam for etcd/Consul (deferred); v1 ships a Redis
983/// implementation. The coordinator runs ALONGSIDE the local file
984/// watcher: a local file change triggers a local reload plus a publish
985/// to the backend; a remote change (detected by polling) triggers a
986/// remote reload. Both go through `compile_and_publish`. When the
987/// `ent` feature is NOT compiled in, or the license lacks the claim,
988/// the block is accepted but inert (the local file watcher runs
989/// alone).
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
991#[serde(deny_unknown_fields)]
992pub struct ConfigConvergenceConfig {
993 /// Master switch. Default false: convergence is inert and the
994 /// gateway serves only its local config (the local file watcher
995 /// still runs). When true (and licensed), the coordinator
996 /// publishes and polls.
997 #[serde(default, skip_serializing_if = "is_false")]
998 pub enabled: bool,
999 /// Convergence backend type. v1 ships `"redis"`; etcd and Consul
1000 /// are deferred behind the `ConfigConvergenceBackend` trait (the
1001 /// value is the seam a future backend plugs into). Validation
1002 /// rejects any value other than `"redis"`.
1003 pub backend: String,
1004 /// Redis connection URL (e.g. `redis://127.0.0.1:6379`), required
1005 /// when `backend = "redis"`. The connection is established once at
1006 /// startup and pooled via a multiplexed `ConnectionManager`.
1007 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub redis_url: Option<String>,
1009 /// Prefix for convergence keys in Redis (default `dwara:config`).
1010 /// Instance generations live under `{prefix}:instances` and config
1011 /// bodies under `{prefix}:config`.
1012 #[serde(
1013 default = "default_config_convergence_key_prefix",
1014 skip_serializing_if = "is_default_config_convergence_key_prefix"
1015 )]
1016 pub key_prefix: String,
1017 /// How often to poll the backend for generations published by
1018 /// other instances, in milliseconds (default 1000; validated to
1019 /// 100..=60 000). The done-when targets sub-second convergence;
1020 /// lower values converge faster at the cost of backend load.
1021 #[serde(
1022 default = "default_config_convergence_poll_interval_ms",
1023 skip_serializing_if = "is_default_config_convergence_poll_interval_ms"
1024 )]
1025 pub poll_interval_ms: u64,
1026 /// How often to read all instances' generations and report drift,
1027 /// in milliseconds (default 5000; validated to 1 000..=300 000).
1028 /// Drift is reported (structured log + metric) when instances
1029 /// serve different config hashes.
1030 #[serde(
1031 default = "default_config_convergence_drift_check_interval_ms",
1032 skip_serializing_if = "is_default_config_convergence_drift_check_interval_ms"
1033 )]
1034 pub drift_check_interval_ms: u64,
1035 /// Fail-open behavior when the convergence backend is unreachable
1036 /// (default true): `true` keeps serving the local config
1037 /// generation (convergence pauses until the backend recovers);
1038 /// `false` refuses to start at cold start when the backend cannot
1039 /// be reached. Fail-open is the safer default for availability --
1040 /// a backend outage should not take down the gateway.
1041 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1042 pub fail_open: bool,
1043}
1044
1045fn default_config_convergence_key_prefix() -> String {
1046 "dwara:config".to_string()
1047}
1048
1049fn is_default_config_convergence_key_prefix(p: &str) -> bool {
1050 p == "dwara:config"
1051}
1052
1053fn default_config_convergence_poll_interval_ms() -> u64 {
1054 1000
1055}
1056
1057fn is_default_config_convergence_poll_interval_ms(v: &u64) -> bool {
1058 *v == 1000
1059}
1060
1061fn default_config_convergence_drift_check_interval_ms() -> u64 {
1062 5000
1063}
1064
1065fn is_default_config_convergence_drift_check_interval_ms(v: &u64) -> bool {
1066 *v == 5000
1067}
1068
1069/// Fleet operations config (DW-098, `gateway.fleet`).
1070///
1071/// Configures the version-skew policy and rolling-upgrade order for a
1072/// fleet of edge instances managed by a controller (CP/DP split,
1073/// DW-066). When present and the `ent` cargo feature is compiled in
1074/// AND a valid license with the `fleet_operations` feature claim is
1075/// loaded, the controller checks each connecting edge's version
1076/// against the skew policy and the admin API exposes fleet status
1077/// endpoints. When the `ent` feature is NOT compiled in, or the
1078/// license lacks the claim, the block is accepted but inert.
1079#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1080#[serde(deny_unknown_fields)]
1081pub struct FleetConfig {
1082 /// Master switch. Default false: fleet operations are inert and
1083 /// the controller accepts edges regardless of version. When true
1084 /// (and licensed), the skew policy is enforced.
1085 #[serde(default, skip_serializing_if = "is_false")]
1086 pub enabled: bool,
1087 /// The rolling-upgrade and version-skew policy.
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub upgrade: Option<FleetUpgradeConfig>,
1090 /// The controller's own version (the reference version edges are
1091 /// checked against). When absent, the gateway binary's
1092 /// `CARGO_PKG_VERSION` is used.
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub controller_version: Option<String>,
1095 /// How long an edge can go without being seen (no ack, no
1096 /// re-registration) before it is flagged stale, in seconds
1097 /// (default 60; validated to 5..=3600). Stale edges are reported
1098 /// by `GET /fleet/status` but are NOT automatically removed — the
1099 /// operator decides whether to drain or wait.
1100 #[serde(
1101 default = "default_fleet_stale_timeout_secs",
1102 skip_serializing_if = "is_default_fleet_stale_timeout_secs"
1103 )]
1104 pub stale_timeout_secs: u64,
1105}
1106
1107/// API lifecycle management config (DW-110, `gateway.lifecycle`).
1108///
1109/// Three sub-concerns, each additive and independently enabled:
1110///
1111/// - `portal` -- the developer portal: a read-only static HTML page
1112/// auto-generated from the configured OpenAPI spec sources, served
1113/// at a reserved path.
1114/// - `profiles` -- environment profiles: dev/staging/prod config
1115/// overlays. The profile is selected via the `DWARA_PROFILE` env
1116/// var; the selected profile's patch is merged onto the base config
1117/// at load time.
1118/// - `journey` -- the API journey recorder: records the request flow
1119/// through the gateway as a JSON document for debugging, stored via
1120/// the existing analytics raw table.
1121///
1122/// The block is accepted in every build and is inert until the
1123/// runtime wiring lands (validation warns).
1124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1125#[serde(deny_unknown_fields)]
1126pub struct LifecycleConfig {
1127 /// The developer portal config. Absent (the default): no portal.
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1129 pub portal: Option<LifecyclePortalConfig>,
1130 /// Environment profile overlays. Absent (the default): no
1131 /// overlays; the base config is used as-is.
1132 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub profiles: Option<LifecycleProfilesConfig>,
1134 /// The journey recorder config. Absent (the default): no
1135 /// journeys are recorded.
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub journey: Option<LifecycleJourneyConfig>,
1138}
1139
1140/// Developer portal config (DW-110, `gateway.lifecycle.portal`).
1141///
1142/// The portal is a read-only static HTML page that aggregates the
1143/// configured OpenAPI specs. `enabled` defaults to false: even with
1144/// the `api_lifecycle` feature compiled in, the portal is inert until
1145/// `enabled: true`.
1146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1147#[serde(deny_unknown_fields)]
1148pub struct LifecyclePortalConfig {
1149 /// Master switch. Default false: the portal is not served.
1150 #[serde(default, skip_serializing_if = "is_false")]
1151 pub enabled: bool,
1152 /// The URL path the portal is served at (a reserved HTTP path,
1153 /// shadowing any configured route, like `/healthz`). Must start
1154 /// with `/`. Default `/portal`.
1155 #[serde(
1156 default = "default_lifecycle_portal_path",
1157 skip_serializing_if = "is_default_lifecycle_portal_path"
1158 )]
1159 pub path: String,
1160 /// The OpenAPI spec sources to aggregate. Each source is either a
1161 /// `file` path or a `url` (an upstream `/openapi.json` endpoint).
1162 /// Empty (the default): the portal renders an empty listing.
1163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1164 pub specs: Vec<LifecyclePortalSpec>,
1165}
1166
1167fn default_lifecycle_portal_path() -> String {
1168 "/portal".to_string()
1169}
1170
1171fn is_default_lifecycle_portal_path(p: &String) -> bool {
1172 p == "/portal"
1173}
1174
1175/// One OpenAPI spec source for the developer portal (DW-110,
1176/// `gateway.lifecycle.portal.specs[]`).
1177///
1178/// Either a `file` path (read from disk) or a `url` (an upstream
1179/// `/openapi.json` endpoint, fetched at render time). Exactly one of
1180/// `file`/`url` must be set; validation rejects a spec with neither
1181/// or both.
1182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1183#[serde(deny_unknown_fields)]
1184pub struct LifecyclePortalSpec {
1185 /// A file path to an OpenAPI 3.x JSON document on disk. The file
1186 /// must exist and be readable (validation checks this at config
1187 /// publish time).
1188 #[serde(skip_serializing_if = "Option::is_none")]
1189 pub file: Option<String>,
1190 /// An upstream `/openapi.json` endpoint URL (http or https). The
1191 /// portal fetches the spec from this URL at render time. NOT
1192 /// fetched at validation time (the upstream may be down at publish
1193 /// time but up at render time).
1194 #[serde(skip_serializing_if = "Option::is_none")]
1195 pub url: Option<String>,
1196 /// An optional display name for the spec. When absent, the portal
1197 /// derives the name from the spec's `info.title` (or the file name
1198 /// / URL host when the spec cannot be parsed).
1199 #[serde(skip_serializing_if = "Option::is_none")]
1200 pub name: Option<String>,
1201}
1202
1203/// Environment profile overlays config (DW-110,
1204/// `gateway.lifecycle.profiles`).
1205///
1206/// Carries a base config (as a YAML string) plus per-profile config
1207/// patches. The profile is selected via the `DWARA_PROFILE` env var;
1208/// the selected profile's patch is merged onto the base at load time.
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1210#[serde(deny_unknown_fields)]
1211pub struct LifecycleProfilesConfig {
1212 /// The base config, as a YAML string. This is the config that
1213 /// applies when no profile is selected (or the selected profile
1214 /// has no patch).
1215 #[serde(default, skip_serializing_if = "String::is_empty")]
1216 pub base_config: String,
1217 /// Per-profile config patches, keyed by profile name (`dev`,
1218 /// `staging`, `prod`). Each value is a YAML string merged onto the
1219 /// base config when that profile is selected.
1220 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1221 pub profile_overrides: std::collections::BTreeMap<String, String>,
1222 /// Directory of file-based profile patches (CFG-01, #180). When
1223 /// set, the selected profile's patch is loaded from
1224 /// `<profiles_dir>/<profile>.yaml` instead of the inline
1225 /// `profile_overrides` map. Relative paths resolve against the
1226 /// config file's directory. Takes precedence over
1227 /// `profile_overrides` when both are set.
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub profiles_dir: Option<String>,
1230}
1231
1232/// Journey recorder config (DW-110, `gateway.lifecycle.journey`).
1233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1234#[serde(deny_unknown_fields)]
1235pub struct LifecycleJourneyConfig {
1236 /// Master switch. Default false: no journeys are recorded.
1237 #[serde(default, skip_serializing_if = "is_false")]
1238 pub enabled: bool,
1239 /// The retention window for in-memory journeys, in hours. Must be
1240 /// greater than 0 (validation rejects 0). The durable copy's
1241 /// retention is the raw table's own retention; this cap only bounds
1242 /// the in-memory ring buffer. Default 24.
1243 #[serde(
1244 default = "default_lifecycle_journey_retention",
1245 skip_serializing_if = "is_default_lifecycle_journey_retention"
1246 )]
1247 pub retention_hours: u64,
1248}
1249
1250fn default_lifecycle_journey_retention() -> u64 {
1251 24
1252}
1253
1254fn is_default_lifecycle_journey_retention(h: &u64) -> bool {
1255 *h == 24
1256}
1257
1258/// Rolling-upgrade and version-skew policy (DW-098,
1259/// `gateway.fleet.upgrade`).
1260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1261#[serde(deny_unknown_fields)]
1262pub struct FleetUpgradeConfig {
1263 /// The version-skew policy: how tolerant the controller is of
1264 /// edges running a different version.
1265 ///
1266 /// - `"allow"` — accept any version (no skew check).
1267 /// - `"allow_minor_skew"` (default) — edges must match the
1268 /// controller's major version and be within 1 minor version
1269 /// (N-1 or N+1).
1270 /// - `"require_exact"` — edges must match the controller's version
1271 /// exactly.
1272 #[serde(default, skip_serializing_if = "is_default_version_skew_policy")]
1273 pub skew: VersionSkewPolicyConfig,
1274 /// The upgrade order: which edges to upgrade first, identified by
1275 /// label selector. Edges matching earlier entries are upgraded
1276 /// before edges matching later entries. An edge matches an entry
1277 /// when it has ALL the labels in the entry's `labels` map. Empty
1278 /// (the default) means no ordering — all edges are upgraded
1279 /// simultaneously (canary/zone-based rollouts are configured by
1280 /// adding entries here).
1281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1282 pub order: Vec<FleetUpgradeOrderEntry>,
1283 /// The maximum number of edges that may be upgraded
1284 /// simultaneously (a rolling-upgrade concurrency cap). 0 (the
1285 /// default) means no cap — all matching edges are upgraded at
1286 /// once. A typical canary rollout sets this to 1.
1287 #[serde(default, skip_serializing_if = "is_zero_u32")]
1288 pub max_concurrent: u32,
1289 /// Whether to fail the rollout if any edge reports a failed ack
1290 /// (applied: false). Default true: the controller stops pushing
1291 /// to further edges when one fails. false: the controller
1292 /// continues pushing to all matching edges regardless of
1293 /// individual failures (the operator monitors and rolls back
1294 /// manually).
1295 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1296 pub halt_on_failure: bool,
1297}
1298
1299/// A label-selector entry in the upgrade order list.
1300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1301#[serde(deny_unknown_fields)]
1302pub struct FleetUpgradeOrderEntry {
1303 /// A name for this upgrade wave (for logging and the status
1304 /// endpoint). Must be non-empty.
1305 pub name: String,
1306 /// The label selector: an edge matches this entry when it has
1307 /// ALL of these labels (key=value). Must be non-empty.
1308 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
1309 pub labels: std::collections::HashMap<String, String>,
1310}
1311
1312/// The version-skew policy, mirroring the CP/DP
1313/// `cluster_sync::VersionSkewPolicy` but living in the config schema
1314/// (so the gateway config carries it, not just the controller
1315/// runtime).
1316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1317#[serde(rename_all = "snake_case", deny_unknown_fields)]
1318pub enum VersionSkewPolicyConfig {
1319 /// Allow any version skew. The edge accepts the config regardless
1320 /// of version.
1321 Allow,
1322 /// Allow skew within a minor version (e.g. 1.2.x can talk to
1323 /// 1.3.x, but not 2.0.x). This is the default.
1324 #[default]
1325 AllowMinorSkew,
1326 /// Require exact version match. The edge rejects the config if
1327 /// its version differs from the controller's.
1328 RequireExact,
1329}
1330
1331fn default_fleet_stale_timeout_secs() -> u64 {
1332 60
1333}
1334
1335fn is_default_fleet_stale_timeout_secs(v: &u64) -> bool {
1336 *v == 60
1337}
1338
1339fn is_default_version_skew_policy(p: &VersionSkewPolicyConfig) -> bool {
1340 *p == VersionSkewPolicyConfig::AllowMinorSkew
1341}
1342
1343fn is_zero_u32(v: &u32) -> bool {
1344 *v == 0
1345}
1346
1347/// The embedded analytics store config (DW-043, `gateway.analytics`).
1348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1349#[serde(deny_unknown_fields)]
1350pub struct AnalyticsConfig {
1351 /// Filesystem path of the analytics SQLite database. The file is
1352 /// created (and migrated) on first open. A separate file from the
1353 /// state store on purpose: retention deletes and incremental
1354 /// vacuum churn must never compact the identity store.
1355 pub path: String,
1356 /// Maximum batch latency of the background writer, in
1357 /// milliseconds (default 1000; validated to [100, 60 000]). Not a
1358 /// correctness knob — the rollup grace covers multiples of this.
1359 #[serde(default, skip_serializing_if = "Option::is_none")]
1360 pub flush_ms: Option<u64>,
1361 /// Per-store retention, in milliseconds (defaults: raw 24 h, 1m
1362 /// 48 h, 5m 7 d, 1h 30 d, 1d 365 d). Validation enforces each
1363 /// floor (a granularity must outlive its own window comfortably)
1364 /// and monotonicity (no coarser table may expire before a finer
1365 /// one).
1366 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub retention: Option<AnalyticsRetention>,
1368 /// Custom dimensions (DW-043): request-header-sourced tags that
1369 /// become analytics dimensions — `x-plan` as dimension `plan`,
1370 /// for consumer tier / plan / feature-flag style cuts the fixed
1371 /// dimensions cannot express. Extracted at request completion
1372 /// capture; analytics-only (never added to the access log). At
1373 /// most 16 (cardinality guard); values are capped at 128 bytes
1374 /// (longer values are skipped for that request).
1375 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1376 pub dimensions: Vec<AnalyticsDimension>,
1377 /// Scheduled usage-report exports (DW-120): durable CSV/JSON
1378 /// dumps of the per-consumer usage statement, one file set per
1379 /// closed UTC calendar window, written by a background worker
1380 /// that reuses the analytics store's scheduling machinery. Absent
1381 /// (the default): no exports run and the admin export endpoints
1382 /// answer that exports are not configured. See the
1383 /// `analytics::exports` module docs for the schedule, the
1384 /// statement's reconciliation contract with the query API, and
1385 /// the quota-column window-alignment rule.
1386 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub exports: Option<AnalyticsExports>,
1388 /// Live in-process sketches (DW-092): sub-second-freshness
1389 /// aggregation maintained synchronously on the request-completion
1390 /// hot path (per-route rolling window with counts, errors, and
1391 /// latency samples). Never blocks the dataplane — the sketch is
1392 /// updated in place under atomics and a short mutex over a capped
1393 /// sample vector. Absent (the default): no live sketches; the
1394 /// `GET /analytics/live` endpoint answers
1395 /// `analytics_not_configured`.
1396 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub live_sketches: Option<AnalyticsLiveSketches>,
1398 /// ML traffic insights (DW-092): EWMA-based capacity forecasting
1399 /// and seasonal-baseline anomaly detection over the live sketch
1400 /// window rotations. The insights engine is in-process and
1401 /// hand-rolled (no ML crates): a minute-of-day ring buffer holds
1402 /// the seasonal pattern, an EWMA over recent windows carries the
1403 /// trend, and anomaly detection compares the current window's
1404 /// shape to the seasonal baseline. Absent (the default): no
1405 /// forecasting or anomaly detection; the
1406 /// `GET /analytics/forecast` and `GET /analytics/anomalies`
1407 /// endpoints answer `analytics_not_configured`.
1408 #[serde(default, skip_serializing_if = "Option::is_none")]
1409 pub insights: Option<AnalyticsInsights>,
1410 /// Replay time-travel debugging capture (DW-102): opt-in capture of
1411 /// request detail (redacted headers, auth identity) into the raw
1412 /// analytics table for offline replay against candidate configs.
1413 /// Absent (the default): no replay capture; the raw table's
1414 /// `request_headers_redacted` and `auth_identity` columns stay
1415 /// NULL. When present with `enabled: true`, the fire-and-forget
1416 /// analytics writer records the redacted headers and auth identity
1417 /// alongside the existing access fields, bounded by `retain_hours`.
1418 /// See [`AnalyticsReplayCapture`].
1419 #[serde(default, skip_serializing_if = "Option::is_none")]
1420 pub replay_capture: Option<AnalyticsReplayCapture>,
1421 /// REL-10 (#221): sampled degradation under channel pressure. When
1422 /// the analytics channel's fill ratio exceeds `high_watermark`,
1423 /// the offer path probabilistically drops records (keeping 1 in
1424 /// `sample_rate`) BEFORE the channel fills, so a sustained burst
1425 /// degrades to sampled telemetry rather than a hard drop cliff.
1426 /// Below `low_watermark` every record is kept. Defaults to off
1427 /// (no sampling); when enabled, `high_watermark` must be >
1428 /// `low_watermark` and both are in (0.0, 1.0].
1429 #[serde(default, skip_serializing_if = "Option::is_none")]
1430 pub sampled_degradation: Option<AnalyticsSampledDegradation>,
1431}
1432
1433/// Sampled degradation config (REL-10, #221). See
1434/// [`AnalyticsConfig::sampled_degradation`].
1435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1436#[serde(deny_unknown_fields)]
1437pub struct AnalyticsSampledDegradation {
1438 /// Channel fill ratio above which sampling kicks in. Must be in
1439 /// (0.0, 1.0) and > `low_watermark`.
1440 #[serde(default = "default_sampled_high_watermark")]
1441 pub high_watermark: f64,
1442 /// Channel fill ratio below which sampling stops (every record
1443 /// kept). Must be in (0.0, 1.0) and < `high_watermark`.
1444 #[serde(default = "default_sampled_low_watermark")]
1445 pub low_watermark: f64,
1446 /// Fraction of records to KEEP when sampling (1 in N). 1.0 keeps
1447 /// all (no sampling); 0.1 keeps 10%. Must be in (0.0, 1.0].
1448 #[serde(default = "default_sampled_keep_rate")]
1449 pub keep_rate: f64,
1450}
1451
1452fn default_sampled_high_watermark() -> f64 {
1453 0.8
1454}
1455
1456fn default_sampled_low_watermark() -> f64 {
1457 0.5
1458}
1459
1460fn default_sampled_keep_rate() -> f64 {
1461 0.1
1462}
1463
1464/// Live in-process sketches config (DW-092, `analytics.live_sketches`).
1465///
1466/// The sketches are maintained inside the embedded analytics runtime
1467/// and updated on every `record()` call — a per-route rolling window
1468/// (size = `freshness_target_ms`) with request/error counts and a capped
1469/// latency-sample vector. Percentiles are computed at snapshot time
1470/// (sort-and-pick, O(n log n) with n capped at 1000), never on the hot
1471/// path. No new dependencies: the sketches are hand-rolled with atomics
1472/// and a short mutex, the same approach as the load-generator's
1473/// histogram.
1474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1475#[serde(deny_unknown_fields)]
1476pub struct AnalyticsLiveSketches {
1477 /// Whether live sketches are maintained (default true when the
1478 /// block is present). Setting false disables the in-process
1479 /// sketches while keeping the block present (a no-op shape that
1480 /// reads as coverage).
1481 #[serde(default = "default_true", skip_serializing_if = "is_false")]
1482 pub enabled: bool,
1483 /// The rolling window size in milliseconds (default 500). Also the
1484 /// freshness target: a snapshot reflects at most this much
1485 /// staleness. Validated to [100, 5000] — below 100 is timer churn,
1486 /// above 5000 is no longer "live".
1487 #[serde(
1488 default = "default_freshness_target",
1489 skip_serializing_if = "is_default_freshness_target"
1490 )]
1491 pub freshness_target_ms: u64,
1492}
1493
1494fn is_default_freshness_target(v: &u64) -> bool {
1495 *v == 500
1496}
1497
1498/// ML traffic insights config (DW-092, `analytics.insights`).
1499///
1500/// The insights engine is hand-rolled (no ML crates): an EWMA over
1501/// recent sketch-window rotations carries the trend, a minute-of-day
1502/// ring buffer (1440 entries) holds the seasonal baseline, and anomaly
1503/// detection compares the current window's shape to the seasonal
1504/// baseline with a configurable factor. Forecasting combines the
1505/// seasonal average for the next minute with the EWMA trend
1506/// adjustment.
1507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1508#[serde(deny_unknown_fields)]
1509pub struct AnalyticsInsights {
1510 /// Whether capacity forecasting is enabled (default false). When
1511 /// true, the `GET /analytics/forecast` endpoint returns a
1512 /// prediction for the next window.
1513 #[serde(default, skip_serializing_if = "is_false")]
1514 pub forecast: bool,
1515 /// Whether seasonal-baseline anomaly detection is enabled (default
1516 /// false). When true, the `GET /analytics/anomalies` endpoint
1517 /// returns the current window's anomaly status.
1518 #[serde(default, skip_serializing_if = "is_false")]
1519 pub anomaly_baseline: bool,
1520 /// The number of recent windows retained for the EWMA trend and
1521 /// the seasonal baseline ring (default 1440 = 24 hours of 1-minute
1522 /// windows). Validated to be > 0 when forecast or anomaly_baseline
1523 /// is enabled.
1524 #[serde(
1525 default = "default_baseline_windows",
1526 skip_serializing_if = "is_default_baseline_windows"
1527 )]
1528 pub baseline_windows: u64,
1529}
1530
1531fn is_default_baseline_windows(v: &u64) -> bool {
1532 *v == 1440
1533}
1534
1535/// Replay time-travel debugging capture config (DW-102,
1536/// `analytics.replay_capture`). Opt-in capture of request detail
1537/// (redacted headers, auth identity) into the raw analytics table for
1538/// offline replay against candidate configs. The capture reuses the
1539/// existing fire-and-forget analytics writer path and the existing PII
1540/// redaction patterns (`ai::redaction`); body capture is OFF by
1541/// default (PII risk).
1542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1543#[serde(deny_unknown_fields)]
1544pub struct AnalyticsReplayCapture {
1545 /// Whether replay capture is active (default false — opt-in). When
1546 /// false the raw table's `request_headers_redacted` and
1547 /// `auth_identity` columns stay NULL even when the block is
1548 /// present (a no-op shape that reads as coverage).
1549 #[serde(default, skip_serializing_if = "is_false")]
1550 pub enabled: bool,
1551 /// Time-bounded retention for replay-captured raw rows, in hours
1552 /// (default 24). Rows older than the retention window are deleted
1553 /// by the existing raw retention sweep. Validated to be > 0.
1554 #[serde(
1555 default = "default_replay_retain_hours",
1556 skip_serializing_if = "is_default_replay_retain_hours"
1557 )]
1558 pub retain_hours: u64,
1559 /// Whether to capture redacted request headers (default true when
1560 /// the block is present). Headers are scrubbed via the existing PII
1561 /// redaction patterns before storage.
1562 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1563 pub capture_headers: bool,
1564 /// Whether to capture the request body (default false — PII risk).
1565 /// Body capture is off by default; enabling it is an explicit
1566 /// operator decision to accept the PII exposure of stored bodies
1567 /// (even redacted bodies may carry residual sensitive content).
1568 #[serde(default, skip_serializing_if = "is_false")]
1569 pub capture_body: bool,
1570}
1571
1572fn default_replay_retain_hours() -> u64 {
1573 24
1574}
1575
1576fn is_default_replay_retain_hours(v: &u64) -> bool {
1577 *v == 24
1578}
1579
1580/// Scheduled usage-report exports (DW-120, `analytics.exports`).
1581///
1582/// The exports worker ticks on the same background machinery as the
1583/// rollup cascade, closes each UTC calendar window of the configured
1584/// [`AnalyticsExportWindow`] kind once its data has settled, and
1585/// writes one file per configured format into `directory` (created on
1586/// demand; unwritable directories fail the run LOUD in the run record
1587/// and logs — never the dataplane). Parquet is deliberately NOT a v1
1588/// format: the arrow/parquet dependency weight is deferred (see the
1589/// DW-156 backlog issue).
1590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1591#[serde(deny_unknown_fields)]
1592pub struct AnalyticsExports {
1593 /// Directory the export files are written into (created on demand
1594 /// if missing). One deterministic file per (window, format); a
1595 /// re-export of the same window overwrites it (idempotent output,
1596 /// the rollup recompute philosophy).
1597 pub directory: String,
1598 /// The schedule/window kind (fixed UTC calendar windows). Default
1599 /// `daily` (midnight-to-midnight UTC — aligned with the quota
1600 /// daily budget's window, so a daily statement's quota column is
1601 /// exactly that day's counter).
1602 #[serde(default, skip_serializing_if = "Option::is_none")]
1603 pub window: Option<AnalyticsExportWindow>,
1604 /// Output formats, a subset of `csv`/`json`; omitted or empty
1605 /// means BOTH csv and json (the default). Validation rejects only
1606 /// duplicates.
1607 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1608 pub formats: Vec<AnalyticsExportFormat>,
1609}
1610
1611/// One export schedule kind (DW-120, `analytics.exports.window`).
1612/// Closed set; fixed UTC calendar windows.
1613#[derive(
1614 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1615)]
1616#[serde(rename_all = "lowercase")]
1617pub enum AnalyticsExportWindow {
1618 Hourly,
1619 Daily,
1620 Monthly,
1621}
1622
1623/// One export output format (DW-120, `analytics.exports.formats[]`).
1624/// Closed set: `csv`, `json`, `parquet_csv`.
1625///
1626/// SCALE-12 (#190): `parquet_csv` writes a CSV file with a
1627/// `.parquet.csv` extension and a JSON metadata header describing
1628/// the schema in Parquet-compatible terms. The operator converts it
1629/// to true Parquet using an external tool (e.g., `duckdb -c "COPY
1630/// input.parquet.csv TO output.parquet (FORMAT PARQUET)"`). This
1631/// avoids adding the heavy `arrow`/`parquet` crate stack while
1632/// providing a clear path to Parquet output.
1633#[derive(
1634 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1635)]
1636#[serde(rename_all = "lowercase")]
1637pub enum AnalyticsExportFormat {
1638 Csv,
1639 Json,
1640 /// SCALE-12 (#190): CSV with Parquet-compatible metadata header.
1641 /// The file extension is `.parquet.csv`.
1642 ParquetCsv,
1643}
1644
1645/// Per-granularity retention (DW-043, `analytics.retention`).
1646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1647#[serde(deny_unknown_fields)]
1648pub struct AnalyticsRetention {
1649 /// Raw access records.
1650 #[serde(default, skip_serializing_if = "Option::is_none")]
1651 pub raw_ms: Option<i64>,
1652 /// The 1-minute rollup.
1653 #[serde(default, skip_serializing_if = "Option::is_none")]
1654 pub m1_ms: Option<i64>,
1655 /// The 5-minute rollup.
1656 #[serde(default, skip_serializing_if = "Option::is_none")]
1657 pub m5_ms: Option<i64>,
1658 /// The 1-hour rollup.
1659 #[serde(default, skip_serializing_if = "Option::is_none")]
1660 pub h1_ms: Option<i64>,
1661 /// The 1-day rollup.
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub d1_ms: Option<i64>,
1664}
1665
1666/// Default per-granularity retention (ms), indexed [raw, 1m, 5m, 1h,
1667/// 1d] — a week of traffic at every granularity a week query wants,
1668/// bounded disk at the 1-minute grain (DW-043). Lives in `config`
1669/// (the lowest consuming domain) so both validation and the analytics
1670/// store read ONE definition.
1671pub const ANALYTICS_DEFAULT_RETENTION_MS: [i64; 5] = [
1672 86_400_000, // raw
1673 172_800_000, // 1m
1674 604_800_000, // 5m
1675 2_592_000_000, // 1h
1676 31_536_000_000, // 1d
1677];
1678
1679impl AnalyticsRetention {
1680 /// Resolve to the effective [raw, m1, m5, h1, d1] millisecond
1681 /// set, defaulting absent entries (and clamping each to its
1682 /// floor so programmatic builders cannot under-retain below a
1683 /// window's own lifetime).
1684 pub fn effective(&self) -> [i64; 5] {
1685 let d = ANALYTICS_DEFAULT_RETENTION_MS;
1686 let floors = [
1687 5 * 60_000, // raw: five fine windows
1688 10 * 60_000, // m1
1689 60 * 60_000, // m5
1690 24 * 3_600_000, // h1
1691 30 * 86_400_000, // d1
1692 ];
1693 [
1694 self.raw_ms.unwrap_or(d[0]).max(floors[0]),
1695 self.m1_ms.unwrap_or(d[1]).max(floors[1]),
1696 self.m5_ms.unwrap_or(d[2]).max(floors[2]),
1697 self.h1_ms.unwrap_or(d[3]).max(floors[3]),
1698 self.d1_ms.unwrap_or(d[4]).max(floors[4]),
1699 ]
1700 }
1701}
1702
1703/// GeoIP allow/deny rules on the effective client IP (DW-050,
1704/// `authorization.geoip`). Countries are ISO 3166-1 alpha-2 codes
1705/// (case-insensitive in config, compared uppercase); ASNs are plain
1706/// numbers. Evaluation mirrors `allowed_consumers`/`denied_consumers`:
1707/// a denied match rejects (403) regardless of the allow lists; a
1708/// NON-EMPTY allow list admits only matches; empty allow lists
1709/// constrain nothing. The UNKNOWN case (unresolvable address, no
1710/// database) matches neither side.
1711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1712#[serde(deny_unknown_fields)]
1713pub struct GeoipRules {
1714 /// Countries allowed (empty = any not denied).
1715 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1716 pub allowed_countries: Vec<String>,
1717 /// Countries rejected, even when otherwise allowed.
1718 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1719 pub denied_countries: Vec<String>,
1720 /// Autonomous system numbers allowed (empty = any not denied).
1721 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1722 pub allowed_asns: Vec<u32>,
1723 /// Autonomous system numbers rejected.
1724 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1725 pub denied_asns: Vec<u32>,
1726}
1727
1728/// The GeoIP database config (DW-050, `gateway.geoip`). One MaxMind-
1729/// format database serves both country and ASN predicates (a
1730/// GeoLite2-Country, GeoLite2-ASN, or combined database — whichever
1731/// subtrees it carries are the ones the rules can use). The file is
1732/// opened at startup (a failed open is LOUD but non-fatal: the gateway
1733/// serves with geo lookups resolving UNKNOWN) and hot-reloaded: the
1734/// watcher swaps the reader when the file changes, with no restart.
1735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1736#[serde(deny_unknown_fields)]
1737pub struct GeoipConfig {
1738 /// Path to the .mmdb database file.
1739 pub path: String,
1740}
1741
1742/// The source of a custom analytics dimension value (DW-093). The
1743/// default (when `source` is absent) is [`DimensionSource::Header`],
1744/// preserving the original DW-043 header-only config shape.
1745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1746#[serde(rename_all = "snake_case")]
1747pub enum DimensionSource {
1748 /// Read the value from a request header (the original DW-043
1749 /// source; requires the `header` field).
1750 Header,
1751 /// Read the value from a verified JWT claim (requires the `claim`
1752 /// field; the claim must be present in the resolved `Identity`'s
1753 /// claims map — only string- and number-valued top-level claims
1754 /// are available, the same subset authn exposes).
1755 Claim,
1756 /// Read the value from the request body via an RFC 6901 JSON
1757 /// pointer (requires the `body_path` field; only works when the
1758 /// body is buffered — retries, hedging, request transforms, or
1759 /// request validation opt the route into buffering; the
1760 /// zero-buffering default skips body-path dimensions).
1761 BodyPath,
1762}
1763
1764/// One custom analytics dimension (DW-043,
1765/// `analytics.dimensions[]`).
1766///
1767/// DW-093 extends the source beyond headers: a `source` of `claim`
1768/// extracts from the verified JWT's claims, and `body_path` extracts
1769/// from the request body via a JSON pointer (only when the body is
1770/// buffered). The original header-only shape (`header` without
1771/// `source`) is preserved for backward compatibility.
1772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1773#[serde(deny_unknown_fields)]
1774pub struct AnalyticsDimension {
1775 /// The dimension name: lowercase `[a-z0-9_]`, at most 32 bytes —
1776 /// it becomes the rollup table's `dim` key and the query field.
1777 pub name: String,
1778 /// The dimension source (DW-093). Absent defaults to `header`
1779 /// (the original DW-043 behavior). When `header`, the `header`
1780 /// field is required; when `claim`, the `claim` field is
1781 /// required; when `body_path`, the `body_path` field is required.
1782 #[serde(default, skip_serializing_if = "Option::is_none")]
1783 pub source: Option<DimensionSource>,
1784 /// The request header whose value is captured (case-insensitive,
1785 /// as HTTP header names are). Required when `source` is `header`
1786 /// (or absent). The FIRST value of a repeated header wins;
1787 /// non-UTF-8 and over-128-byte values are skipped.
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1789 pub header: Option<String>,
1790 /// The JWT claim name whose value is captured (DW-093). Required
1791 /// when `source` is `claim`. The claim must be a string- or
1792 /// number-valued top-level claim in the verified token; other
1793 /// types and absent claims are skipped for that request.
1794 #[serde(default, skip_serializing_if = "Option::is_none")]
1795 pub claim: Option<String>,
1796 /// An RFC 6901 JSON pointer into the request body (DW-093).
1797 /// Required when `source` is `body_path`. Only works when the
1798 /// body is buffered (retries, hedging, request transforms, or
1799 /// request validation); the zero-buffering default skips
1800 /// body-path dimensions silently. The body must be valid JSON;
1801 /// non-JSON bodies and unresolved pointers are skipped.
1802 #[serde(default, skip_serializing_if = "Option::is_none")]
1803 pub body_path: Option<String>,
1804}
1805
1806impl AnalyticsDimension {
1807 /// The effective source (defaults to [`DimensionSource::Header`]
1808 /// when `source` is absent — the backward-compatible DW-043
1809 /// behavior).
1810 pub fn effective_source(&self) -> DimensionSource {
1811 self.source.unwrap_or(DimensionSource::Header)
1812 }
1813}
1814
1815/// One alert/event webhook target (DW-044, `gateway.webhooks[]`).
1816/// Compiled per generation into an `events::webhook::WebhookTarget`
1817/// (secret references resolved at compile time, URL decomposed); see
1818/// that module for the delivery contract.
1819#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1820#[serde(deny_unknown_fields)]
1821pub struct Webhook {
1822 /// Absolute `http://` or `https://` URL the envelope is POSTed to.
1823 /// Host and port come from the URL; the path (and query, if any) is
1824 /// preserved. `https://` verifies against the public webpki root set
1825 /// (no `trusted_ca_file` override for webhooks in v1).
1826 pub url: String,
1827 /// Event kinds this target receives (subset of the kinds the gateway
1828 /// emits; validation rejects unknown kinds). At least one entry.
1829 pub events: Vec<String>,
1830 /// Extra headers sent on every delivery. Values may be inline or
1831 /// `${ENV_NAME}` / `${file:/path}` secret references (DW-045),
1832 /// resolved at config-compile time; inline values are redacted in
1833 /// every config echo. Use a reference for bearer tokens and signing
1834 /// secrets.
1835 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1836 pub headers: std::collections::BTreeMap<String, String>,
1837 /// TOTAL budget for one delivery (all retry attempts share it):
1838 /// connect, write, and response-head read per attempt plus the
1839 /// backoff waits between attempts. A slow or hung target can never
1840 /// occupy more than this. Default 2000; validation enforces
1841 /// 1..=[`limits::MAX_WEBHOOK_TIMEOUT_MS`].
1842 #[serde(
1843 default = "default_webhook_timeout_ms",
1844 skip_serializing_if = "is_default_webhook_timeout_ms"
1845 )]
1846 pub timeout_ms: u64,
1847 /// Total attempts per delivery (the first try plus retries). Retries
1848 /// happen for transport failures and the transient status set
1849 /// (429/502/503/504), honoring a seconds-form `Retry-After` when the
1850 /// target sends one. Default 3; validation enforces
1851 /// 1..=[`limits::MAX_WEBHOOK_ATTEMPTS`].
1852 #[serde(
1853 default = "default_webhook_attempts",
1854 skip_serializing_if = "is_default_webhook_attempts"
1855 )]
1856 pub max_attempts: u32,
1857 /// First backoff between delivery attempts, doubling per retry up to
1858 /// `backoff_cap_ms` (a `Retry-After` answer replaces the computed
1859 /// value for that wait). Default 100.
1860 #[serde(
1861 default = "default_webhook_backoff_base_ms",
1862 skip_serializing_if = "is_default_webhook_backoff_base_ms"
1863 )]
1864 pub backoff_base_ms: u64,
1865 /// Upper bound on the computed backoff. Default 1000; must be >=
1866 /// `backoff_base_ms`.
1867 #[serde(
1868 default = "default_webhook_backoff_cap_ms",
1869 skip_serializing_if = "is_default_webhook_backoff_cap_ms"
1870 )]
1871 pub backoff_cap_ms: u64,
1872}
1873
1874/// Default `gateway.webhooks[].timeout_ms` (DW-044): one shared
1875/// delivery budget, generous for a remote alerting endpoint, small
1876/// enough that a hung target cannot pile up deliveries.
1877pub const DEFAULT_WEBHOOK_TIMEOUT_MS: u64 = 2_000;
1878/// Default `gateway.webhooks[].max_attempts` (DW-044).
1879pub const DEFAULT_WEBHOOK_ATTEMPTS: u32 = 3;
1880/// Default `gateway.webhooks[].backoff_base_ms` (DW-044).
1881pub const DEFAULT_WEBHOOK_BACKOFF_BASE_MS: u64 = 100;
1882/// Default `gateway.webhooks[].backoff_cap_ms` (DW-044).
1883pub const DEFAULT_WEBHOOK_BACKOFF_CAP_MS: u64 = 1_000;
1884
1885fn default_webhook_timeout_ms() -> u64 {
1886 DEFAULT_WEBHOOK_TIMEOUT_MS
1887}
1888fn default_webhook_attempts() -> u32 {
1889 DEFAULT_WEBHOOK_ATTEMPTS
1890}
1891fn default_webhook_backoff_base_ms() -> u64 {
1892 DEFAULT_WEBHOOK_BACKOFF_BASE_MS
1893}
1894fn default_webhook_backoff_cap_ms() -> u64 {
1895 DEFAULT_WEBHOOK_BACKOFF_CAP_MS
1896}
1897fn is_default_webhook_timeout_ms(v: &u64) -> bool {
1898 *v == DEFAULT_WEBHOOK_TIMEOUT_MS
1899}
1900fn is_default_webhook_attempts(v: &u32) -> bool {
1901 *v == DEFAULT_WEBHOOK_ATTEMPTS
1902}
1903fn is_default_webhook_backoff_base_ms(v: &u64) -> bool {
1904 *v == DEFAULT_WEBHOOK_BACKOFF_BASE_MS
1905}
1906fn is_default_webhook_backoff_cap_ms(v: &u64) -> bool {
1907 *v == DEFAULT_WEBHOOK_BACKOFF_CAP_MS
1908}
1909
1910/// Real-time access-record stream (DW-121,
1911/// `gateway.analytics_stream`).
1912///
1913/// Every completed request's access record is copied onto a bounded
1914/// channel at request completion (drop-and-count on full — the stream
1915/// can never slow the dataplane), and a background flusher turns the
1916/// queue into ordered batches delivered to the configured sink: one
1917/// delivery per flushed BATCH, not per record. The channel capacity is
1918/// fixed at boot; the sink set, flush cadence, and batch bound are read
1919/// live from the current generation, so a reload can retarget or
1920/// disable the stream without a restart.
1921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1922#[serde(deny_unknown_fields)]
1923pub struct AnalyticsStreamConfig {
1924 /// Where flushed batches go. One sink in v1 (`type: webhook`); the
1925 /// set is closed so a second implementation (a Kafka producer is
1926 /// the documented slot) is an additive config change, not a silent
1927 /// behavior change.
1928 pub sink: AnalyticsStreamSink,
1929 /// Capacity of the bounded record channel (default 8192; validated
1930 /// to [64, 65536]). The queue's entire memory story: every queued
1931 /// record is an owned copy, and a full queue DROPS (and counts)
1932 /// rather than blocking the request path. Startup wiring: the
1933 /// capacity is fixed when the gateway boots (a live reload changes
1934 /// the sink and the cadence, not this).
1935 #[serde(default, skip_serializing_if = "Option::is_none")]
1936 pub buffer: Option<u64>,
1937 /// Maximum batch latency in milliseconds (default 1000; validated
1938 /// to [100, 60000]): a batch is flushed when it holds
1939 /// `batch_max` records, reaches the batch byte cap, or this much
1940 /// time has passed since its first record — whichever comes first.
1941 /// Read live per flush cycle.
1942 #[serde(default, skip_serializing_if = "Option::is_none")]
1943 pub flush_ms: Option<u64>,
1944 /// Maximum records per flushed batch (default 512; validated to
1945 /// [1, 4096]). With the batch byte cap, whichever comes first. One
1946 /// batch is one delivery, so this is also the largest single
1947 /// delivery's record count.
1948 #[serde(default, skip_serializing_if = "Option::is_none")]
1949 pub batch_max: Option<u64>,
1950}
1951
1952/// The access-record stream's sink (DW-121,
1953/// `gateway.analytics_stream.sink`). Closed set, internally tagged
1954/// (`type: webhook` or `type: kafka`): `webhook` ships today; `kafka`
1955/// (SCALE-08, #187) produces NDJSON batches to a Kafka topic via a
1956/// Kafka REST Proxy (HTTP-based, no native client dependency). The
1957/// variant payloads carry their own `deny_unknown_fields`, so a
1958/// misspelled knob inside a sink is still a rejected config.
1959#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1960#[serde(tag = "type", rename_all = "lowercase")]
1961pub enum AnalyticsStreamSink {
1962 /// POST each flushed batch as one NDJSON body (one JSON object per
1963 /// line, one line per record) to the configured URL, reusing the
1964 /// DW-044 webhook delivery engine's retry/budget shape — one
1965 /// delivery (with its retries) per batch.
1966 Webhook(AnalyticsStreamWebhook),
1967 /// Produce each flushed batch as NDJSON messages to a Kafka topic
1968 /// via a Kafka REST Proxy (SCALE-08, #187). The REST Proxy
1969 /// endpoint receives the batch as an HTTP POST with the
1970 /// `Content-Type: application/vnd.kafka.binary.v2+json` media
1971 /// type; each NDJSON line becomes a Kafka message value (base64
1972 /// encoded per the REST Proxy spec). TLS is via `https://`; SASL
1973 /// auth is via headers. This avoids the native `rdkafka`
1974 /// dependency while providing a production-usable Kafka
1975 /// integration.
1976 Kafka(AnalyticsStreamKafka),
1977}
1978
1979/// The webhook batch sink (DW-121,
1980/// `gateway.analytics_stream.sink.webhook`). Same URL/header/retry
1981/// grammar as `gateway.webhooks[]` (DW-044) — the delivery engine is
1982/// shared — minus the `events` filter (a record stream has no kinds to
1983/// filter: every record goes to the sink).
1984#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1985#[serde(deny_unknown_fields)]
1986pub struct AnalyticsStreamWebhook {
1987 /// Absolute `http://` or `https://` URL the batch is POSTed to.
1988 /// `https://` verifies against the public webpki root set (no
1989 /// `trusted_ca_file` override — v1 scope, same as alert webhooks).
1990 pub url: String,
1991 /// Headers sent on every batch delivery (e.g. the collector's auth
1992 /// token). Values may be inline or `${ENV_NAME}` / `${file:/path}`
1993 /// secret references (DW-045), resolved at config-compile time;
1994 /// inline values are redacted in every config echo. Use a
1995 /// reference for bearer tokens and signing secrets.
1996 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1997 pub headers: std::collections::BTreeMap<String, String>,
1998 /// TOTAL budget for one batch delivery (all retry attempts share
1999 /// it), in milliseconds. Default 5000: a BATCH is heavier than a
2000 /// single alert envelope, and the flusher delivers batches strictly
2001 /// in order, so this bounds how long one unlucky batch can hold
2002 /// back the queue. Validation enforces
2003 /// 1..=[`limits::MAX_WEBHOOK_TIMEOUT_MS`] (the shared engine's
2004 /// bound).
2005 #[serde(
2006 default = "default_stream_webhook_timeout_ms",
2007 skip_serializing_if = "is_default_stream_webhook_timeout_ms"
2008 )]
2009 pub timeout_ms: u64,
2010 /// Total attempts per batch delivery (first try plus retries);
2011 /// retries cover transport failures and the transient status set
2012 /// (429/502/503/504), honoring a seconds-form `Retry-After`.
2013 /// Default 3; validation enforces
2014 /// 1..=[`limits::MAX_WEBHOOK_ATTEMPTS`].
2015 #[serde(
2016 default = "default_stream_webhook_attempts",
2017 skip_serializing_if = "is_default_stream_webhook_attempts"
2018 )]
2019 pub max_attempts: u32,
2020 /// First backoff between batch attempts, doubling per retry up to
2021 /// `backoff_cap_ms` (a `Retry-After` answer replaces the computed
2022 /// value for that wait). Default 250 — a batch retry is heavier
2023 /// than an alert retry, so it starts slower.
2024 #[serde(
2025 default = "default_stream_webhook_backoff_base_ms",
2026 skip_serializing_if = "is_default_stream_webhook_backoff_base_ms"
2027 )]
2028 pub backoff_base_ms: u64,
2029 /// Upper bound on the computed backoff. Default 4000; must be >=
2030 /// `backoff_base_ms`.
2031 #[serde(
2032 default = "default_stream_webhook_backoff_cap_ms",
2033 skip_serializing_if = "is_default_stream_webhook_backoff_cap_ms"
2034 )]
2035 pub backoff_cap_ms: u64,
2036}
2037
2038/// The Kafka batch sink (SCALE-08, #187,
2039/// `gateway.analytics_stream.sink.kafka`). Produces each flushed
2040/// batch as NDJSON messages to a Kafka topic via a Kafka REST Proxy
2041/// (HTTP-based, no native client dependency). The REST Proxy
2042/// endpoint receives the batch as an HTTP POST with the
2043/// `Content-Type: application/vnd.kafka.binary.v2+json` media type;
2044/// each NDJSON line becomes a Kafka message value (base64 encoded
2045/// per the REST Proxy spec). TLS is via `https://`; SASL auth is via
2046/// headers (e.g. `Authorization: Basic ...`).
2047#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2048#[serde(deny_unknown_fields)]
2049pub struct AnalyticsStreamKafka {
2050 /// Absolute `http://` or `https://` URL of the Kafka REST Proxy
2051 /// endpoint for producing to the topic, e.g.
2052 /// `https://kafka-rest:8082/topics/<topic>`. `https://` verifies
2053 /// against the public webpki root set.
2054 pub rest_proxy_url: String,
2055 /// Headers sent on every batch delivery (e.g. the REST Proxy's
2056 /// auth token, `Authorization: Basic ...` for SASL). Values may
2057 /// be inline or `${ENV_NAME}` / `${file:/path}` secret references
2058 /// (DW-045), resolved at config-compile time; inline values are
2059 /// redacted in every config echo.
2060 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
2061 pub headers: std::collections::BTreeMap<String, String>,
2062 /// TOTAL budget for one batch delivery (all retry attempts share
2063 /// it), in milliseconds. Default 5000. Validation enforces
2064 /// 1..=[`limits::MAX_WEBHOOK_TIMEOUT_MS`] (the shared engine's
2065 /// bound).
2066 #[serde(
2067 default = "default_stream_webhook_timeout_ms",
2068 skip_serializing_if = "is_default_stream_webhook_timeout_ms"
2069 )]
2070 pub timeout_ms: u64,
2071 /// Max delivery attempts per batch. Default 3.
2072 #[serde(
2073 default = "default_stream_webhook_attempts",
2074 skip_serializing_if = "is_default_stream_webhook_attempts"
2075 )]
2076 pub max_attempts: u32,
2077 /// First backoff between batch attempts, doubling per retry up to
2078 /// `backoff_cap_ms`. Default 250.
2079 #[serde(
2080 default = "default_stream_webhook_backoff_base_ms",
2081 skip_serializing_if = "is_default_stream_webhook_backoff_base_ms"
2082 )]
2083 pub backoff_base_ms: u64,
2084 /// Upper bound on the computed backoff. Default 4000; must be >=
2085 /// `backoff_base_ms`.
2086 #[serde(
2087 default = "default_stream_webhook_backoff_cap_ms",
2088 skip_serializing_if = "is_default_stream_webhook_backoff_cap_ms"
2089 )]
2090 pub backoff_cap_ms: u64,
2091}
2092
2093/// Default `analytics_stream.buffer` (DW-121): the bounded record
2094/// channel's capacity when the block is absent at boot (the stream is
2095/// always constructed so a live reload can arm it). Lives in `config`
2096/// with the retention defaults — the lowest consuming domain — so
2097/// validation docs, the stream, and the binary read one definition.
2098pub const DEFAULT_STREAM_BUFFER: u64 = 8_192;
2099/// Default `analytics_stream.flush_ms` (DW-121): maximum batch latency.
2100pub const DEFAULT_STREAM_FLUSH_MS: u64 = 1_000;
2101/// Default `analytics_stream.batch_max` (DW-121): records per flushed
2102/// batch.
2103pub const DEFAULT_STREAM_BATCH_MAX: u64 = 512;
2104/// Default `analytics_stream.sink.webhook.timeout_ms` (DW-121): a
2105/// batch carries up to `batch_max` records, so its budget starts
2106/// heavier than the alert webhook's 2000 but stays far under the
2107/// one-minute ceiling.
2108pub const DEFAULT_STREAM_WEBHOOK_TIMEOUT_MS: u64 = 5_000;
2109/// Default `analytics_stream.sink.webhook.max_attempts` (DW-121).
2110pub const DEFAULT_STREAM_WEBHOOK_ATTEMPTS: u32 = 3;
2111/// Default `analytics_stream.sink.webhook.backoff_base_ms` (DW-121).
2112pub const DEFAULT_STREAM_WEBHOOK_BACKOFF_BASE_MS: u64 = 250;
2113/// Default `analytics_stream.sink.webhook.backoff_cap_ms` (DW-121).
2114pub const DEFAULT_STREAM_WEBHOOK_BACKOFF_CAP_MS: u64 = 4_000;
2115
2116fn default_stream_webhook_timeout_ms() -> u64 {
2117 DEFAULT_STREAM_WEBHOOK_TIMEOUT_MS
2118}
2119fn default_stream_webhook_attempts() -> u32 {
2120 DEFAULT_STREAM_WEBHOOK_ATTEMPTS
2121}
2122fn default_stream_webhook_backoff_base_ms() -> u64 {
2123 DEFAULT_STREAM_WEBHOOK_BACKOFF_BASE_MS
2124}
2125fn default_stream_webhook_backoff_cap_ms() -> u64 {
2126 DEFAULT_STREAM_WEBHOOK_BACKOFF_CAP_MS
2127}
2128fn is_default_stream_webhook_timeout_ms(v: &u64) -> bool {
2129 *v == DEFAULT_STREAM_WEBHOOK_TIMEOUT_MS
2130}
2131fn is_default_stream_webhook_attempts(v: &u32) -> bool {
2132 *v == DEFAULT_STREAM_WEBHOOK_ATTEMPTS
2133}
2134fn is_default_stream_webhook_backoff_base_ms(v: &u64) -> bool {
2135 *v == DEFAULT_STREAM_WEBHOOK_BACKOFF_BASE_MS
2136}
2137fn is_default_stream_webhook_backoff_cap_ms(v: &u64) -> bool {
2138 *v == DEFAULT_STREAM_WEBHOOK_BACKOFF_CAP_MS
2139}
2140
2141/// Gateway-wide HMAC request-signing policy (DW-036). The credential
2142/// itself (key id + secret) lives on a consumer
2143/// (`credentials: [{type: hmac, ...}]`); this block carries the
2144/// VERIFICATION policy that applies to every signed request.
2145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2146#[serde(deny_unknown_fields)]
2147pub struct HmacAuth {
2148 /// Maximum accepted absolute difference, in seconds, between the
2149 /// request's `X-Dwara-Timestamp` and the gateway clock. A timestamp
2150 /// outside the window is rejected 401 BEFORE any HMAC computation
2151 /// (an expired window is not a MAC problem). The window is also the
2152 /// replay window: nonces are remembered for twice this duration.
2153 /// Default 300 (±5 minutes, the §4.6 recommendation); validation
2154 /// enforces 1..=3600 (a sub-second window rejects every request
2155 /// with any clock drift; an unbounded one pins nonce-cache memory
2156 /// forever).
2157 #[serde(default = "default_hmac_clock_skew_secs")]
2158 pub max_clock_skew_secs: u64,
2159}
2160
2161/// Default `hmac_auth.max_clock_skew_secs` (±5 minutes, DW-036 §4.6).
2162pub const DEFAULT_HMAC_CLOCK_SKEW_SECS: u64 = 300;
2163
2164fn default_hmac_clock_skew_secs() -> u64 {
2165 DEFAULT_HMAC_CLOCK_SKEW_SECS
2166}
2167
2168impl Gateway {
2169 /// A display-safe copy of this config (DW-045): every INLINE
2170 /// `api_key` value is replaced by the
2171 /// [`credentials::redact_inline_secret`] placeholder
2172 /// (`${redacted:sha256:<prefix>}`); `${...}` references pass through
2173 /// unchanged (an env-var name or file path is not secret bytes —
2174 /// the config file itself carries it). This is the TYPED redaction
2175 /// behind every surface that echoes configuration (admin
2176 /// `GET /config`); it is deliberately a transform, not a custom
2177 /// serializer, so it cannot drift from the schema — a future
2178 /// secret-bearing field fails visibly (its value reaches the dump
2179 /// unredacted) instead of silently escaping an allowlist regex.
2180 pub fn redacted(&self) -> Gateway {
2181 let mut redacted = self.clone();
2182 for consumer in &mut redacted.consumers {
2183 for credential in &mut consumer.credentials {
2184 match credential {
2185 Credential::ApiKey { key } => {
2186 *key = credentials::redact_inline_secret(key);
2187 }
2188 // DW-036: the HMAC secret is inline secret material
2189 // exactly like an api key — same placeholder, same
2190 // unresolvable-by-design round-trip rejection.
2191 Credential::Hmac { secret, .. } => {
2192 *secret = credentials::redact_inline_secret(secret);
2193 }
2194 Credential::Jwt { .. } | Credential::Mtls { .. } => {}
2195 }
2196 }
2197 }
2198 // DW-044: webhook header values are secret-bearing config fields
2199 // exactly like api keys (bearer tokens, signing secrets) — the
2200 // same placeholder, the same reference passthrough, the same
2201 // unresolvable-by-design round-trip rejection.
2202 for webhook in &mut redacted.webhooks {
2203 for value in webhook.headers.values_mut() {
2204 *value = credentials::redact_inline_secret(value);
2205 }
2206 }
2207 // DW-075: AI provider auth values are inline secret material
2208 // exactly like api keys — the same placeholder, the same
2209 // reference passthrough.
2210 if let Some(ai) = &mut redacted.ai {
2211 for provider in &mut ai.providers {
2212 if let Some(auth) = &mut provider.auth {
2213 auth.value = credentials::redact_inline_secret(&auth.value);
2214 }
2215 // DW-080: redact credential pool entry values too.
2216 if let Some(pool) = &mut provider.credential_pool {
2217 for cred in &mut pool.credentials {
2218 cred.value = credentials::redact_inline_secret(&cred.value);
2219 }
2220 }
2221 }
2222 }
2223 redacted
2224 }
2225}
2226
2227/// Admin listener configuration (DW-022).
2228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2229#[serde(deny_unknown_fields)]
2230pub struct AdminConfig {
2231 /// Bind address of the admin listener. Default `127.0.0.1:2019`
2232 /// (loopback-only; frozen decision 6) — override only to place the
2233 /// admin API on a dedicated management interface, and rely on mTLS
2234 /// for access control: there is no token layer in v1 (the client
2235 /// certificate IS the auth).
2236 #[serde(default = "default_admin_bind")]
2237 pub bind: String,
2238 /// mTLS material for the admin listener; all three files are
2239 /// required.
2240 pub tls: AdminTlsConfig,
2241 /// SEC-01: RBAC bindings for mTLS-authenticated admin clients.
2242 /// Maps client certificate fingerprints (SHA-256 of the cert
2243 /// DER) to roles. When present, a valid client certificate with
2244 /// NO binding is denied (fail-closed: no implicit admin). When
2245 /// absent (the default), any CA-valid client certificate is
2246 /// treated as full admin (the v1 behavior — backward compatible).
2247 #[serde(default, skip_serializing_if = "Option::is_none")]
2248 pub rbac: Option<AdminRbacConfig>,
2249 /// SEC-01: API token authentication. When present, the admin API
2250 /// accepts an `Authorization: Bearer <token>` header in addition
2251 /// to mTLS. Tokens are hashed (SHA-256) at rest; the config lists
2252 /// token hashes, not plaintext tokens. When absent (the default),
2253 /// only mTLS is accepted (the v1 behavior).
2254 #[serde(default, skip_serializing_if = "Option::is_none")]
2255 pub api_tokens: Option<AdminApiTokensConfig>,
2256 /// SEC-01: Admin audit log. When present, all mutating admin
2257 /// actions (PATCH /config, purge) are recorded in an append-only
2258 /// audit table in the state store. Each entry includes the actor
2259 /// (cert fingerprint or token hash), the action, the before/after
2260 /// config hash, and a timestamp. When absent (the default), no
2261 /// audit log is kept.
2262 #[serde(default, skip_serializing_if = "Option::is_none")]
2263 pub audit: Option<AdminAuditConfig>,
2264}
2265
2266/// SEC-01: Admin RBAC configuration. Maps client certificate
2267/// fingerprints to roles. A valid certificate with no binding is
2268/// denied (fail-closed).
2269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2270#[serde(deny_unknown_fields)]
2271pub struct AdminRbacConfig {
2272 /// Certificate-fingerprint-to-role bindings. Each entry maps one
2273 /// SHA-256 fingerprint (lowercase hex, 64 chars) to a role.
2274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2275 pub bindings: Vec<AdminRbacBinding>,
2276}
2277
2278/// One certificate-fingerprint-to-role binding.
2279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2280#[serde(deny_unknown_fields)]
2281pub struct AdminRbacBinding {
2282 /// SHA-256 fingerprint of the client certificate (lowercase hex,
2283 /// 64 chars). Computed over the full DER encoding of the cert.
2284 pub cert_fingerprint: String,
2285 /// The role assigned to this certificate: `admin` (full access)
2286 /// or `readonly` (GET only; PATCH/purge denied).
2287 pub role: AdminRole,
2288}
2289
2290/// Admin roles.
2291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
2292#[serde(rename_all = "lowercase")]
2293pub enum AdminRole {
2294 /// Full admin access: all admin API endpoints.
2295 #[default]
2296 Admin,
2297 /// Read-only access: GET endpoints only. PATCH /config and purge
2298 /// are denied (403).
2299 Readonly,
2300}
2301
2302/// SEC-01: API token authentication for the admin API. Tokens are
2303/// hashed (SHA-256) at rest; the config lists token hashes, not
2304/// plaintext tokens. A client presents a token via
2305/// `Authorization: Bearer <token>`.
2306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2307#[serde(deny_unknown_fields)]
2308pub struct AdminApiTokensConfig {
2309 /// The hashed API tokens (SHA-256 of the plaintext token, as
2310 /// lowercase hex, 64 chars). A request with a matching bearer
2311 /// token is authenticated as the token's role.
2312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2313 pub tokens: Vec<AdminApiToken>,
2314}
2315
2316/// One hashed API token with a role.
2317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2318#[serde(deny_unknown_fields)]
2319pub struct AdminApiToken {
2320 /// SHA-256 hash of the plaintext token (lowercase hex, 64 chars).
2321 pub token_hash: String,
2322 /// The role assigned to this token.
2323 pub role: AdminRole,
2324}
2325
2326/// SEC-01: Admin audit log configuration.
2327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2328#[serde(deny_unknown_fields)]
2329pub struct AdminAuditConfig {
2330 /// Whether the audit log is enabled. Default true (when the block
2331 /// is present, the audit log is on).
2332 #[serde(
2333 default = "default_audit_enabled",
2334 skip_serializing_if = "is_default_audit_enabled"
2335 )]
2336 pub enabled: bool,
2337}
2338
2339fn default_audit_enabled() -> bool {
2340 true
2341}
2342
2343fn is_default_audit_enabled(b: &bool) -> bool {
2344 *b
2345}
2346
2347/// mTLS material for the admin listener (DW-022). Unlike dataplane
2348/// listeners there is no mode: the admin listener always terminates TLS
2349/// and always requires a client certificate chaining to `client_ca_file`.
2350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2351#[serde(deny_unknown_fields)]
2352pub struct AdminTlsConfig {
2353 /// Path to the PEM certificate chain the admin server presents.
2354 pub cert_file: String,
2355 /// Path to the PEM private key for `cert_file`.
2356 pub key_file: String,
2357 /// Path to the PEM CA bundle client certificates must chain to.
2358 /// Required (mTLS-only): validation rejects an admin block without
2359 /// it rather than silently serving no-auth TLS.
2360 pub client_ca_file: String,
2361}
2362
2363fn default_admin_bind() -> String {
2364 "127.0.0.1:2019".to_string()
2365}
2366
2367/// One JWT verification provider (DW-019).
2368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2369#[serde(deny_unknown_fields)]
2370pub struct JwtProvider {
2371 pub name: String,
2372 /// JWKS endpoint (`http://` or `https://`). Keys are fetched lazily on
2373 /// the first Bearer request, refreshed after `refresh_secs`, and
2374 /// re-fetched on an unknown `kid` (key rotation mid-flight).
2375 pub jwks_url: String,
2376 /// Path to a PEM file of CA certificates the JWKS fetcher trusts for
2377 /// its `https://` connection INSTEAD of the default public (webpki)
2378 /// root set (#121): for JWKS endpoints served over TLS by a private
2379 /// CA. The file may carry several certificates (a typical CA bundle).
2380 /// Only meaningful with an `https://` jwks_url — no TLS is negotiated
2381 /// toward an `http://` endpoint, so validation rejects that
2382 /// combination. The file must exist and be readable at config compile
2383 /// time; validation names this field otherwise.
2384 #[serde(default, skip_serializing_if = "Option::is_none")]
2385 pub trusted_ca_file: Option<String>,
2386 /// Required token issuer (`iss` claim). Absent: any issuer accepted.
2387 #[serde(skip_serializing_if = "Option::is_none")]
2388 pub issuer: Option<String>,
2389 /// Required audience (`aud` claim). Audience is validated ONLY when
2390 /// this is configured (#124, maintainer decision): a provider
2391 /// without an `audience` ACCEPTS tokens that carry any (or no) `aud`
2392 /// claim — `aud` is not interpreted unless the provider names one.
2393 /// All other validations (exp, nbf, iss when configured, the
2394 /// algorithm allowlist) are identical either way.
2395 #[serde(skip_serializing_if = "Option::is_none")]
2396 pub audience: Option<String>,
2397 /// Allowed signature algorithms (default `["RS256", "ES256"]`).
2398 /// `none` and HMAC (`HS*`) algorithms are never allowed implicitly —
2399 /// they must not appear in this list; validation rejects them
2400 /// (asymmetric verification only: the gateway holds no shared
2401 /// secrets with issuers).
2402 #[serde(default = "default_jwt_algorithms")]
2403 pub algorithms: Vec<String>,
2404 /// JWKS cache staleness bound in seconds (default 300): a cached key
2405 /// set older than this is refreshed before use.
2406 #[serde(
2407 default = "default_jwt_refresh_secs",
2408 skip_serializing_if = "is_default_jwt_refresh_secs"
2409 )]
2410 pub refresh_secs: u64,
2411 /// exp/nbf clock-skew leeway in seconds (default 30).
2412 #[serde(
2413 default = "default_jwt_leeway_secs",
2414 skip_serializing_if = "is_default_jwt_leeway_secs"
2415 )]
2416 pub leeway_secs: u64,
2417 /// Consumer this provider's tokens authenticate. Absent: the token's
2418 /// `iss` claim is matched against consumers' `jwt` credentials.
2419 #[serde(skip_serializing_if = "Option::is_none")]
2420 pub consumer: Option<String>,
2421 /// How long a key REMOVED from a fresh JWKS fetch keeps verifying
2422 /// (DW-046), in seconds (default 86400 = 24 h; 0 disables). The
2423 /// dual-validity window for JWKS rotation: issuers drop the old
2424 /// key from their JWKS the moment the new one appears, while
2425 /// previously-issued tokens still carry the old `kid` — during
2426 /// this grace the gateway honors the immediately-previous key set
2427 /// for kids the fresh set no longer carries. Only ONE previous set
2428 /// is retained (rotation is one step at a time). Validation caps
2429 /// the value at 7 days (604800).
2430 #[serde(default, skip_serializing_if = "Option::is_none")]
2431 pub retired_key_grace_secs: Option<u64>,
2432 /// How long to serve stale (cached) keys after a JWKS fetch failure
2433 /// (SEC-12, #215), in seconds (default: same as `refresh_secs`,
2434 /// capped at 300). When a JWKS fetch fails, the cached key set
2435 /// keeps serving for this duration before a new fetch is attempted,
2436 /// so a transient JWKS provider outage does not cascade into auth
2437 /// failures. 0 disables stale-on-error (a fetch failure surfaces
2438 /// immediately when the cache is empty; when the cache is non-empty
2439 /// the existing degraded-mode behavior applies).
2440 #[serde(default, skip_serializing_if = "Option::is_none")]
2441 pub stale_on_error_secs: Option<u64>,
2442}
2443
2444impl JwtProvider {
2445 /// `retired_key_grace_secs` resolved to its effective value
2446 /// (default 86 400; the DW-046 dual-validity window).
2447 pub fn retired_key_grace_secs(&self) -> u64 {
2448 self.retired_key_grace_secs.unwrap_or(86_400)
2449 }
2450 /// `stale_on_error_secs` resolved to its effective value (SEC-12,
2451 /// #215): the default is `min(refresh_secs, 300)` — a failed fetch
2452 /// backs off for at most one refresh interval (capped at 5 min) so
2453 /// a down JWKS endpoint does not chain every Bearer request through
2454 /// a doomed fetch attempt.
2455 pub fn stale_on_error_secs(&self) -> u64 {
2456 self.stale_on_error_secs
2457 .unwrap_or_else(|| self.refresh_secs.min(300))
2458 }
2459}
2460
2461fn default_jwt_algorithms() -> Vec<String> {
2462 vec!["RS256".to_string(), "ES256".to_string()]
2463}
2464
2465fn default_jwt_refresh_secs() -> u64 {
2466 300
2467}
2468
2469fn default_jwt_leeway_secs() -> u64 {
2470 30
2471}
2472
2473fn is_default_jwt_refresh_secs(v: &u64) -> bool {
2474 *v == 300
2475}
2476
2477fn is_default_jwt_leeway_secs(v: &u64) -> bool {
2478 *v == 30
2479}
2480
2481/// One OIDC provider (DW-034): an OpenID Connect issuer whose Bearer
2482/// tokens are validated by token introspection (RFC 7662) rather than
2483/// local JWT signature verification. The gateway fetches the issuer's
2484/// discovery document (`{issuer}/.well-known/openid-configuration`) to
2485/// learn the introspection, revocation, authorization, and token
2486/// endpoints, then introspects presented Bearer tokens (caching the
2487/// result keyed by token hash with a configurable TTL). The provider
2488/// also backs the authorization-code + PKCE relying-party flow, token
2489/// exchange (RFC 8693), and token revocation (RFC 7009).
2490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2491#[serde(deny_unknown_fields)]
2492pub struct OidcProvider {
2493 pub name: String,
2494 /// The OIDC issuer URL (e.g. `https://idp.example.com`). The
2495 /// discovery document is fetched from
2496 /// `{issuer}/.well-known/openid-configuration`. Must be an absolute
2497 /// `http(s)://` URL; validation rejects anything else. The
2498 /// introspection result's `iss` (when present) is checked against
2499 /// this issuer for defense against token confusion.
2500 pub issuer: String,
2501 /// The gateway's client identifier at the IdP (RFC 6749 section
2502 /// 2.2). Sent as the username of the HTTP Basic auth header to the
2503 /// introspection and token endpoints, and as `client_id` in the
2504 /// authorization-code and token-exchange request bodies.
2505 pub client_id: String,
2506 /// The gateway's client secret (RFC 6749 section 2.3.1). Either the
2507 /// value INLINE (redacted in every config echo, DW-045) or a
2508 /// `${...}` secret reference (env or file) resolved at config-compile
2509 /// time and re-read on every reload. Sent as the password of the
2510 /// HTTP Basic auth header. SECRET: never logged, never in Debug.
2511 pub client_secret: String,
2512 /// Path to a PEM file of CA certificates the OIDC HTTP client
2513 /// trusts for its `https://` connections INSTEAD of the default
2514 /// public (webpki) root set: for an IdP served over TLS by a private
2515 /// CA. The file may carry several certificates (a typical CA bundle).
2516 /// Only meaningful with an `https://` issuer; validation rejects the
2517 /// combination with an `http://` issuer. The file must exist and be
2518 /// readable at config compile time.
2519 #[serde(default, skip_serializing_if = "Option::is_none")]
2520 pub trusted_ca_file: Option<String>,
2521 /// Scopes to request (default `["openid"]`). Sent in the
2522 /// authorization-code flow's authorization request and the
2523 /// token-exchange request body (space-joined).
2524 #[serde(
2525 default = "default_oidc_scopes",
2526 skip_serializing_if = "is_default_oidc_scopes"
2527 )]
2528 pub scopes: Vec<String>,
2529 /// Cache TTL for introspection results, in seconds (default 60). A
2530 /// cached `active: true` result short-circuits the IdP call for this
2531 /// duration; a cached `active: false` result is NOT cached (it is
2532 /// re-checked on the next request so a revoked token is noticed
2533 /// promptly). Must be > 0; validation rejects 0.
2534 #[serde(
2535 default = "default_oidc_introspection_cache_ttl_s",
2536 skip_serializing_if = "is_default_oidc_introspection_cache_ttl_s"
2537 )]
2538 pub introspection_cache_ttl_s: u64,
2539 /// Override the discovery-discovered introspection endpoint. Absent
2540 /// (the default): the endpoint is taken from the discovery document.
2541 /// Useful when the IdP's discovery document is incorrect or the
2542 /// endpoint is behind a different host.
2543 #[serde(default, skip_serializing_if = "Option::is_none")]
2544 pub introspection_endpoint: Option<String>,
2545 /// Override the discovery-discovered revocation endpoint. Absent
2546 /// (the default): the endpoint is taken from the discovery document.
2547 #[serde(default, skip_serializing_if = "Option::is_none")]
2548 pub revocation_endpoint: Option<String>,
2549 /// Consumer this provider's tokens authenticate (like
2550 /// [`JwtProvider::consumer`]). Absent: the introspection result's
2551 /// `sub` claim is used as the consumer name. When set, every
2552 /// successfully introspected token resolves to this consumer
2553 /// regardless of the `sub` claim.
2554 #[serde(default, skip_serializing_if = "Option::is_none")]
2555 pub consumer: Option<String>,
2556 /// Fail-open posture when the IdP is unreachable (network error,
2557 /// non-2xx, malformed response). Default false (fail closed = 401):
2558 /// the gateway refuses to authenticate a token it cannot introspect.
2559 /// When true, the gateway treats an IdP failure as anonymous
2560 /// (pass-through) rather than rejecting the request. Fail-open
2561 /// trades security for availability; use with care.
2562 #[serde(default, skip_serializing_if = "is_false")]
2563 pub fail_open: bool,
2564}
2565
2566fn default_oidc_scopes() -> Vec<String> {
2567 vec!["openid".to_string()]
2568}
2569
2570fn is_default_oidc_scopes(v: &[String]) -> bool {
2571 v.len() == 1 && v[0] == "openid"
2572}
2573
2574fn default_oidc_introspection_cache_ttl_s() -> u64 {
2575 60
2576}
2577
2578fn is_default_oidc_introspection_cache_ttl_s(v: &u64) -> bool {
2579 *v == 60
2580}
2581
2582/// Entry point: bind address + port + TLS termination (or passthrough) config.
2583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2584#[serde(deny_unknown_fields)]
2585pub struct Listener {
2586 pub name: String,
2587 /// Bind address, e.g. `0.0.0.0` or `127.0.0.1`.
2588 pub address: String,
2589 pub port: u16,
2590 #[serde(default = "default_listener_protocol")]
2591 pub protocol: ListenerProtocol,
2592 #[serde(default, skip_serializing_if = "Option::is_none")]
2593 pub tls: Option<ListenerTls>,
2594 /// Accept a PROXY protocol v1 or v2 header as the FIRST bytes of
2595 /// every connection on this listener (DW-030), replacing the peer
2596 /// address the gateway uses everywhere the socket peer is used
2597 /// today: the authz IP ACL's effective-client-IP base, rate-limit
2598 /// keying, `X-Forwarded-For` / `X-Real-IP` on the forwarded
2599 /// request. OPT-IN (default false): a plaintext listener that does
2600 /// not expect a PROXY line must never interpret the first request
2601 /// bytes as one, and a client that spoofs a PROXY line on a listener
2602 /// without this flag is simply serving garbage to the HTTP parser.
2603 /// The header is read BEFORE the TLS handshake (the L4 LB in front
2604 /// wraps the whole stream, TLS included) and a MALFORMED header
2605 /// fails closed: the connection is answered with a `400` error
2606 /// envelope and closed, never handed to HTTP parsing. A v2 `LOCAL`
2607 /// command or a v1 `UNKNOWN` line keeps the real peer address (the
2608 /// spec's own fallback for connections the LB did not originate).
2609 /// Validation rejects the combination with `tls.mode passthrough`:
2610 /// a passthrough listener splices raw bytes and never runs the
2611 /// HTTP pipeline that consumes the client address. Part of the
2612 /// restart-only bind set (like address/port) — toggling it takes a
2613 /// restart, not a reload.
2614 #[serde(default, skip_serializing_if = "is_false")]
2615 pub proxy_protocol: bool,
2616 /// Policies attached to this listener (#123): names from the
2617 /// gateway's `policies` list, applying to every request the listener
2618 /// accepts (the second-least specific link of the frozen chain
2619 /// consumer > route > service > listener > global; all applicable
2620 /// levels' rules AND together — see the rate-limiter module docs).
2621 /// Listener policies also apply to UNROUTED traffic accepted by this
2622 /// listener: a request whose path matches no route is rate-limited by
2623 /// them before the 404 is answered (reserved paths stay exempt).
2624 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2625 pub policies: Vec<String>,
2626 /// Listener-level authorization rules (#123): applies to every
2627 /// request this listener accepts that resolved a route (authorization
2628 /// runs after route resolution per the documented request-path
2629 /// order). Link of the frozen chain consumer > route > service >
2630 /// listener > global; see [`Authz`] for the rule semantics and the
2631 /// `authz` module for the merge.
2632 #[serde(default, skip_serializing_if = "Option::is_none")]
2633 pub authorization: Option<Authz>,
2634 /// Alt-Svc header value advertised on H1/H2 responses for this
2635 /// listener (DW-088). When set, an `Alt-Svc` response header is
2636 /// added telling clients that HTTP/3 is available. The value is
2637 /// the alt-svc payload (e.g. `h3=":8443"; ma=86400`). Only
2638 /// meaningful on `http` and `https` listeners (an H3 listener
2639 /// advertising itself is a no-op and rejected by validation).
2640 /// The header is added after all other response processing (it
2641 /// is a transport-level advertisement, not a route-level
2642 /// transform).
2643 #[serde(default, skip_serializing_if = "Option::is_none")]
2644 pub alt_svc: Option<String>,
2645 /// L4 proxying configuration (DW-103). Only meaningful on
2646 /// `protocol: tcp` and `protocol: udp` listeners; rejected by
2647 /// validation on other protocols. The `upstream` field names the
2648 /// upstream that receives the spliced stream (or, when
2649 /// `sni_routing` is true, the upstream selected by the TLS
2650 /// ClientHello SNI via the upstream's `tls.sni_routes` -- reusing
2651 /// the SNI extraction from DW-008 TLS passthrough). Compiled into
2652 /// every build (no cargo feature).
2653 #[serde(default, skip_serializing_if = "Option::is_none")]
2654 pub l4: Option<L4Config>,
2655 /// HTTP/2 flow-control tuning for this listener (PERF-11, #244).
2656 /// When set, the h2 server builder uses these values instead of
2657 /// the process-wide defaults from `DWARA_H2_*` env vars. Only
2658 /// meaningful on `http` and `https` listeners (h2 is negotiated
2659 /// via ALPN or prior knowledge); ignored on `h3` (HTTP/3 has its
2660 /// own transport-level flow control) and `tcp`/`udp`.
2661 #[serde(default, skip_serializing_if = "Option::is_none")]
2662 pub http2: Option<Http2ListenerConfig>,
2663}
2664
2665/// HTTP/2 flow-control tuning for a listener (PERF-11, #244,
2666/// `listeners[].http2`). Every field is optional; an omitted field
2667/// keeps the process-wide default from `DWARA_H2_*` env vars. This
2668/// allows per-listener tuning for streaming (SSE) workloads where
2669/// larger windows reduce flow-control stalls on long-lived streams.
2670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2671#[serde(deny_unknown_fields)]
2672pub struct Http2ListenerConfig {
2673 /// Initial stream-level window size in bytes (hyper
2674 /// `initial_stream_window_size`). Absent: process default
2675 /// (1 MiB via `DWARA_H2_STREAM_WINDOW_KIB`). Larger values
2676 /// reduce stalls on streaming responses but increase memory
2677 /// per stream. Bounds: at most 16 MiB.
2678 #[serde(default, skip_serializing_if = "Option::is_none")]
2679 pub initial_stream_window_size: Option<u32>,
2680 /// Initial connection-level window size in bytes (hyper
2681 /// `initial_connection_window_size`). Absent: process default
2682 /// (4 MiB via `DWARA_H2_CONNECTION_WINDOW_KIB`). Bounds: at
2683 /// most 64 MiB.
2684 #[serde(default, skip_serializing_if = "Option::is_none")]
2685 pub initial_connection_window_size: Option<u32>,
2686 /// Enable adaptive flow-control window (hyper
2687 /// `adaptive_window`). Absent: false. When true, the
2688 /// connection window grows and shrinks based on throughput,
2689 /// which helps streaming workloads on high-BDP paths.
2690 #[serde(default, skip_serializing_if = "is_false")]
2691 pub adaptive_window: bool,
2692 /// Maximum number of concurrent streams per connection (hyper
2693 /// `max_concurrent_streams`). Absent: process default (128 via
2694 /// `DWARA_H2_MAX_CONCURRENT_STREAMS`). Bounds: at most 1_000_000.
2695 #[serde(default, skip_serializing_if = "Option::is_none")]
2696 pub max_concurrent_streams: Option<u32>,
2697}
2698
2699fn default_listener_protocol() -> ListenerProtocol {
2700 ListenerProtocol::Http
2701}
2702
2703#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2704#[serde(rename_all = "lowercase")]
2705pub enum ListenerProtocol {
2706 Http,
2707 Https,
2708 /// HTTP/3 (QUIC) ingress (DW-088). A UDP socket is bound on the
2709 /// listener's address:port; the QUIC handshake uses the `tls`
2710 /// block's certificates (terminate mode only — passthrough is
2711 /// rejected). Compiled into every build (no cargo feature; QUIC
2712 /// via the unconditional `quinn`/`h3` deps). The `alt_svc` field on
2713 /// sibling H1/H2 listeners advertises this listener's H3 port to
2714 /// clients for protocol upgrade discovery.
2715 H3,
2716 /// L4 TCP proxying (DW-103). A raw TCP socket is bound on the
2717 /// listener's address:port; accepted connections are spliced
2718 /// byte-for-byte to an upstream endpoint (optionally selected by
2719 /// peeking the TLS ClientHello SNI, reusing the SNI extraction
2720 /// from DW-008 TLS passthrough). Compiled into every build (no
2721 /// cargo feature). The `l4` block configures the
2722 /// upstream and SNI-routing flag.
2723 Tcp,
2724 /// L4 UDP proxying (DW-103). A UDP socket is bound on the
2725 /// listener's address:port; datagrams are forwarded to an upstream
2726 /// endpoint. STUBBED: the UDP dispatcher returns Unimplemented --
2727 /// UDP session semantics and NAT timeouts are a follow-up. Compiled
2728 /// into every build (no cargo feature). The `l4` block configures
2729 /// the upstream.
2730 Udp,
2731}
2732
2733/// TLS handling for a listener: terminate at the edge or pass through.
2734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2735#[serde(deny_unknown_fields)]
2736pub struct ListenerTls {
2737 #[serde(default = "default_tls_mode")]
2738 pub mode: TlsMode,
2739 /// Path to the PEM certificate chain (termination mode only). Serves as
2740 /// the default/fallback certificate when `certificates` is also set.
2741 #[serde(skip_serializing_if = "Option::is_none")]
2742 pub cert_file: Option<String>,
2743 /// Path to the PEM private key (termination mode only).
2744 #[serde(skip_serializing_if = "Option::is_none")]
2745 pub key_file: Option<String>,
2746 /// Additional SNI-scoped certificate pairs (termination mode only).
2747 /// Each entry serves the listed `server_names`; the single
2748 /// cert_file/key_file pair (if present) is the fallback for unmatched
2749 /// or absent SNI. With no single pair, the first entry is the fallback.
2750 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2751 pub certificates: Vec<TlsCertificate>,
2752 /// SNI routing rules for passthrough mode: each entry maps its
2753 /// `server_names` to an upstream (by name) that receives the raw TLS
2754 /// bytes. Rejected in terminate mode.
2755 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2756 pub sni_routes: Vec<SniRoute>,
2757 /// PEM file of CA certificates that CLIENT certificates must chain
2758 /// to (#124, termination mode only). When set, the listener
2759 /// REQUESTS a client certificate and verifies any presented one
2760 /// against this bundle; a connection without a certificate is still
2761 /// accepted (other credential families apply) — the verified
2762 /// certificate is matched against consumers' `mtls` credentials to
2763 /// resolve an identity, and an UNVERIFIED certificate fails the
2764 /// handshake (mTLS authn never sees it). Rejected in passthrough
2765 /// mode (the TLS layer is not terminated, so client certificates
2766 /// cannot be verified). The file must exist and be readable at
2767 /// config compile time; validation names this field otherwise.
2768 #[serde(default, skip_serializing_if = "Option::is_none")]
2769 pub client_ca_file: Option<String>,
2770 /// 0-RTT (early data) policy for HTTP/3 listeners (DW-088). When
2771 /// `accept`, the QUIC handshake accepts 0-RTT early data from
2772 /// clients with a saved session ticket — at the cost of replay
2773 /// risk. Non-idempotent requests (POST, PUT, PATCH, DELETE) are
2774 /// rejected under 0-RTT with a 425 Too Early response (the client
2775 /// retries over a 1-RTT connection). When `reject` (the default),
2776 /// 0-RTT early data is refused and the client must complete a
2777 /// full 1-RTT handshake before sending a request. Only meaningful
2778 /// on `protocol: h3` listeners; ignored (and rejected by
2779 /// validation) on H1/H2 listeners.
2780 #[serde(default, skip_serializing_if = "is_default_zero_rtt")]
2781 pub zero_rtt: ZeroRttPolicy,
2782 /// DW-105: opt in to post-quantum hybrid key exchange
2783 /// (X25519+ML-KEM) for this listener. When `true`, the
2784 /// X25519+ML-KEM hybrid kx group is
2785 /// prepended to the rustls provider's kx group list, PREFERRING
2786 /// the hybrid group while keeping the classical X25519 fallback
2787 /// for non-PQ clients. Compiled into every build (no cargo
2788 /// feature). EXPERIMENTAL: the rustls PQ
2789 /// API is not yet stable. Rejected when combined with FIPS mode
2790 /// (ML-KEM is not on the FIPS-validated list for aws-lc-rs).
2791 /// Terminate mode only (passthrough does not terminate TLS, so
2792 /// the kx group list is irrelevant); validation rejects `pq: true`
2793 /// on a passthrough listener.
2794 #[serde(default, skip_serializing_if = "is_false")]
2795 pub pq: bool,
2796 /// SEC-02: ACME / Let's Encrypt automation. When present, the
2797 /// gateway automatically obtains and renews certificates for the
2798 /// configured domains from the ACME directory. Terminates mode
2799 /// only (passthrough does not terminate TLS). When `acme` is
2800 /// present, `cert_file`/`key_file`/`certificates` are optional
2801 /// (ACME-managed certs serve as the fallback); manual certs still
2802 /// take precedence for their SNI names. See [`AcmeConfig`].
2803 #[serde(default, skip_serializing_if = "Option::is_none")]
2804 pub acme: Option<AcmeConfig>,
2805}
2806
2807/// SEC-02: ACME (Let's Encrypt) certificate automation configuration.
2808/// The gateway acts as an ACME client: orders certificates for the
2809/// configured domains, completes the challenge (HTTP-01 or TLS-ALPN-01),
2810/// and installs the issued certificates into the hot-reloadable TLS
2811/// termination/SNI resolver. Renewal runs automatically before expiry.
2812#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2813#[serde(deny_unknown_fields)]
2814pub struct AcmeConfig {
2815 /// The domains to obtain certificates for. At least one must be
2816 /// present (validation rejects an empty list).
2817 pub domains: Vec<String>,
2818 /// The ACME directory URL. Defaults to Let's Encrypt production
2819 /// (`https://acme-v02.api.letsencrypt.org/directory`). Set to the
2820 /// staging directory (`https://acme-staging-v02.api.letsencrypt.org/directory`)
2821 /// for testing to avoid rate limits.
2822 #[serde(
2823 default = "default_acme_directory",
2824 skip_serializing_if = "is_default_acme_directory"
2825 )]
2826 pub directory_url: String,
2827 /// Contact email addresses registered with the ACME account. Let's
2828 /// Encrypt requires at least one for production issuance.
2829 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2830 pub contact: Vec<String>,
2831 /// Challenge type: `http-01` (serves a token over HTTP on port 80)
2832 /// or `tls-alpn-01` (serves a challenge cert over TLS-ALPN on port
2833 /// 443). Default `tls-alpn-01` (no separate HTTP listener needed).
2834 #[serde(
2835 default = "default_acme_challenge",
2836 skip_serializing_if = "is_default_acme_challenge"
2837 )]
2838 pub challenge: AcmeChallenge,
2839 /// Use the staging directory instead of production. When true,
2840 /// `directory_url` is overridden to the Let's Encrypt staging
2841 /// endpoint. Staging certs are NOT trusted by browsers — use for
2842 /// testing only. Default false.
2843 #[serde(default, skip_serializing_if = "is_false")]
2844 pub staging: bool,
2845 /// Path to the directory where ACME state is persisted (account
2846 /// key, issued certificates). The gateway must have read/write
2847 /// access. Defaults to `./acme-state`.
2848 #[serde(
2849 default = "default_acme_state_dir",
2850 skip_serializing_if = "is_default_acme_state_dir"
2851 )]
2852 pub state_dir: String,
2853}
2854
2855/// ACME challenge type.
2856#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
2857#[serde(rename_all = "kebab-case")]
2858pub enum AcmeChallenge {
2859 /// HTTP-01 challenge: serves a token over HTTP on port 80. Requires
2860 /// a separate HTTP listener bound to port 80 (or port forwarding).
2861 Http01,
2862 /// TLS-ALPN-01 challenge: serves a challenge certificate over TLS
2863 /// with the `acme-tls/1` ALPN protocol on port 443. No separate
2864 /// listener needed (the default). Default.
2865 #[default]
2866 TlsAlpn01,
2867}
2868
2869fn default_acme_directory() -> String {
2870 "https://acme-v02.api.letsencrypt.org/directory".to_string()
2871}
2872
2873fn is_default_acme_directory(s: &str) -> bool {
2874 s == default_acme_directory()
2875}
2876
2877fn default_acme_challenge() -> AcmeChallenge {
2878 AcmeChallenge::TlsAlpn01
2879}
2880
2881fn is_default_acme_challenge(c: &AcmeChallenge) -> bool {
2882 *c == AcmeChallenge::TlsAlpn01
2883}
2884
2885fn default_acme_state_dir() -> String {
2886 "./acme-state".to_string()
2887}
2888
2889fn is_default_acme_state_dir(s: &str) -> bool {
2890 s == default_acme_state_dir()
2891}
2892
2893fn is_default_zero_rtt(p: &ZeroRttPolicy) -> bool {
2894 *p == ZeroRttPolicy::Reject
2895}
2896
2897/// 0-RTT (early data) acceptance policy for HTTP/3 (DW-088).
2898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
2899#[serde(rename_all = "lowercase")]
2900pub enum ZeroRttPolicy {
2901 /// Accept 0-RTT early data. Non-idempotent requests are rejected
2902 /// with 425 Too Early (replay-safe).
2903 Accept,
2904 /// Reject 0-RTT early data (default). The client must complete a
2905 /// full 1-RTT handshake before sending a request.
2906 #[default]
2907 Reject,
2908}
2909
2910/// One SNI-scoped certificate pair for TLS termination.
2911#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2912#[serde(deny_unknown_fields)]
2913pub struct TlsCertificate {
2914 /// Server names (SNI values) this certificate answers. Exact match,
2915 /// case-insensitive per rustls.
2916 pub server_names: Vec<String>,
2917 /// Path to the PEM certificate chain.
2918 pub cert_file: String,
2919 /// Path to the PEM private key.
2920 pub key_file: String,
2921}
2922
2923/// One SNI-to-upstream routing rule for TLS passthrough.
2924#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2925#[serde(deny_unknown_fields)]
2926pub struct SniRoute {
2927 /// Server names (SNI values) routed to `upstream`. Exact match.
2928 pub server_names: Vec<String>,
2929 /// Name of the upstream that receives the spliced TLS stream.
2930 pub upstream: String,
2931}
2932
2933/// L4 proxying configuration for a `tcp` or `udp` listener (DW-103).
2934///
2935/// The listener accepts raw L4 connections and splices them byte-for-byte
2936/// to an upstream endpoint. When `sni_routing` is true (TCP only), the
2937/// dispatcher peeks the TLS ClientHello SNI (reusing the SNI extraction
2938/// from DW-008 TLS passthrough) and selects the upstream from the
2939/// listener's `tls.sni_routes` -- the same routing table passthrough
2940/// uses. When `sni_routing` is false, the configured `upstream` receives
2941/// every connection.
2942#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2943#[serde(deny_unknown_fields)]
2944pub struct L4Config {
2945 /// Name of the upstream that receives the spliced stream. Required
2946 /// when `sni_routing` is false; when `sni_routing` is true the
2947 /// upstream is selected per-connection from `tls.sni_routes`, and
2948 /// this field is the fallback for connections with no SNI or an
2949 /// unmatched name (absent = close the connection on no match).
2950 #[serde(default, skip_serializing_if = "Option::is_none")]
2951 pub upstream: Option<String>,
2952 /// Peek the TLS ClientHello SNI and route the connection to the
2953 /// upstream matched by the listener's `tls.sni_routes` (reusing the
2954 /// SNI extraction from DW-008 TLS passthrough). TCP only; rejected
2955 /// on UDP listeners by validation (UDP has no byte-stream handshake
2956 /// to peek). When false, every connection is spliced to the
2957 /// configured `upstream`.
2958 #[serde(default, skip_serializing_if = "is_false")]
2959 pub sni_routing: bool,
2960 /// Idle timeout in seconds for an established splice. When neither
2961 /// side sends data for this duration, the splice is closed. Absent
2962 /// (or 0) means no idle timeout (the splice runs until either side
2963 /// closes). Bounded by validation to at most 1 hour.
2964 #[serde(default, skip_serializing_if = "Option::is_none")]
2965 pub idle_timeout_s: Option<u64>,
2966 /// DP-01 (#234): UDP session idle timeout in seconds. A UDP session
2967 /// (per-client upstream socket) that receives no datagrams for this
2968 /// duration is evicted from the session table. Default 30s. UDP
2969 /// only; ignored for TCP listeners.
2970 #[serde(default, skip_serializing_if = "Option::is_none")]
2971 pub session_timeout_s: Option<u64>,
2972 /// DP-01 (#234): maximum concurrent UDP sessions. When the session
2973 /// table is full, the oldest idle session is evicted (LRU). Default
2974 /// 4096. UDP only; ignored for TCP listeners.
2975 #[serde(default, skip_serializing_if = "Option::is_none")]
2976 pub max_sessions: Option<usize>,
2977}
2978
2979fn default_tls_mode() -> TlsMode {
2980 TlsMode::Terminate
2981}
2982
2983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2984#[serde(rename_all = "lowercase")]
2985pub enum TlsMode {
2986 Terminate,
2987 Passthrough,
2988}
2989
2990/// Match rules (path/host/method/header) plus action and attached policies.
2991#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2992#[serde(deny_unknown_fields)]
2993pub struct Route {
2994 pub name: String,
2995 /// Name of the service this route targets.
2996 pub service: String,
2997 pub r#match: RouteMatch,
2998 pub action: RouteAction,
2999 /// Attached policy names. All applicable levels' rules AND together
3000 /// (see the rate-limiter module docs); the resolution order is
3001 /// consumer > route > service > listener > global.
3002 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3003 pub policies: Vec<String>,
3004 /// Request priority class for load shedding (DW-016): 0 (lowest) to 10
3005 /// (highest); absent means the default 5. When the gateway concurrency
3006 /// cap is saturated, requests at `high_priority` (>= 8) draw from a
3007 /// small reserved sub-allowance of the cap that lower-priority traffic
3008 /// cannot use (see `proxy` module docs); preemption is impossible, so
3009 /// normal traffic is shed first rather than displaced. Validation
3010 /// rejects values above 10.
3011 #[serde(default, skip_serializing_if = "Option::is_none")]
3012 pub priority: Option<u8>,
3013 /// Require authenticated requests (DW-019): a request that arrives
3014 /// WITHOUT a recognized credential (or with an invalid one) is
3015 /// rejected with 401 and a `WWW-Authenticate` challenge. Absent
3016 /// (the default) allows anonymous traffic through; note that an
3017 /// INVALID presented credential is always rejected with 401
3018 /// regardless of this flag.
3019 #[serde(default, skip_serializing_if = "is_false")]
3020 pub auth_required: bool,
3021 /// Route-scoped CORS policy (DW-027, feature analysis 4.14): controls
3022 /// how the gateway answers browser cross-origin requests on this
3023 /// route. When present, a CORS PREFLIGHT (`OPTIONS` carrying both
3024 /// `Origin` and `Access-Control-Request-Method`) is answered by the
3025 /// gateway itself, before authentication and never forwarded
3026 /// upstream; actual (non-preflight) responses carry the policy's
3027 /// CORS headers. Non-CORS requests are untouched. A preflight on a
3028 /// route whose `match.methods` list excludes `OPTIONS` never matches
3029 /// the route (the documented request-path order applies first) —
3030 /// include `OPTIONS` in the method list of CORS routes.
3031 #[serde(default, skip_serializing_if = "Option::is_none")]
3032 pub cors: Option<Cors>,
3033 /// Route-scoped response compression (DW-027, feature analysis 4.13):
3034 /// negotiates gzip/brotli/zstd against the request's `Accept-Encoding`
3035 /// and compresses the response body per policy. Streaming-safe: the
3036 /// body is compressed chunk-by-chunk with a per-chunk flush, never
3037 /// buffered whole. Disabled (absent) by default — the dataplane
3038 /// forwards bytes untouched.
3039 #[serde(default, skip_serializing_if = "Option::is_none")]
3040 pub compression: Option<Compression>,
3041 /// Route-scoped request size limits (DW-027, feature analysis 4.16):
3042 /// per-route body and header caps enforced after route resolution.
3043 /// Over-limit requests are rejected by the gateway with the JSON
3044 /// error envelope (413 for bodies, 431 for headers) before any
3045 /// upstream contact when the size is declared up front
3046 /// (`Content-Length`); streaming bodies of unknown length are
3047 /// aborted as soon as they cross the cap.
3048 #[serde(default, skip_serializing_if = "Option::is_none")]
3049 pub limits: Option<RequestLimits>,
3050 /// Route-level authorization rules (DW-020, feature analysis 4.7).
3051 /// Absent (the default) imposes no authorization on the route. A
3052 /// PRESENT-but-entirely-empty block (no consumers, groups, scopes,
3053 /// claims, or `ip_acl`) is likewise a no-op at evaluation time —
3054 /// it imposes nothing, exactly like an absent one — but it is
3055 /// always a config-authoring mistake (a rule block with no rules),
3056 /// so validation REJECTS it: omit the block instead of emptying it.
3057 /// When present (and non-empty), the rules are evaluated after
3058 /// authentication and BEFORE
3059 /// rate limiting; see [`Authz`] for the rule semantics. Presence of
3060 /// any identity rule implies authentication (an anonymous request is
3061 /// rejected 401); an `ip_acl`-only block is the one case that can
3062 /// permit anonymous access (from an allowed IP). Precedence across
3063 /// levels (consumer > route > service > listener > global) is
3064 /// resolved by the `authz` module's resolver; every level has a
3065 /// config attachment point (`consumers[].authorization`,
3066 /// `services[].authorization`, `listeners[].authorization`, and the
3067 /// gateway-level `authorization`).
3068 #[serde(default, skip_serializing_if = "Option::is_none")]
3069 pub authorization: Option<Authz>,
3070 /// API deprecation policy (DW-048): emits the standard deprecation
3071 /// signal headers on this route's responses. See [`Deprecation`].
3072 #[serde(default, skip_serializing_if = "Option::is_none")]
3073 pub deprecation: Option<Deprecation>,
3074 /// Route maintenance mode (DW-041): when present, every request the
3075 /// route matches is answered by the GATEWAY with a 503 carrying
3076 /// `Retry-After` and the JSON error envelope (`maintenance` code) —
3077 /// before route limits, CORS preflight handling (preflights
3078 /// themselves are exempt, see [`Maintenance`]), authentication, and
3079 /// the route action (proxy, redirect, and respond actions alike
3080 /// never run). Toggled by config reload like every route field:
3081 /// publish a generation with the block to enter maintenance and one
3082 /// without it to leave. See [`Maintenance`].
3083 #[serde(default, skip_serializing_if = "Option::is_none")]
3084 pub maintenance: Option<Maintenance>,
3085 /// Request/response transforms (DW-028, feature analysis 4.12):
3086 /// header and query manipulation on the forwarded request and the
3087 /// route's responses, plus the size-capped JSON-pointer body
3088 /// transform — the transforms surface's ONE explicitly buffering
3089 /// piece. Absent (the default): bytes and headers flow untouched.
3090 /// See [`Transforms`].
3091 #[serde(default, skip_serializing_if = "Option::is_none")]
3092 pub transforms: Option<Transforms>,
3093 /// Security-header injection (DW-028, feature analysis 5-Security):
3094 /// HSTS, `X-Content-Type-Options: nosniff`,
3095 /// `Content-Security-Policy`, and `X-Frame-Options`, stamped on
3096 /// EVERY response the route emits (action responses and gateway
3097 /// short-circuits alike), replacing any upstream-sent values — the
3098 /// gateway is the source of truth at its edge. See
3099 /// [`SecurityHeaders`].
3100 #[serde(default, skip_serializing_if = "Option::is_none")]
3101 pub security_headers: Option<SecurityHeaders>,
3102 /// Opt out of the gateway-level `default_security_headers`
3103 /// baseline (SEC-09, #213): when true, a route with no
3104 /// `security_headers` block ships WITHOUT the gateway's default
3105 /// security headers. A route that carries its own
3106 /// `security_headers` block always uses it (the block fully
3107 /// replaces the default); this flag is for the rare route that
3108 /// deliberately needs no security headers at all. Default:
3109 /// false (inherit the default when present).
3110 #[serde(default, skip_serializing_if = "is_false")]
3111 pub security_headers_opt_out: bool,
3112 /// Response field masking (DW-029, feature analysis 5-Security):
3113 /// redacts the named RFC 6901 JSON pointers from the route's
3114 /// responses — the floor `fields` for every consumer, plus the
3115 /// `groups` additions for members of each consumer group — before
3116 /// any other body-handling stage. The redaction is fail-closed: a
3117 /// response the gateway cannot prove clean (encoded, non-JSON,
3118 /// over the cap, unparseable, or missing a configured pointer)
3119 /// answers 502, never a passthrough. See [`Masking`].
3120 #[serde(default, skip_serializing_if = "Option::is_none")]
3121 pub masking: Option<Masking>,
3122 /// Route-scoped response caching (DW-037, feature analysis
3123 /// 5-Protocol): opts the route's cacheable GET traffic into the
3124 /// local response cache behind the `CacheStore` extension seam.
3125 /// Absent (the default): responses are never cached or buffered.
3126 /// The cache key folds in the route, the consumer, the inbound
3127 /// path + query, and the effective vary set — masked (DW-029) and
3128 /// consumer-group-specific variants never cross consumers. Stored
3129 /// bodies are the POST-masking/transform bytes (replay consistency
3130 /// under route-scoped policies), stored identity (never compressed;
3131 /// compression re-negotiates per request), and the decoration tail
3132 /// (compression onward) re-runs on every replay. See
3133 /// [`RouteCache`] and `dataplane::response_cache`.
3134 #[serde(default, skip_serializing_if = "Option::is_none")]
3135 pub cache: Option<RouteCache>,
3136 /// Per-route METHOD ALLOWLIST (DW-030): when non-empty, a request
3137 /// that resolved this route but whose method is not in the list is
3138 /// answered by the gateway with `405 Method Not Allowed` plus an
3139 /// `Allow` header listing the configured methods (RFC 9110 10.2.1).
3140 /// Distinct from `match.methods`, which gates ROUTE RESOLUTION (a
3141 /// miss falls through to 404 / other routes); this list gates the
3142 /// ALREADY-matched route. Placement (frozen): after route
3143 /// resolution and the maintenance 503, before the route limits and
3144 /// CORS preflight short-circuit and authentication — the allowlist
3145 /// is a statement about the route, not the request's shape (the
3146 /// DW-041 maintenance ordering argument), and a CORS PREFLIGHT
3147 /// (`OPTIONS` + preflight markers) is exempt exactly like the
3148 /// maintenance 503 (the preflight asks about the GATEWAY's
3149 /// cross-origin policy, not the resource; failing it would hide the
3150 /// CORS answer from the browser). Matching is case-insensitive, the
3151 /// same comparison `match.methods` uses. HEAD is NOT implicitly
3152 /// granted by GET — a route supporting HEAD alongside GET lists
3153 /// both (the allowlist is exhaustive by design; implicit grants
3154 /// would leak methods the operator never named). Empty (the
3155 /// default) allows every method, exactly like pre-DW-030.
3156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3157 pub methods: Vec<String>,
3158 /// Service-level objectives (DW-052, feature analysis 4.17): the
3159 /// route's availability target and optional latency objective,
3160 /// exported as `dwara_slo_burn_rate` / `dwara_slo_target` metrics
3161 /// over process-local 5m and 1h sliding windows for multiwindow
3162 /// burn-rate alerting. Availability counts a request BAD only when
3163 /// the GATEWAY answers 5xx (client errors are the caller's policy,
3164 /// not this route's availability). Absent (the default): the route
3165 /// records nothing and exports no SLO series. See [`RouteSlo`].
3166 #[serde(default, skip_serializing_if = "Option::is_none")]
3167 pub slo: Option<RouteSlo>,
3168 /// WebSocket policy (DW-039, feature analysis 4.13): origin
3169 /// allowlisting before an upgrade is offered and post-upgrade
3170 /// frame-rate policing on the tunneled connection. Absent (the
3171 /// default): upgrades are transparent — any origin, unlimited
3172 /// frame rate, the generic 101 tunnel unchanged. See
3173 /// [`RouteWebsocket`].
3174 #[serde(default, skip_serializing_if = "Option::is_none")]
3175 pub websocket: Option<RouteWebsocket>,
3176 /// WAF-lite heuristic filtering (DW-051): pattern-matching
3177 /// inspection of the request path, query string, selected
3178 /// headers, and body (when JSON or form-urlencoded) for common
3179 /// SQL-injection, XSS, and path-traversal signatures. Absent (the
3180 /// default): no inspection. When `enabled` is true, the WAF runs
3181 /// after the route method allowlist and before the route limits —
3182 /// a content filter that rejects malicious requests before any
3183 /// resource is spent on auth or rate limiting. `dry_run` evaluates
3184 /// and logs matches without blocking (audit-log-only mode). See
3185 /// [`RouteWaf`].
3186 #[serde(default, skip_serializing_if = "Option::is_none")]
3187 pub waf: Option<RouteWaf>,
3188 /// GraphQL awareness (DW-099): query depth/complexity limits and
3189 /// optional persisted-query enforcement for routes that front a
3190 /// GraphQL server. When enabled, the gateway rejects abusive
3191 /// queries before the route limits and authentication. Absent
3192 /// (the default): no GraphQL checks. Compiled into every build
3193 /// (no cargo feature). See [`RouteGraphql`].
3194 #[serde(default, skip_serializing_if = "Option::is_none")]
3195 pub graphql: Option<RouteGraphql>,
3196 /// gRPC-Web framing translation + JSON-to-gRPC transcoding (DW-101):
3197 /// when enabled, the gateway translates gRPC-Web framing to native
3198 /// gRPC for the upstream and wraps the response back. When
3199 /// transcoding is enabled, JSON requests are translated to/from
3200 /// protobuf using .proto descriptors supplied via config. Absent
3201 /// (the default): no translation. The block parses and validates in
3202 /// every build (no cargo feature); the runtime translation is
3203 /// scaffolded and not yet dispatched from the request path. See
3204 /// [`GrpcWeb`].
3205 #[serde(default, skip_serializing_if = "Option::is_none")]
3206 pub grpc_web: Option<GrpcWeb>,
3207 /// Protocol translation (DW-100): translates the request/response
3208 /// bodies between two wire protocols on this route's forward and
3209 /// response paths. When enabled, the gateway converts the client's
3210 /// body to the upstream's wire format and the upstream's response
3211 /// back to the client's wire format. Absent (the default): no
3212 /// translation. The block parses and validates in every build (no
3213 /// cargo feature); the runtime translation is scaffolded and not
3214 /// yet dispatched from the request path.
3215 /// See [`Translation`].
3216 #[serde(default, skip_serializing_if = "Option::is_none")]
3217 pub translation: Option<Translation>,
3218 /// Request validation (DW-047): an optional JSON-schema block the
3219 /// gateway validates the request body against before the route
3220 /// action runs. A mismatch answers 400 `validation_failed` with the
3221 /// offending paths in the JSON error envelope; a match proceeds to
3222 /// the action (proxy, mock, ...). This is a SIMPLER surface than
3223 /// full OpenAPI parameter/header/query validation — it covers the
3224 /// body only, the common case for write endpoints. The schema is a
3225 /// minimal JSON-Schema subset (type, required, properties, items,
3226 /// enum, minimum, maximum, minLength, maxLength); `$ref` is not
3227 /// supported. See [`RequestValidation`].
3228 #[serde(default, skip_serializing_if = "Option::is_none")]
3229 pub request_validation: Option<RequestValidation>,
3230 /// OpenAPI import metadata (DW-047): when this route was generated
3231 /// by `dwara import openapi`, this block records the source
3232 /// operation's `operationId`, `summary`, and `tags` for
3233 /// traceability. It has no runtime effect — the gateway ignores it
3234 /// — but it survives round-trips so the operator can audit which
3235 /// spec operation a route came from. Absent for hand-authored
3236 /// routes.
3237 #[serde(default, skip_serializing_if = "Option::is_none")]
3238 pub openapi: Option<OpenApiMeta>,
3239 /// Shadow traffic mirroring (DW-062): fire-and-forget duplicate
3240 /// requests to a mirror upstream. The mirror response is discarded
3241 /// and never impacts the primary request's latency. A percentage
3242 /// controls sampling (0 = never, 100 = always). Absent (the
3243 /// default): no mirroring. See [`MirrorConfig`].
3244 #[serde(default, skip_serializing_if = "Option::is_none")]
3245 pub mirror: Option<MirrorConfig>,
3246 /// Fault injection (DW-062): percentage-based delays and aborts for
3247 /// chaos testing. A delay injects a fixed latency before the
3248 /// request is forwarded; an abort short-circuits with a configured
3249 /// status without contacting the upstream. Both are sampled by
3250 /// percentage (0 = never, 100 = always). Absent (the default): no
3251 /// fault injection. See [`FaultInjection`].
3252 #[serde(default, skip_serializing_if = "Option::is_none")]
3253 pub fault_injection: Option<FaultInjection>,
3254 /// Plugin names to run on this route (DW-055). Each name must
3255 /// reference a plugin defined in the top-level `plugins` list.
3256 /// Plugins run in declaration order at their declared phases
3257 /// (request-path dispatch, DW-157). Routes without plugins take
3258 /// the untouched fast path.
3259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3260 pub plugins: Vec<String>,
3261 /// SCALE-12 (#191): per-route filter-chain ordering and dry-run
3262 /// overrides. When absent, the global `filter_chain` config (or
3263 /// the default order) applies. See [`FilterChainConfig`].
3264 #[serde(default, skip_serializing_if = "Option::is_none")]
3265 pub filter_chain: Option<FilterChainConfig>,
3266 /// SEC-05: OIDC browser login flow as a route auth mode. When
3267 /// present, the gateway acts as an OIDC relying party: unauthenticated
3268 /// browser requests are redirected to the IdP's authorization
3269 /// endpoint, and the callback (`redirect_uri`) exchanges the
3270 /// authorization code for tokens, sets a session cookie, and
3271 /// redirects the user back to the original URL. Subsequent requests
3272 /// carry the session cookie (validated by the gateway). Absent (the
3273 /// default): no browser login flow (the route uses its normal auth
3274 /// mode). See [`OidcLoginConfig`].
3275 #[serde(default, skip_serializing_if = "Option::is_none")]
3276 pub oidc_login: Option<OidcLoginConfig>,
3277}
3278
3279/// SEC-05: OIDC browser login flow configuration for a route. The
3280/// gateway redirects unauthenticated browser requests to the IdP,
3281/// exchanges the authorization code for tokens, sets a session cookie,
3282/// and validates the cookie on subsequent requests.
3283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3284#[serde(deny_unknown_fields)]
3285pub struct OidcLoginConfig {
3286 /// The OIDC provider name (must match an entry in
3287 /// `gateway.oidc_providers`).
3288 pub provider: String,
3289 /// The redirect URI the IdP sends the authorization code to. This
3290 /// must be a path on the gateway (e.g. `/auth/callback`) and must
3291 /// be registered with the IdP. The gateway handles the callback
3292 /// automatically when this route is matched.
3293 pub redirect_uri: String,
3294 /// The session cookie name. Default `dwara_session`. The cookie is
3295 /// `HttpOnly`, `Secure` (when the listener is TLS), `SameSite=Lax`,
3296 /// and carries a signed session ID.
3297 #[serde(
3298 default = "default_oidc_session_cookie",
3299 skip_serializing_if = "is_default_oidc_session_cookie"
3300 )]
3301 pub session_cookie: String,
3302 /// Session lifetime in seconds (default 3600 = 1 hour). After this
3303 /// duration the cookie expires and the user must re-authenticate.
3304 #[serde(
3305 default = "default_oidc_session_ttl_s",
3306 skip_serializing_if = "is_default_oidc_session_ttl_s"
3307 )]
3308 pub session_ttl_s: u64,
3309 /// The post-logout redirect URL. When the user visits the logout
3310 /// path (the redirect URI with `/logout` appended, e.g.
3311 /// `/auth/callback/logout`), the session cookie is cleared and the
3312 /// user is redirected to this URL. Default: the IdP's
3313 /// `end_session_endpoint` (from discovery) with
3314 /// `post_logout_redirect_uri` set to the gateway's root.
3315 #[serde(default, skip_serializing_if = "Option::is_none")]
3316 pub post_logout_url: Option<String>,
3317}
3318
3319fn default_oidc_session_cookie() -> String {
3320 "dwara_session".to_string()
3321}
3322
3323fn is_default_oidc_session_cookie(s: &str) -> bool {
3324 s == default_oidc_session_cookie()
3325}
3326
3327fn default_oidc_session_ttl_s() -> u64 {
3328 3600
3329}
3330
3331fn is_default_oidc_session_ttl_s(v: &u64) -> bool {
3332 *v == 3600
3333}
3334
3335/// OpenAPI import metadata attached to a route (DW-047). No runtime
3336/// effect; purely for traceability from the generated config back to
3337/// the source spec operation.
3338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3339#[serde(deny_unknown_fields)]
3340pub struct OpenApiMeta {
3341 /// The `operationId` from the source OpenAPI operation (may be
3342 /// absent if the operation had none — the route name is then a
3343 /// path+method fallback).
3344 #[serde(default, skip_serializing_if = "Option::is_none")]
3345 pub operation_id: Option<String>,
3346 /// The `summary` from the source OpenAPI operation.
3347 #[serde(default, skip_serializing_if = "Option::is_none")]
3348 pub summary: Option<String>,
3349 /// The `tags` from the source OpenAPI operation.
3350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3351 pub tags: Vec<String>,
3352 /// The HTTP method of the source operation (uppercased).
3353 pub method: String,
3354 /// The path template from the source OpenAPI spec (e.g.
3355 /// `/pets/{id}`).
3356 pub path: String,
3357}
3358
3359/// Shadow traffic mirroring configuration (DW-062,
3360/// `routes[].mirror`). Fire-and-forget duplicate requests to a named
3361/// mirror upstream; the mirror response is discarded and never impacts
3362/// the primary request's latency. A percentage controls sampling.
3363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3364#[serde(deny_unknown_fields)]
3365pub struct MirrorConfig {
3366 /// Name of the upstream to receive mirrored (shadow) requests. The
3367 /// mirror upstream is separate from the route's service upstream —
3368 /// it exists solely to receive shadow traffic. The upstream must
3369 /// exist in the `upstreams` list.
3370 pub upstream: String,
3371 /// Percentage of requests to mirror (0..=100). 0 mirrors nothing;
3372 /// 100 mirrors every request. The sampling is per-request (a
3373 /// random draw against this percentage).
3374 pub percentage: u8,
3375 /// Hard timeout for the mirror request in milliseconds. If the
3376 /// mirror upstream does not respond within this duration, the
3377 /// mirror request is abandoned. Default 2000 (2 seconds). The
3378 /// mirror is fire-and-forget, so this timeout never affects the
3379 /// primary request.
3380 #[serde(
3381 default = "default_mirror_timeout_ms",
3382 skip_serializing_if = "is_default_mirror_timeout_ms"
3383 )]
3384 pub timeout_ms: u64,
3385}
3386
3387fn default_mirror_timeout_ms() -> u64 {
3388 2000
3389}
3390
3391fn is_default_mirror_timeout_ms(v: &u64) -> bool {
3392 *v == 2000
3393}
3394
3395/// Fault injection configuration (DW-062, `routes[].fault_injection`).
3396/// Percentage-based delays and aborts for chaos testing. A delay
3397/// injects a fixed latency before the request is forwarded; an abort
3398/// short-circuits with a configured status without contacting the
3399/// upstream. Both may be present simultaneously — the abort is
3400/// evaluated first, and if it does not fire, the delay is applied.
3401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3402#[serde(deny_unknown_fields)]
3403pub struct FaultInjection {
3404 /// Abort injection: return a configured status without forwarding.
3405 #[serde(default, skip_serializing_if = "Option::is_none")]
3406 pub abort: Option<FaultAbort>,
3407 /// Delay injection: inject a fixed delay before forwarding.
3408 #[serde(default, skip_serializing_if = "Option::is_none")]
3409 pub delay: Option<FaultDelay>,
3410}
3411
3412/// Abort fault injection (DW-062): short-circuit the request with a
3413/// configured HTTP status, sampled by percentage.
3414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3415#[serde(deny_unknown_fields)]
3416pub struct FaultAbort {
3417 /// Percentage of requests to abort (0..=100).
3418 pub percentage: u8,
3419 /// HTTP status code to return (100..=599).
3420 pub status: u16,
3421}
3422
3423/// Delay fault injection (DW-062): inject a fixed delay before
3424/// forwarding the request, sampled by percentage.
3425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3426#[serde(deny_unknown_fields)]
3427pub struct FaultDelay {
3428 /// Percentage of requests to delay (0..=100).
3429 pub percentage: u8,
3430 /// Fixed delay in milliseconds (1..=300000, i.e. up to 5 minutes).
3431 pub fixed_ms: u64,
3432}
3433
3434/// Route-scoped request-body validation (DW-047,
3435/// `routes[].request_validation`).
3436///
3437/// A minimal JSON-Schema subset the gateway validates the request body
3438/// against before the route action runs. A mismatch answers 400
3439/// `validation_failed` (the JSON error envelope carries the offending
3440/// instance paths); a match proceeds normally. The schema is compiled
3441/// at config publish time (an invalid schema fails validation); the
3442/// body is buffered up to the route's `limits.max_body_bytes` (or a
3443/// 1 MiB default when no limit is set) for validation, then replayed
3444/// to the action. See [`JsonSchema`] for the supported keywords.
3445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3446#[serde(deny_unknown_fields)]
3447pub struct RequestValidation {
3448 /// The JSON Schema the request body must satisfy. A minimal subset
3449 /// of JSON Schema Draft 2020-12 is supported: `type`, `required`,
3450 /// `properties`, `items`, `enum`, `minimum`, `maximum`,
3451 /// `minLength`, `maxLength`, and `additionalProperties` (bool or a
3452 /// schema). `$ref` is NOT supported (inline your schemas). An
3453 /// invalid schema fails config validation at publish time.
3454 pub body_schema: BodySchema,
3455 /// SEC-14: dry-run mode. When true, the gateway evaluates the
3456 /// schema and records violations (log + metric) but does NOT reject
3457 /// the request — the body is forwarded to the upstream even when it
3458 /// fails validation. Useful for rolling out a new schema without
3459 /// breaking existing callers. Default false (reject on mismatch).
3460 #[serde(default, skip_serializing_if = "is_false")]
3461 pub dry_run: bool,
3462}
3463
3464/// A minimal JSON-Schema subset (DW-047) for request-body validation.
3465///
3466/// Supports the keywords sufficient for typical OpenAPI-derived body
3467/// schemas: `type`, `required`, `properties`, `items`, `enum`,
3468/// `minimum`, `maximum`, `minLength`, `maxLength`, and
3469/// `additionalProperties`. Nested objects and arrays compose
3470/// recursively. Unknown keywords are IGNORED (forward compatibility
3471/// with richer specs) EXCEPT at the root `RequestValidation.body_schema`
3472/// where `deny_unknown_fields` on the surrounding config struct already
3473/// rejects typos in the wrapper; this struct itself is permissive so a
3474/// real-world schema carrying `description`, `example`, or `$schema`
3475/// does not fail to parse.
3476#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3477pub struct BodySchema {
3478 /// The JSON type: `object`, `array`, `string`, `number`,
3479 /// `integer`, `boolean`, or `null`.
3480 #[serde(default, skip_serializing_if = "Option::is_none")]
3481 pub r#type: Option<String>,
3482 /// For `object`: the required property names.
3483 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3484 pub required: Vec<String>,
3485 /// For `object`: named property schemas.
3486 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
3487 pub properties: std::collections::BTreeMap<String, BodySchema>,
3488 /// For `object`: `true` (default, allows extra properties), `false`
3489 /// (rejects any property not in `properties`), or a schema every
3490 /// extra property must satisfy.
3491 #[serde(
3492 default,
3493 skip_serializing_if = "Option::is_none",
3494 rename = "additionalProperties"
3495 )]
3496 pub additional_properties: Option<Box<AdditionalProperties>>,
3497 /// For `array`: the schema every element must satisfy.
3498 #[serde(default, skip_serializing_if = "Option::is_none")]
3499 pub items: Option<Box<BodySchema>>,
3500 /// An enum of allowed values (compared by JSON equality).
3501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3502 pub r#enum: Vec<serde_json::Value>,
3503 /// Inclusive numeric minimum (for `number`/`integer`).
3504 #[serde(default, skip_serializing_if = "Option::is_none")]
3505 pub minimum: Option<f64>,
3506 /// Inclusive numeric maximum (for `number`/`integer`).
3507 #[serde(default, skip_serializing_if = "Option::is_none")]
3508 pub maximum: Option<f64>,
3509 /// Minimum string length (for `string`).
3510 #[serde(default, skip_serializing_if = "Option::is_none", rename = "minLength")]
3511 pub min_length: Option<u64>,
3512 /// Maximum string length (for `string`).
3513 #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxLength")]
3514 pub max_length: Option<u64>,
3515}
3516
3517/// The `additionalProperties` keyword accepts either a boolean or a
3518/// nested schema; this union models both.
3519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3520#[serde(untagged)]
3521pub enum AdditionalProperties {
3522 /// A boolean: `true` allows any extra property, `false` rejects
3523 /// any property not listed in `properties`.
3524 Bool(bool),
3525 /// A schema every extra property must satisfy.
3526 Schema(BodySchema),
3527}
3528
3529/// Route-scoped WAF-lite config (DW-051, `routes[].waf`).
3530///
3531/// A lightweight, heuristic-based web application filter: regex pattern
3532/// matching for SQL injection, XSS, and path traversal signatures across
3533/// the request path, query string, selected headers (User-Agent, Referer,
3534/// Cookie, X-Forwarded-For), and body (when the content type is JSON or
3535/// form-urlencoded, up to `max_body_inspect_bytes`). This is NOT a full
3536/// WAF — it is a first-line traffic filter that rejects obvious attack
3537/// signatures before authentication or rate limiting. Per-route opt-in;
3538/// routes without a `waf` block are never inspected.
3539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3540#[serde(deny_unknown_fields)]
3541pub struct RouteWaf {
3542 /// Master switch. Default false: the WAF block is inert even when
3543 /// present (allows staged rollout). When true, the configured
3544 /// filter categories run on every request the route matches.
3545 #[serde(default, skip_serializing_if = "is_false")]
3546 pub enabled: bool,
3547 /// Audit-log-only mode (dry-run synergy, DW-041): when true, the
3548 /// WAF evaluates every filter and LOGS matches (a `dwara::policy`
3549 /// warn event + the `dwara_waf_total{outcome="logged"}` counter)
3550 /// but does NOT block — the request continues to the upstream.
3551 /// Lets an operator observe what the WAF would catch before
3552 /// enforcing it. Default false: matches are blocked with 403.
3553 #[serde(default, skip_serializing_if = "is_false")]
3554 pub dry_run: bool,
3555 /// Which filter categories to run. An empty or absent list
3556 /// defaults to all three (`sqli`, `xss`, `path_traversal`).
3557 /// Validation rejects an enabled WAF with an explicit non-empty
3558 /// list containing an unknown category name.
3559 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3560 pub filters: Vec<String>,
3561 /// Maximum number of body bytes to inspect (0 = do not inspect
3562 /// the body at all; 1..=1048576 = inspect up to this many bytes).
3563 /// Default 131072 (128 KiB). Bodies larger than this limit are
3564 /// inspected only up to the limit (a malicious payload beyond the
3565 /// limit is not caught — the trade-off for a bounded inspection
3566 /// cost). Validation rejects values > 1048576 (1 MiB).
3567 #[serde(
3568 default = "default_waf_max_body_inspect_bytes",
3569 skip_serializing_if = "is_default_waf_max_body_inspect_bytes"
3570 )]
3571 pub max_body_inspect_bytes: u64,
3572 /// Optional custom regex pattern overrides (additional patterns
3573 /// appended to the built-in signatures for each filter category).
3574 /// Each entry is a regex string compiled at config publish time;
3575 /// an invalid regex fails validation. When absent, only the
3576 /// built-in signatures are used.
3577 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3578 pub custom_patterns: Vec<String>,
3579}
3580
3581fn default_waf_max_body_inspect_bytes() -> u64 {
3582 131_072
3583}
3584
3585fn is_default_waf_max_body_inspect_bytes(v: &u64) -> bool {
3586 *v == 131_072
3587}
3588
3589/// SEC-08 (#212): A single OWASP CRS-compatible WAF rule. Each rule
3590/// has a numeric ID (CRS convention: 9xxxx), a severity level, a
3591/// phase (1 = request headers, 2 = request body), a list of tags, a
3592/// regex pattern, and the inspection targets to apply the pattern
3593/// to. Rules are evaluated in order; matching rules add their
3594/// severity to the request's anomaly score. The request is blocked
3595/// when the score exceeds the configured threshold.
3596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3597#[serde(deny_unknown_fields)]
3598pub struct CrsRule {
3599 /// Unique rule ID (CRS convention: 9xxxx). Used for exclusions
3600 /// and logging.
3601 pub id: u32,
3602 /// Rule severity: 1 = critical, 2 = warning, 3 = notice, 4 = info.
3603 /// Higher severity contributes more to the anomaly score.
3604 #[serde(default = "default_crs_severity")]
3605 pub severity: u8,
3606 /// Evaluation phase: 1 = request headers (path, query, headers),
3607 /// 2 = request body. Default 1.
3608 #[serde(default = "default_crs_phase")]
3609 pub phase: u8,
3610 /// Tags for grouping and exclusion (e.g. "OWASP_CRS", "SQL_INJECTION").
3611 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3612 pub tags: Vec<String>,
3613 /// The regex pattern to match against the target values.
3614 pub pattern: String,
3615 /// Which request parts to inspect: "path", "query", "header",
3616 /// "headers" (all selected headers), "body". Default: ["path", "query"].
3617 #[serde(default = "default_crs_targets")]
3618 pub targets: Vec<String>,
3619 /// Optional list of transformations to apply before matching
3620 /// (e.g. "lowercase", "url_decode", "html_entity_decode"). Default: [].
3621 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3622 pub transformations: Vec<String>,
3623}
3624
3625fn default_crs_severity() -> u8 {
3626 2
3627}
3628
3629fn default_crs_phase() -> u8 {
3630 1
3631}
3632
3633fn default_crs_targets() -> Vec<String> {
3634 vec!["path".into(), "query".into()]
3635}
3636
3637/// SEC-08 (#212): Global WAF policy with CRS-compatible rules. When
3638/// enabled, the rules are evaluated on every request (regardless of
3639/// route-level WAF config). The anomaly score is accumulated across
3640/// all matching rules; the request is blocked when the score exceeds
3641/// the threshold. Rule exclusions can disable specific rules by ID or
3642/// tag. The paranoia level controls which rules are active (1 =
3643/// default, 2 = more aggressive, 3 = paranoid, 4 = very paranoid).
3644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3645#[serde(deny_unknown_fields)]
3646pub struct GlobalWaf {
3647 /// Master switch. Default false.
3648 #[serde(default = "default_false", skip_serializing_if = "is_false")]
3649 pub enabled: bool,
3650 /// Audit-log-only mode (same semantics as RouteWaf::dry_run).
3651 #[serde(default = "default_false", skip_serializing_if = "is_false")]
3652 pub dry_run: bool,
3653 /// Paranoia level (1-4). Rules with a higher paranoia level than
3654 /// this are skipped. Default 1.
3655 #[serde(
3656 default = "default_crs_paranoia",
3657 skip_serializing_if = "is_default_crs_paranoia"
3658 )]
3659 pub paranoia_level: u8,
3660 /// Anomaly score threshold for blocking. A request is blocked
3661 /// when the sum of matching rule severities reaches this value.
3662 /// Default 5 (critical rules block immediately, warnings need
3663 /// multiple hits).
3664 #[serde(
3665 default = "default_crs_threshold",
3666 skip_serializing_if = "is_default_crs_threshold"
3667 )]
3668 pub anomaly_threshold: u32,
3669 /// Maximum body bytes to inspect (same semantics as
3670 /// RouteWaf::max_body_inspect_bytes). Default 131072.
3671 #[serde(
3672 default = "default_waf_max_body_inspect_bytes",
3673 skip_serializing_if = "is_default_waf_max_body_inspect_bytes"
3674 )]
3675 pub max_body_inspect_bytes: u64,
3676 /// CRS-compatible rules to evaluate.
3677 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3678 pub rules: Vec<CrsRule>,
3679 /// Rule IDs to exclude (skip even if the rule is in the rules
3680 /// list). Useful for tuning false positives.
3681 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3682 pub exclude_rule_ids: Vec<u32>,
3683 /// Tags to exclude (skip all rules carrying any of these tags).
3684 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3685 pub exclude_tags: Vec<String>,
3686}
3687
3688fn default_crs_paranoia() -> u8 {
3689 1
3690}
3691
3692fn is_default_crs_paranoia(v: &u8) -> bool {
3693 *v == 1
3694}
3695
3696fn default_crs_threshold() -> u32 {
3697 5
3698}
3699
3700fn is_default_crs_threshold(v: &u32) -> bool {
3701 *v == 5
3702}
3703
3704/// Route-scoped GraphQL awareness config (DW-099, `routes[].graphql`).
3705///
3706/// When enabled (compiled into every build; no cargo feature), the
3707/// gateway enforces query depth/complexity limits and optional
3708/// persisted-query enforcement on every request the route matches,
3709/// BEFORE the route limits and authentication. A query whose depth or
3710/// complexity exceeds the configured limits is rejected with 400
3711/// (`graphql_depth_exceeded` or `graphql_complexity_exceeded`); a
3712/// query whose SHA-256 hash is not in the persisted-query store is
3713/// rejected with 400 (`graphql_persisted_query_required`).
3714///
3715/// The check is compiled into every build (no cargo feature): a
3716/// config carrying the block behaves identically in any build.
3717///
3718/// Depth is the maximum brace-nesting level of the query (the
3719/// top-level operation body is depth 1). Complexity is the sum of
3720/// per-field costs: each field's cost is looked up in
3721/// `cost_per_field`, falling back to `complexity_coefficient` (default
3722/// 1) when the field name is not in the map.
3723///
3724/// Both are computed by a
3725/// bounded hand-rolled scanner (no external GraphQL parser dependency).
3726#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3727#[serde(deny_unknown_fields)]
3728pub struct RouteGraphql {
3729 /// Master switch. Default false: the GraphQL block is inert even
3730 /// when present (allows staged rollout). When true, the depth and
3731 /// complexity checks run on every request the route matches.
3732 #[serde(default, skip_serializing_if = "is_false")]
3733 pub enabled: bool,
3734 /// Maximum query depth (max brace-nesting level). A query whose
3735 /// depth exceeds this is rejected with 400
3736 /// `graphql_depth_exceeded`. Must be > 0 when the block is
3737 /// enabled (validation rejects 0 or absent). The top-level
3738 /// operation body is depth 1, so a value of 1 permits only a
3739 /// flat selection set with no nested fields.
3740 pub depth_limit: usize,
3741 /// Maximum query complexity (sum of per-field costs). A query
3742 /// whose complexity exceeds this is rejected with 400
3743 /// `graphql_complexity_exceeded`. Must be > 0 when the block is
3744 /// enabled.
3745 pub complexity_limit: u64,
3746 /// Default cost per field when the field name is not in
3747 /// `cost_per_field` (default 1). The complexity total is
3748 /// `sum(cost_per_field[name].unwrap_or(complexity_coefficient))`.
3749 #[serde(
3750 default = "default_graphql_complexity_coefficient",
3751 skip_serializing_if = "is_default_graphql_complexity_coefficient"
3752 )]
3753 pub complexity_coefficient: u64,
3754 /// Per-field cost overrides. Maps a field name to its explicit
3755 /// cost; any field not in this map uses
3756 /// `complexity_coefficient`. Empty (the default): every field
3757 /// costs `complexity_coefficient`.
3758 #[serde(default, skip_serializing_if = "is_graphql_cost_per_field_empty")]
3759 pub cost_per_field: std::collections::HashMap<String, u64>,
3760 /// Persisted-query enforcement (Apollo APQ + GET-by-hash variant).
3761 /// When enabled, every request's query SHA-256 hash must be in the
3762 /// configured `store` (a map of hash -> query text). A hash not in
3763 /// the store is rejected with 400
3764 /// `graphql_persisted_query_required`. Absent (the default): no
3765 /// persisted-query enforcement.
3766 #[serde(default, skip_serializing_if = "Option::is_none")]
3767 pub persisted_queries: Option<GraphqlPersistedQueries>,
3768 /// Federation passthrough config (DP-11, #256). When enabled, the
3769 /// gateway treats the route as a federation passthrough: it
3770 /// forwards GraphQL requests to a single federated upstream
3771 /// (subgraph or supergraph router) while preserving
3772 /// federation-specific headers and allowing introspection queries
3773 /// (`_service`, `__schema`) to bypass depth/complexity checks.
3774 #[serde(default, skip_serializing_if = "Option::is_none")]
3775 pub federation: Option<GraphqlFederation>,
3776}
3777
3778/// Federation passthrough config (DP-11, #256,
3779/// `routes[].graphql.federation`).
3780///
3781/// When `passthrough` is true, the gateway:
3782/// - Preserves Apollo federation headers on the proxied request
3783/// (`Apollo-Require-Preflight`, `x-apollo-operation-name`,
3784/// `graphql-preflight`, `x-apollo-operation-id`).
3785/// - Allows federation introspection queries (`_service { sdl }`,
3786/// `__schema`, `__type`) to bypass depth and complexity checks.
3787/// - Still applies persisted-query enforcement if configured.
3788///
3789/// This is **passthrough mode**: the gateway does NOT implement query
3790/// planning or subgraph stitching. It proxies to a single upstream
3791/// that is expected to be either a federation router (subgraph-aware)
3792/// or a single subgraph.
3793#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3794#[serde(deny_unknown_fields)]
3795pub struct GraphqlFederation {
3796 /// Master switch. Default false.
3797 #[serde(default, skip_serializing_if = "is_false")]
3798 pub passthrough: bool,
3799}
3800
3801/// Persisted-query enforcement config (DW-099,
3802/// `routes[].graphql.persisted_queries`).
3803///
3804/// When enabled, the gateway enforces that every request's query
3805/// SHA-256 hash is in the config-supplied `store`. This is the Apollo
3806/// APQ (Automatic Persisted Queries) + GET-by-hash variant: the
3807/// client sends the query text, the gateway computes its SHA-256, and
3808/// verifies the hash is known. An external store (Redis, etc.) is a
3809/// future extension point; v1 uses a config-supplied map.
3810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3811#[serde(deny_unknown_fields)]
3812pub struct GraphqlPersistedQueries {
3813 /// Master switch. Default false: the persisted-query block is
3814 /// inert even when present.
3815 #[serde(default, skip_serializing_if = "is_false")]
3816 pub enabled: bool,
3817 /// The persisted-query store: a map of SHA-256 hash (lowercase
3818 /// hex) to query text. Every key must be a non-empty string
3819 /// (validation rejects empty keys). The query text is stored for
3820 /// future use (e.g. GET-by-hash resolution); v1 only checks hash
3821 /// membership.
3822 #[serde(default, skip_serializing_if = "is_graphql_persisted_store_empty")]
3823 pub store: std::collections::HashMap<String, String>,
3824}
3825
3826fn default_graphql_complexity_coefficient() -> u64 {
3827 1
3828}
3829
3830fn is_default_graphql_complexity_coefficient(v: &u64) -> bool {
3831 *v == 1
3832}
3833
3834fn is_graphql_cost_per_field_empty(v: &std::collections::HashMap<String, u64>) -> bool {
3835 v.is_empty()
3836}
3837
3838fn is_graphql_persisted_store_empty(v: &std::collections::HashMap<String, String>) -> bool {
3839 v.is_empty()
3840}
3841
3842/// Route-scoped gRPC-Web policy (DW-101, `routes[].grpc_web`).
3843///
3844/// When enabled, the gateway translates gRPC-Web framing (the browser-
3845/// friendly variant that replaces HTTP/2 trailers with a trailing
3846/// frame) to native gRPC for the upstream, and wraps the native gRPC
3847/// response back into gRPC-Web framing for the browser. Streaming
3848/// (server-streaming and bidi) is supported: each upstream data chunk
3849/// becomes its own gRPC-Web data frame, and the trailers become a
3850/// final gRPC-Web trailer frame.
3851///
3852/// When `transcoding` is enabled, the gateway additionally accepts
3853/// plain JSON requests (Content-Type: application/json) and translates
3854/// them to protobuf wire bytes for the upstream, and translates the
3855/// protobuf response back to JSON. The mapping is driven by .proto
3856/// descriptors supplied via config.
3857///
3858/// The block parses and validates in every build (no cargo feature);
3859/// the runtime framing/transcoding translation is scaffolded and not
3860/// yet dispatched from the request path. See [`GrpcWebTranscoding`] and
3861/// [`GrpcWebDescriptor`].
3862#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3863#[serde(deny_unknown_fields)]
3864pub struct GrpcWeb {
3865 /// Master switch. Default false: the gRPC-Web block is inert even
3866 /// when present (allows staged rollout). When true, the gateway
3867 /// translates gRPC-Web framing to native gRPC on the forward path
3868 /// and back on the response path.
3869 #[serde(default, skip_serializing_if = "is_false")]
3870 pub enabled: bool,
3871 /// JSON-to-gRPC transcoding configuration. When enabled, the
3872 /// gateway accepts JSON requests and translates them to protobuf
3873 /// using the supplied .proto descriptors. Absent (the default):
3874 /// only gRPC-Web framing translation runs (no JSON transcoding).
3875 #[serde(default, skip_serializing_if = "Option::is_none")]
3876 pub transcoding: Option<GrpcWebTranscoding>,
3877}
3878
3879/// gRPC-Web JSON transcoding configuration (DW-101,
3880/// `routes[].grpc_web.transcoding`).
3881///
3882/// Supplies the .proto descriptor files that map HTTP/JSON requests to
3883/// gRPC methods. Each descriptor names a file (a FileDescriptorSet in
3884/// binary protobuf format), a package, and a service. At config publish
3885/// the gateway loads the descriptors, resolves the service's methods,
3886/// and builds a method map keyed by the fully-qualified gRPC method
3887/// path. The google.api.http annotation on each RPC maps an HTTP path +
3888/// verb to the gRPC method.
3889#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3890#[serde(deny_unknown_fields)]
3891pub struct GrpcWebTranscoding {
3892 /// Master switch for transcoding. Default false: even when the
3893 /// `transcoding` block is present, transcoding is inert unless
3894 /// this is true. (Allows the descriptor list to be staged before
3895 /// activation.)
3896 #[serde(default, skip_serializing_if = "is_false")]
3897 pub enabled: bool,
3898 /// The .proto descriptor files to load. Each entry names a
3899 /// FileDescriptorSet file (binary protobuf format, as produced by
3900 /// `protoc --descriptor_set_out=...`), the package, and the
3901 /// service within that package. At least one descriptor is
3902 /// required when transcoding is enabled.
3903 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3904 pub descriptors: Vec<GrpcWebDescriptor>,
3905}
3906
3907/// A .proto descriptor file entry (DW-101,
3908/// `routes[].grpc_web.transcoding.descriptors[]`).
3909#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3910#[serde(deny_unknown_fields)]
3911pub struct GrpcWebDescriptor {
3912 /// Path to the FileDescriptorSet file (binary protobuf format, as
3913 /// produced by `protoc --descriptor_set_out=...`). The file must
3914 /// exist and be readable at config publish time; a missing or
3915 /// unparseable file fails validation.
3916 pub file: String,
3917 /// The protobuf package name (e.g. `my.api`). Used to locate the
3918 /// service's methods in the descriptor set.
3919 pub package: String,
3920 /// The service name within the package (e.g. `MyService`).
3921 pub service: String,
3922}
3923
3924/// Route-scoped protocol translation policy (DW-100,
3925/// `routes[].translation`).
3926///
3927/// When enabled, the gateway translates the request/response bodies
3928/// between two wire protocols on this route's forward and response
3929/// paths. The `kind` selects the translation direction; the `graphql`
3930/// and `soap` sub-blocks carry the direction-specific config.
3931///
3932/// The block parses and validates in every build (no cargo feature);
3933/// the runtime translators are scaffolded and not yet dispatched from
3934/// the request path.
3935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3936#[serde(deny_unknown_fields)]
3937pub struct Translation {
3938 /// The translation direction. Selects which translator runs and
3939 /// which sub-block (`graphql` or `soap`) carries the config.
3940 pub kind: TranslationKind,
3941 /// REST <-> GraphQL translation config. Required when `kind` is
3942 /// `rest_to_graphql` or `graphql_to_rest`; ignored otherwise.
3943 #[serde(default, skip_serializing_if = "Option::is_none")]
3944 pub graphql: Option<GraphqlTranslation>,
3945 /// SOAP/XML translation config. Required when `kind` is
3946 /// `rest_to_soap` or `soap_to_rest`; ignored otherwise.
3947 #[serde(default, skip_serializing_if = "Option::is_none")]
3948 pub soap: Option<SoapTranslation>,
3949}
3950
3951/// The protocol translation direction (DW-100,
3952/// `routes[].translation.kind`).
3953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3954#[serde(rename_all = "snake_case")]
3955pub enum TranslationKind {
3956 /// A REST/JSON client -> GraphQL upstream. The gateway builds a
3957 /// GraphQL query from a config-supplied query template and the
3958 /// REST JSON body.
3959 RestToGraphql,
3960 /// A GraphQL client -> REST upstream. The gateway unwraps the
3961 /// GraphQL `data` envelope into a REST JSON body on the response
3962 /// path.
3963 GraphqlToRest,
3964 /// A REST/JSON client -> SOAP/XML upstream. The gateway wraps the
3965 /// JSON body in a SOAP envelope with the configured operation name
3966 /// and namespace.
3967 RestToSoap,
3968 /// A SOAP/XML client -> REST/JSON upstream. The gateway parses the
3969 /// SOAP envelope and converts the Body's payload element to JSON.
3970 SoapToRest,
3971}
3972
3973/// REST <-> GraphQL translation config (DW-100,
3974/// `routes[].translation.graphql`).
3975#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3976#[serde(deny_unknown_fields)]
3977pub struct GraphqlTranslation {
3978 /// The GraphQL query template. `$variable` references are resolved
3979 /// from the REST JSON body's top-level fields and substituted into
3980 /// the query (the resolved values are also sent as the GraphQL
3981 /// `variables` map). Required for `rest_to_graphql`; ignored for
3982 /// `graphql_to_rest` (which only unwraps the response envelope).
3983 #[serde(default, skip_serializing_if = "String::is_empty")]
3984 pub query_template: String,
3985 /// The path to send the GraphQL upstream (REST-to-GraphQL rewrites
3986 /// every request to this path). Default `/graphql`.
3987 #[serde(
3988 default = "default_graphql_upstream_path",
3989 skip_serializing_if = "is_default_graphql_upstream_path"
3990 )]
3991 pub upstream_path: String,
3992}
3993
3994fn default_graphql_upstream_path() -> String {
3995 "/graphql".to_string()
3996}
3997
3998fn is_default_graphql_upstream_path(s: &str) -> bool {
3999 s == "/graphql"
4000}
4001
4002/// SOAP/XML translation config (DW-100, `routes[].translation.soap`).
4003#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4004#[serde(deny_unknown_fields)]
4005pub struct SoapTranslation {
4006 /// The SOAP operation name: the Body's first child element name
4007 /// (e.g. `GetUser`). Required for both directions.
4008 pub operation: String,
4009 /// The XML namespace for the operation element
4010 /// (e.g. `http://example.com/api`). Required for `rest_to_soap`
4011 /// (the envelope's payload element carries the namespace); used
4012 /// for `soap_to_rest` only to validate the payload element's
4013 /// namespace when present.
4014 #[serde(default, skip_serializing_if = "String::is_empty")]
4015 pub namespace: String,
4016}
4017
4018/// Route-scoped WebSocket policy (DW-039, `routes[].websocket`).
4019///
4020/// Both knobs are opt-in and independent: an `origins` list gates the
4021/// upgrade handshake (403 before any upstream contact), and
4022/// `max_frames_per_sec` polices the ESTABLISHED tunnel (a client
4023/// sending data frames faster than the allowance is closed with a
4024/// 1008 policy-violation close frame and disconnected). The policing
4025/// works on the client-to-upstream direction only — the gateway
4026/// protects upstreams from abusive clients, not the reverse (an
4027/// abusive upstream is the operator's backend problem).
4028#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4029#[serde(deny_unknown_fields)]
4030pub struct RouteWebsocket {
4031 /// Allowed `Origin` header values for the upgrade handshake
4032 /// (case-sensitive exact match, e.g. `https://app.example.com`).
4033 /// Empty or absent: every origin is allowed (the transparent
4034 /// default). Non-empty: ONLY listed origins upgrade — a missing
4035 /// `Origin` header is DENIED (browsers always send one on
4036 /// WebSocket handshakes; a request without one is not a browser
4037 /// and must be named explicitly or fail closed). The literal
4038 /// string `null` matches the sandboxed-document origin by exact
4039 /// match.
4040 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4041 pub origins: Vec<String>,
4042 /// Post-upgrade policing: the sustained allowance of DATA frames
4043 /// (text, binary, continuation — control frames are free) the
4044 /// CLIENT may send per second, with a one-second burst of the same
4045 /// size. A client past its allowance is closed with close code
4046 /// 1008 (policy violation) and disconnected. Absent: no policing
4047 /// (unlimited, the transparent default). Validation enforces
4048 /// 1..=[`limits::MAX_WEBSOCKET_FRAMES_PER_SEC`].
4049 #[serde(default, skip_serializing_if = "Option::is_none")]
4050 pub max_frames_per_sec: Option<u64>,
4051 /// Post-upgrade idle timeout in seconds (DP-05, #253). If no data
4052 /// flows in either direction for this duration, the tunnel is
4053 /// closed. Absent: no idle timeout (the tunnel stays open until
4054 /// one side closes, the prior default). Validation enforces
4055 /// 1..=86400 (1 second to 24 hours).
4056 #[serde(default, skip_serializing_if = "Option::is_none")]
4057 pub idle_timeout_s: Option<u64>,
4058 /// Maximum payload size of a single WebSocket DATA frame in bytes
4059 /// (DP-05, #253). A frame exceeding this limit causes the
4060 /// connection to be closed with close code 1009 (message too big).
4061 /// Absent: no frame size limit (the prior default). Validation
4062 /// enforces 1..=16777216 (1 byte to 16 MiB).
4063 #[serde(default, skip_serializing_if = "Option::is_none")]
4064 pub max_frame_size_bytes: Option<u64>,
4065}
4066
4067/// Per-route SLO objectives (DW-052, `routes[].slo`).
4068#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4069#[serde(deny_unknown_fields)]
4070pub struct RouteSlo {
4071 /// Availability target as a PERCENTAGE in (0, 100]: the share of
4072 /// requests the gateway must answer outside the 5xx class.
4073 /// `99.9` allows one bad request per thousand.
4074 pub availability: f64,
4075 /// Latency objective threshold in milliseconds: a request is BAD
4076 /// for the latency objective when its end-to-end duration exceeds
4077 /// this. Absent: no latency objective (only availability is
4078 /// exported for the route).
4079 #[serde(default, skip_serializing_if = "Option::is_none")]
4080 pub latency_ms: Option<f64>,
4081 /// Latency target as a PERCENTAGE in (0, 100): the share of
4082 /// requests that must complete within `latency_ms` (default 99).
4083 /// Requires `latency_ms`.
4084 #[serde(default, skip_serializing_if = "Option::is_none")]
4085 pub latency_target: Option<f64>,
4086}
4087
4088/// Route maintenance mode (DW-041): a per-route availability state that
4089/// short-circuits every matched request with a gateway-generated 503.
4090///
4091/// Frozen semantics (enforcement lives in `dataplane::proxy`):
4092///
4093/// - Checked IMMEDIATELY after route resolution, BEFORE the route's
4094/// request limits: maintenance is a statement about the route's
4095/// availability, not about any one request's shape, so every matched
4096/// request gets the same 503 + `Retry-After` answer (an over-limit
4097/// request during maintenance is told "we're down", not "your headers
4098/// are too big" — fixing the headers would still leave it refused).
4099/// - The response is `503` with `Retry-After: <retry_after_secs>` (a
4100/// whole-seconds delay-suggestion, NOT a promise of recovery) and the
4101/// uniform JSON error envelope; `message` replaces the envelope's
4102/// default human text (it never leaks upstream internals — it is
4103/// operator-authored).
4104/// - CORS: an actual (non-preflight) request on a CORS-configured route
4105/// gets the policy's actual-response headers on the 503, so a browser
4106/// client can READ the maintenance envelope cross-origin. A CORS
4107/// PREFLIGHT is exempt: it still answers 204 exactly as without
4108/// maintenance. The preflight is a Fetch-protocol handshake about the
4109/// GATEWAY's cross-origin policy, answered from static config and sent
4110/// without credentials — failing it would surface in the browser as an
4111/// opaque CORS error and hide the very 503 the operator wants clients
4112/// to see on the subsequent actual request.
4113/// - Reserved paths (`/healthz`, `/readyz`, `/metrics`) never match a
4114/// route, so probes and scrapes keep answering through maintenance —
4115/// an orchestrator must not restart a deliberately idling gateway.
4116/// - Unrouted traffic is unaffected (maintenance is per-route; a 404
4117/// stays a 404).
4118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4119#[serde(deny_unknown_fields)]
4120pub struct Maintenance {
4121 /// `Retry-After` value, in whole seconds, sent with the 503. Absent:
4122 /// 60 (one minute — long enough to shed load, short enough that a
4123 /// client polling for recovery notices the route return). Validation
4124 /// rejects 0 (it would invite an immediate retry stampede against a
4125 /// route the operator just took down).
4126 #[serde(default, skip_serializing_if = "Option::is_none")]
4127 pub retry_after_secs: Option<u64>,
4128 /// Human-readable text for the error envelope's `message` field.
4129 /// Absent: "route under maintenance". Validation rejects an
4130 /// empty/whitespace string (omit the field for the default).
4131 #[serde(default, skip_serializing_if = "Option::is_none")]
4132 pub message: Option<String>,
4133}
4134
4135/// Default `maintenance.retry_after_secs` (DW-041).
4136pub const DEFAULT_MAINTENANCE_RETRY_AFTER_SECS: u64 = 60;
4137
4138impl Maintenance {
4139 /// The `Retry-After` seconds to send (the configured value or the
4140 /// default; validation has rejected 0, so this is always >= 1).
4141 pub fn retry_after(&self) -> u64 {
4142 self.retry_after_secs
4143 .unwrap_or(DEFAULT_MAINTENANCE_RETRY_AFTER_SECS)
4144 .max(1)
4145 }
4146
4147 /// The envelope `message` text (the configured value or the default).
4148 pub fn message(&self) -> &str {
4149 self.message.as_deref().unwrap_or("route under maintenance")
4150 }
4151}
4152
4153/// Route-level API deprecation policy (DW-048): automates the RFC
4154/// deprecation signal headers on every response of the route.
4155///
4156/// What the block emits (computed once at snapshot compile; see
4157/// [`CompiledDeprecation`] and `dataplane::versioning`):
4158///
4159/// - `since` (header emitted only when set): `Deprecation:
4160/// @<unix-seconds>` — the RFC 9745 structured-date form (RFC 9651
4161/// dates). RFC 9745 carries no URI inside the `Deprecation` field;
4162/// the human-readable notice travels in the `Link` below.
4163/// - `sunset` (optional): `Sunset: <HTTP-date>` verbatim — RFC 8594
4164/// requires an HTTP-date, and validation accepts exactly the
4165/// IMF-fixdate form generators must send (see `config::versioning`).
4166/// - `uri` (optional, requires `since`): `Link: <uri>;
4167/// rel="deprecation"` appended — the RFC 9745 companion link to the
4168/// migration documentation.
4169///
4170/// Semantics (frozen):
4171///
4172/// - Headers are stamped on the route's ACTION responses (proxy,
4173/// redirect, respond) in the response decoration tail — after
4174/// compression wrapping (the codec only rewrites `Content-Length`,
4175/// `Content-Encoding`, and `Vary`, so these headers pass through
4176/// verbatim) and beside the CORS headers (independent families).
4177/// Gateway-generated short-circuits on the route (413/431 limit
4178/// rejections, CORS preflights, authn/authz/rate-limit rejections,
4179/// 503 sheds) do NOT carry them: those responses describe the
4180/// gateway's opinion of the REQUEST, not the route's lifecycle.
4181/// (The eager HMAC body-digest 401 is an action-path response and
4182/// DOES carry them.) Unrouted traffic (404) never matches the route
4183/// at all.
4184/// - The gateway is the source of truth for the headers it is configured
4185/// to emit: an upstream-sent `Deprecation`/`Sunset` on a route WITH a
4186/// `deprecation` block is replaced; on a route WITHOUT one, upstream
4187/// values pass through untouched. `Link` is appended (a list header —
4188/// upstream links survive).
4189/// - Validation rejects: a block with neither `since` nor `sunset` (no
4190/// effect — omit it), dates that are not IMF-fixdate, a `sunset`
4191/// already in the past (advertising a removal that already happened;
4192/// remove the route or extend the date — this also stops a long-lived
4193/// generation from silently re-publishing a stale sunset), a `sunset`
4194/// before `since` (removed before deprecated), and a `uri` without
4195/// `since` (the link documents the `Deprecation` header).
4196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4197#[serde(deny_unknown_fields)]
4198pub struct Deprecation {
4199 /// When this API version was (or will be) deprecated, as an
4200 /// IMF-fixdate HTTP-date (`Sun, 06 Nov 1994 08:49:37 GMT`). Emits
4201 /// `Deprecation: @<unix-seconds>` (RFC 9745). A past date is normal
4202 /// (the deprecation is in effect); a date before 1970 cannot form
4203 /// the structured date and is rejected.
4204 #[serde(skip_serializing_if = "Option::is_none")]
4205 pub since: Option<String>,
4206 /// When this API version is expected to stop working, as an
4207 /// IMF-fixdate HTTP-date. Emits `Sunset` (RFC 8594) verbatim.
4208 /// Validation rejects a date already in the past and a date before
4209 /// `since`.
4210 #[serde(skip_serializing_if = "Option::is_none")]
4211 pub sunset: Option<String>,
4212 /// URI of the deprecation/migration notice (absolute `http(s)` URL).
4213 /// Requires `since` — the RFC 9745 `Link; rel="deprecation"` it
4214 /// emits documents the `Deprecation` header.
4215 #[serde(skip_serializing_if = "Option::is_none")]
4216 pub uri: Option<String>,
4217}
4218
4219/// Snapshot-compiled form of a [`Deprecation`] block (DW-048): the
4220/// emitted header VALUES, precomputed at snapshot-compile time — the
4221/// HTTP-date strings are parsed once here (never per response) and the
4222/// RFC 9745 structured date is rendered from the parsed seconds. Lives
4223/// in `config` beside [`CompiledCorsOrigins`] for the same reason:
4224/// `snapshot` builds it into the route table and `dataplane::versioning`
4225/// consumes it. Validation has already rejected unparseable dates, so
4226/// compilation drops nothing on any publishable config; entries that
4227/// would fail are skipped exactly like the CORS matcher's fallback.
4228#[derive(Debug, Clone, PartialEq, Eq)]
4229pub struct CompiledDeprecation {
4230 deprecation: Option<String>,
4231 sunset: Option<String>,
4232 link: Option<String>,
4233}
4234
4235impl CompiledDeprecation {
4236 /// Compile a (validated) policy's header values.
4237 pub fn compile(dep: &Deprecation) -> Self {
4238 CompiledDeprecation {
4239 deprecation: dep
4240 .since
4241 .as_deref()
4242 .and_then(versioning::parse_http_date)
4243 .filter(|d| d.unix_seconds() >= 0)
4244 .map(|d| format!("@{}", d.unix_seconds())),
4245 sunset: dep
4246 .sunset
4247 .as_deref()
4248 .filter(|s| versioning::parse_http_date(s).is_some())
4249 .map(str::to_string),
4250 link: dep
4251 .uri
4252 .as_deref()
4253 .map(|uri| format!("<{uri}>; rel=\"deprecation\"")),
4254 }
4255 }
4256
4257 /// The `Deprecation` header value (`@<unix-seconds>`), if the policy
4258 /// sets `since`.
4259 pub fn deprecation_header(&self) -> Option<&str> {
4260 self.deprecation.as_deref()
4261 }
4262
4263 /// The `Sunset` header value (the validated config string, verbatim),
4264 /// if the policy sets `sunset`.
4265 pub fn sunset_header(&self) -> Option<&str> {
4266 self.sunset.as_deref()
4267 }
4268
4269 /// The `Link` header value (`<uri>; rel="deprecation"`), if the
4270 /// policy sets `uri`.
4271 pub fn link_header(&self) -> Option<&str> {
4272 self.link.as_deref()
4273 }
4274}
4275
4276/// Route-level authorization rules (DW-020, feature analysis 4.7).
4277///
4278/// Rule semantics (frozen here; evaluation lives in the `authz` module):
4279///
4280/// - `denied_consumers` / `denied_groups` beat `allowed_*` at the SAME
4281/// level: within one [`Authz`], a deny always wins a tie.
4282/// - `allowed_consumers`, when non-empty, is a closed set: the
4283/// authenticated consumer must be listed. Empty = any authenticated
4284/// consumer passes the consumer rule.
4285/// - `allowed_groups`, when non-empty, requires the consumer to be a
4286/// member of at least one listed group. Group membership comes from
4287/// the CONFIG consumer's `groups` field; store-only consumers
4288/// (DWARA_STATE_DB deployments whose consumer has no config entry)
4289/// have no groups and therefore never satisfy an `allowed_groups`
4290/// rule (documented limitation until the state store carries groups).
4291/// - `required_scopes`: every listed scope must appear in the JWT
4292/// `scope` claim. The claim may be a space-separated string
4293/// (`"read write"`, the OAuth convention) or a JSON array of strings
4294/// (`["read", "write"]`, joined to its space-separated form when the
4295/// identity's claims are captured in `authn`); non-JWT identities
4296/// (API key / Basic) carry no claims and never satisfy scope rules.
4297/// - `required_claims`: exact string equality on the stringified claim
4298/// value; a claim absent from the token fails the match. Only
4299/// string- and number-valued claims are captured on the identity
4300/// (see `authn`), so a claim that is a JSON `true`/`false`, `null`,
4301/// object, or nested structure can NEVER satisfy a `required_claims`
4302/// entry — there is no stringified form to compare. Comparisons are
4303/// CASE-SENSITIVE throughout: consumer names, groups, scopes, and
4304/// claim values must match byte-for-byte.
4305/// - `ip_acl`: evaluated against the EFFECTIVE client IP — the
4306/// `X-Forwarded-For`-resolved client when the direct peer is inside
4307/// `gateway.trusted_proxies` (DW-009 chain), otherwise the direct
4308/// peer. See [`IpAcl`].
4309///
4310/// Authentication implication: an [`Authz`] carrying ANY identity rule
4311/// (consumer/group/scope/claim) rejects anonymous requests with 401; an
4312/// `ip_acl`-only [`Authz`] is the one authorization shape that can
4313/// ADMIT anonymous traffic (from an IP the ACL allows). A denial of an
4314/// AUTHENTICATED request is 403 (forbidden), never 401.
4315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4316#[serde(deny_unknown_fields)]
4317pub struct Authz {
4318 /// Consumers allowed to call the route (empty = any authenticated).
4319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4320 pub allowed_consumers: Vec<String>,
4321 /// Consumers explicitly rejected, even when otherwise allowed.
4322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4323 pub denied_consumers: Vec<String>,
4324 /// Groups allowed to call the route (empty = no group constraint).
4325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4326 pub allowed_groups: Vec<String>,
4327 /// Groups explicitly rejected, even when otherwise allowed.
4328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4329 pub denied_groups: Vec<String>,
4330 /// JWT scopes (from the `scope` claim) every request must carry.
4331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4332 pub required_scopes: Vec<String>,
4333 /// Claims (name -> exact stringified value) every request must
4334 /// carry. A listed claim absent from the token's claims fails.
4335 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
4336 pub required_claims: std::collections::BTreeMap<String, String>,
4337 /// IP allow/deny gate on the effective client IP.
4338 #[serde(default, skip_serializing_if = "Option::is_none")]
4339 pub ip_acl: Option<IpAcl>,
4340 /// GeoIP gate on the effective client IP (DW-050): country/ASN
4341 /// allow and deny lists resolved through `gateway.geoip`'s
4342 /// database. An address the database does not resolve (private
4343 /// ranges, not-in-DB, no database loaded) is UNKNOWN and matches
4344 /// NEITHER list: deny-lists keep passing unknowns, allow-lists
4345 /// keep rejecting them. Requires a `gateway.geoip` block —
4346 /// validation rejects the predicate without one. See
4347 /// [`GeoipRules`].
4348 #[serde(default, skip_serializing_if = "Option::is_none")]
4349 pub geoip: Option<GeoipRules>,
4350 /// Monitor mode (DW-041): evaluate this block's rules, LOG and count
4351 /// every would-be denial (the `dwara_policy_dry_run_total{phase=
4352 /// "authz"}` counter plus a `dwara::policy` warn event), but let the
4353 /// request PROCEED as if allowed. The flag is per ATTACHMENT and
4354 /// mutes only this block's OWN denials: a LIVE deny at any other
4355 /// level of the chain still enforces (dry run never makes
4356 /// enforcement more permissive — the resolver walks past a dry deny
4357 /// and stops only at a live one). Route `auth_required` is an
4358 /// authentication-phase check, not an authorization rule, and is
4359 /// never muted by this flag.
4360 #[serde(default, skip_serializing_if = "is_false")]
4361 pub dry_run: bool,
4362}
4363
4364/// IP access control on the effective client IP (DW-020, feature
4365/// analysis 4.15). Entries are IP addresses (e.g. `10.1.2.3`) or CIDRs
4366/// (e.g. `10.0.0.0/8`); anything else fails config validation (the same
4367/// parser as `gateway.trusted_proxies`). Evaluation order: the `deny`
4368/// list first (a match rejects with 403 regardless of the allow list),
4369/// then the `allow` list, then `default` for IPs matched by neither.
4370///
4371/// A `/0` (all-addresses) entry such as `0.0.0.0/0` or `::/0` is
4372/// REJECTED by validation in the `allow` list: an allow-all entry
4373/// filters nothing and is always a mistake — the intended shape is an
4374/// empty allow list with `default: allow` (the ACL's default mode).
4375/// A `/0` in the `deny` list is meaningful (deny-all) and is accepted.
4376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4377#[serde(deny_unknown_fields)]
4378pub struct IpAcl {
4379 /// CIDRs/IPs allowed through the gate.
4380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4381 pub allow: Vec<String>,
4382 /// CIDRs/IPs rejected; a deny match wins over any allow match.
4383 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4384 pub deny: Vec<String>,
4385 /// What happens to an IP matched by NEITHER list: `allow` (the
4386 /// default — the lists are exceptions) or `deny` (closed mode — only
4387 /// allow-listed IPs pass).
4388 #[serde(
4389 default = "default_ip_acl_default",
4390 skip_serializing_if = "is_default_ip_acl_default"
4391 )]
4392 pub default: IpAclDefault,
4393}
4394
4395/// Fallback decision of an [`IpAcl`] for IPs matched by neither list.
4396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4397#[serde(rename_all = "lowercase")]
4398pub enum IpAclDefault {
4399 /// Unmatched IPs pass the IP gate (the default).
4400 Allow,
4401 /// Unmatched IPs are rejected: only the allow list passes.
4402 Deny,
4403}
4404
4405fn default_ip_acl_default() -> IpAclDefault {
4406 IpAclDefault::Allow
4407}
4408
4409fn is_default_ip_acl_default(d: &IpAclDefault) -> bool {
4410 *d == IpAclDefault::Allow
4411}
4412
4413/// Route-scoped CORS policy (DW-027, feature analysis 4.14).
4414///
4415/// Semantics (frozen; evaluation lives in `dataplane::cors`):
4416///
4417/// - `allowed_origins` is a closed set of exact origins
4418/// (`https://api.example.com`, scheme + host + optional port, compared
4419/// in normalized form: lowercase scheme/host, default port dropped) or
4420/// the single entry `*` (any origin). It must be non-empty; `*`
4421/// combined with `allow_credentials: true` is rejected by validation
4422/// (the Fetch spec forbids wildcard-credentialed responses).
4423/// - A PREFLIGHT (`OPTIONS` + `Origin` + `Access-Control-Request-Method`)
4424/// on a CORS-configured route is answered 204 by the gateway —
4425/// validated against the policy, never forwarded upstream, and never
4426/// subject to authn/authz/rate limiting (browsers send preflights
4427/// without credentials). A preflight that fails validation (origin,
4428/// method, or requested headers not allowed) still short-circuits:
4429/// 204 with NO CORS headers, which the browser reads as a failed
4430/// preflight. A plain `OPTIONS` without the preflight markers is NOT
4431/// intercepted; it proxies normally.
4432/// - ACTUAL responses on the route carry `Access-Control-Allow-Origin`
4433/// (`*`, or the echoed request origin when the list is specific),
4434/// `Access-Control-Allow-Credentials: true` when configured,
4435/// `Access-Control-Expose-Headers` when configured, and `Vary: Origin`
4436/// (merged into any existing `Vary`). A request whose `Origin` is not
4437/// allowed simply gets no CORS headers — the response passes through
4438/// as-is (same-origin reads do not consult CORS at all).
4439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4440#[serde(deny_unknown_fields)]
4441pub struct Cors {
4442 /// Origins allowed to call this route cross-origin: exact origin
4443 /// strings (`https://api.example.com`, `http://localhost:8080`) or
4444 /// `*` (any origin; never together with `allow_credentials`).
4445 pub allowed_origins: Vec<String>,
4446 /// Methods allowed for cross-origin requests (preflight echo and
4447 /// actual-request check). Default: the common API set
4448 /// GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS. Each entry must be a
4449 /// valid HTTP method token; comparison is case-insensitive.
4450 #[serde(default = "default_cors_methods")]
4451 pub allowed_methods: Vec<String>,
4452 /// Request headers clients may send: exact header names, or `*` to
4453 /// allow any requested header (never `*` together with
4454 /// `allow_credentials`). Validated against the preflight's
4455 /// `Access-Control-Request-Headers` list; empty means no headers
4456 /// beyond the CORS-safelisted set are permitted in preflight checks.
4457 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4458 pub allowed_headers: Vec<String>,
4459 /// Response headers exposed to the browser (readable by cross-origin
4460 /// client script). Absent: only the CORS-safelisted response headers
4461 /// are exposed.
4462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4463 pub expose_headers: Vec<String>,
4464 /// Allow credentialed cross-origin requests (cookies, TLS client
4465 /// certificates). Default false. When true, the origin list must be
4466 /// specific (`*` is rejected by validation) and responses echo the
4467 /// request origin rather than sending `*`.
4468 #[serde(default, skip_serializing_if = "is_false")]
4469 pub allow_credentials: bool,
4470 /// `Access-Control-Max-Age` for preflight responses, in seconds (how
4471 /// long the browser may cache the preflight result). Absent: the
4472 /// header is omitted (each preflight hits the gateway).
4473 #[serde(default, skip_serializing_if = "Option::is_none")]
4474 pub max_age_secs: Option<u64>,
4475}
4476
4477fn default_cors_methods() -> Vec<String> {
4478 ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
4479 .iter()
4480 .map(|s| s.to_string())
4481 .collect()
4482}
4483
4484/// Canonical form of one origin (DW-027): lowercase scheme and host,
4485/// default port dropped, no userinfo, no path/query — the exact string
4486/// the runtime CORS matcher compares request `Origin` values against
4487/// and validation checks config entries with. `None` when the input is
4488/// not a well-formed `http(s)` origin. Lives in `config` (the lowest
4489/// consuming domain) so validation (`snapshot::validate`) and matching
4490/// (`dataplane::cors`) share one grammar, the same way `net.rs` owns the
4491/// IP/CIDR grammar.
4492pub fn normalize_origin(origin: &str) -> Option<String> {
4493 let uri: hyper::Uri = origin.parse().ok()?;
4494 let scheme = uri.scheme()?;
4495 if !matches!(scheme.as_str(), "http" | "https") {
4496 return None;
4497 }
4498 // Browsers never serialize userinfo into an Origin (the grammar is
4499 // scheme "://" host [":" port]); authority userinfo is a config
4500 // authoring error or a smuggle attempt, never a real origin.
4501 if uri.authority().is_some_and(|a| a.as_str().contains('@')) {
4502 return None;
4503 }
4504 let host = uri.host()?;
4505 let default_port = match scheme.as_str() {
4506 "http" => 80,
4507 _ => 443,
4508 };
4509 let port = uri
4510 .port_u16()
4511 .and_then(|p| (p != default_port).then_some(p));
4512 // An origin carries no path, query, or fragment; tolerate only the
4513 // empty path "/" (browsers serialize the origin WITHOUT it, but
4514 // configs hand-writing "https://host/" are unambiguous).
4515 match uri.path_and_query() {
4516 None => {}
4517 Some(pq) if pq.as_str() == "/" => {}
4518 _ => return None,
4519 }
4520 Some(match port {
4521 Some(p) => format!("{}://{}:{}", scheme.as_str(), host.to_lowercase(), p),
4522 None => format!("{}://{}", scheme.as_str(), host.to_lowercase()),
4523 })
4524}
4525
4526/// Snapshot-compiled form of a `cors.allowed_origins` list (DW-027): the
4527/// wildcard flag plus the normalized form of every specific entry,
4528/// computed once at snapshot-compile time so the request path never
4529/// re-parses config strings (per-request work is the request's own
4530/// origin only). Lives in `config` beside [`normalize_origin`] because
4531/// the compiled set is the grammar's runtime consumer: `snapshot`
4532/// builds it into the route table (`snapshot::RouteTable`) and
4533/// `dataplane::cors` matches against it. Validation has already
4534/// rejected entries that do not normalize, so compilation drops
4535/// nothing on any publishable config; entries that would fail
4536/// normalization are skipped exactly as the pre-compiled matcher was.
4537#[derive(Debug, Clone, PartialEq, Eq)]
4538pub struct CompiledCorsOrigins {
4539 wildcard: bool,
4540 origins: Vec<String>,
4541}
4542
4543impl CompiledCorsOrigins {
4544 /// Compile a (validated) policy's origin list.
4545 pub fn compile(cors: &Cors) -> Self {
4546 CompiledCorsOrigins {
4547 wildcard: cors.allowed_origins.iter().any(|o| o == "*"),
4548 origins: cors
4549 .allowed_origins
4550 .iter()
4551 .filter(|o| *o != "*")
4552 .filter_map(|o| normalize_origin(o))
4553 .collect(),
4554 }
4555 }
4556
4557 /// Does a request `Origin` value match this set? `*` matches any
4558 /// origin string; otherwise normalized exact match (the same
4559 /// comparison the pre-compiled matcher ran, without re-normalizing
4560 /// the config side per request).
4561 pub fn allows(&self, origin: &str) -> bool {
4562 if self.wildcard {
4563 return true;
4564 }
4565 let Some(request_origin) = normalize_origin(origin) else {
4566 return false;
4567 };
4568 self.origins.iter().any(|o| o == &request_origin)
4569 }
4570
4571 /// Was the list the single entry `*`?
4572 pub fn wildcard(&self) -> bool {
4573 self.wildcard
4574 }
4575}
4576
4577/// Route-scoped response compression policy (DW-027, feature analysis
4578/// 4.13).
4579///
4580/// Semantics (frozen; negotiation and encoding live in
4581/// `dataplane::compression`):
4582///
4583/// - `algorithms` is the PREFERENCE order: the first entry the request's
4584/// `Accept-Encoding` accepts wins. `q=0` entries in `Accept-Encoding`
4585/// are treated as refusal.
4586/// - `level` is clamped per algorithm at encode time (gzip 0-9, brotli
4587/// 0-11, zstd 0-22; validation bounds the field to 0-22). Absent: the
4588/// algorithm's library default.
4589/// - `min_size` skips compression of responses whose size is known and
4590/// below it — a declared `Content-Length` or the exact size of the
4591/// gateway's own respond/redirect bodies. Responses of UNKNOWN length
4592/// (streaming) are always candidates.
4593/// - `content_types` (when non-empty) restricts compression to responses
4594/// whose `Content-Type` starts with one of the entries; entries are
4595/// lowercase prefix matches (e.g. `text/`). `excluded_content_types`
4596/// is checked after the include list and wins (the way to express
4597/// "compress everything text/ except `text/event-stream`").
4598/// - Responses carrying an existing `Content-Encoding`, 1xx/204/304
4599/// statuses, zero-length bodies, and 101 upgrade responses are never
4600/// compressed. `Vary: Accept-Encoding` is merged into every response
4601/// on a compression-configured route that is not already encoded —
4602/// compressed or not — so caches key correctly.
4603/// - The compressed body streams chunk-by-chunk with a flush per chunk
4604/// (streaming/SSE-friendly, at a small compression-ratio cost); the
4605/// gateway never buffers the whole body to compress it. The frame
4606/// codec's internal buffer is bounded by the chunk size.
4607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4608#[serde(deny_unknown_fields)]
4609pub struct Compression {
4610 /// Enabled algorithms in preference order (default gzip, brotli,
4611 /// zstd). Must be non-empty and duplicate-free (validation).
4612 #[serde(default = "default_compression_algorithms")]
4613 pub algorithms: Vec<CompressionAlgorithm>,
4614 /// Compression level; clamped per algorithm (gzip <= 9, brotli <= 11,
4615 /// zstd <= 22). Absent: library default per algorithm.
4616 #[serde(default, skip_serializing_if = "Option::is_none")]
4617 pub level: Option<u8>,
4618 /// Minimum response size (bytes) to compress, applied whenever the
4619 /// size is known: a declared `Content-Length` or the body's exact
4620 /// size (the gateway's own respond/redirect bodies, which carry no
4621 /// `Content-Length`). Below it the response passes through (still
4622 /// carrying `Vary: Accept-Encoding`). Default 1024. Responses of
4623 /// unknown length (streaming) are always candidates.
4624 #[serde(default = "default_compression_min_size")]
4625 pub min_size: u64,
4626 /// Content-Type prefix include list (lowercase, e.g. `text/`,
4627 /// `application/json`); empty = every content type is a candidate.
4628 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4629 pub content_types: Vec<String>,
4630 /// Content-Type prefix exclude list, checked after `content_types`;
4631 /// a match passes the response through uncompressed.
4632 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4633 pub excluded_content_types: Vec<String>,
4634}
4635
4636fn default_compression_algorithms() -> Vec<CompressionAlgorithm> {
4637 vec![
4638 CompressionAlgorithm::Gzip,
4639 CompressionAlgorithm::Brotli,
4640 CompressionAlgorithm::Zstd,
4641 ]
4642}
4643
4644fn default_compression_min_size() -> u64 {
4645 1024
4646}
4647
4648/// One response compression algorithm (DW-027).
4649#[derive(
4650 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
4651)]
4652#[serde(rename_all = "lowercase")]
4653pub enum CompressionAlgorithm {
4654 Gzip,
4655 Brotli,
4656 Zstd,
4657}
4658
4659impl CompressionAlgorithm {
4660 /// The `Accept-Encoding` / `Content-Encoding` token of the algorithm.
4661 pub fn encoding_token(self) -> &'static str {
4662 match self {
4663 CompressionAlgorithm::Gzip => "gzip",
4664 CompressionAlgorithm::Brotli => "br",
4665 CompressionAlgorithm::Zstd => "zstd",
4666 }
4667 }
4668}
4669
4670/// Snapshot-compiled form of a compression policy's content-type prefix
4671/// lists (DW-027): every include/exclude entry trimmed and lowercased
4672/// once at snapshot-compile time; the request path compares the
4673/// response's (lowercased) media type against the precomputed prefixes
4674/// instead of re-normalizing config strings per response. Same
4675/// placement rationale as [`CompiledCorsOrigins`].
4676#[derive(Debug, Clone, PartialEq, Eq)]
4677pub struct CompiledContentTypeFilter {
4678 include: Vec<String>,
4679 exclude: Vec<String>,
4680}
4681
4682impl CompiledContentTypeFilter {
4683 /// Compile a (validated) policy's prefix lists.
4684 pub fn compile(policy: &Compression) -> Self {
4685 fn normalize_list(list: &[String]) -> Vec<String> {
4686 list.iter().map(|p| p.trim().to_lowercase()).collect()
4687 }
4688 CompiledContentTypeFilter {
4689 include: normalize_list(&policy.content_types),
4690 exclude: normalize_list(&policy.excluded_content_types),
4691 }
4692 }
4693
4694 /// Is `media_type` (lowercase, parameters stripped) a compression
4695 /// candidate? An empty include list admits every type; the exclude
4696 /// list is checked after it and wins.
4697 pub fn allows(&self, media_type: &str) -> bool {
4698 (self.include.is_empty() || self.include.iter().any(|p| media_type.starts_with(p)))
4699 && !self.exclude.iter().any(|p| media_type.starts_with(p))
4700 }
4701}
4702
4703/// Route-scoped request size limits (DW-027, feature analysis 4.16).
4704///
4705/// Semantics (frozen; enforcement lives in `dataplane::hardening`):
4706///
4707/// - Header limits (`max_header_count`, `max_header_bytes`) are checked
4708/// immediately after route resolution, before CORS preflight handling
4709/// and authentication; a violation answers 431 with the JSON error
4710/// envelope. `max_header_bytes` is the SUM of all header name and
4711/// value bytes of the request (hyper's per-connection buffer limits
4712/// still apply beneath this, process-wide).
4713/// - `max_body_bytes`: a request whose `Content-Length` declares more is
4714/// rejected 413 before any upstream contact. A body of UNKNOWN length
4715/// (chunked / h2 without content-length) is wrapped in a counting
4716/// guard: crossing the cap aborts the request (the upstream attempt
4717/// fails; the client receives 413 when the failure precedes the
4718/// upstream's response headers, a torn stream otherwise).
4719/// - A block with NO field set is rejected by validation (always an
4720/// authoring mistake; omit the block).
4721#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4722#[serde(deny_unknown_fields)]
4723pub struct RequestLimits {
4724 /// Maximum request body size in bytes (declared via `Content-Length`
4725 /// or enforced streaming). Absent: unlimited.
4726 #[serde(default, skip_serializing_if = "Option::is_none")]
4727 pub max_body_bytes: Option<u64>,
4728 /// Maximum number of header fields on the request. Absent:
4729 /// unlimited (hyper's parser bound applies).
4730 #[serde(default, skip_serializing_if = "Option::is_none")]
4731 pub max_header_count: Option<u32>,
4732 /// Maximum total size of request headers (names + values, bytes).
4733 /// Absent: unlimited.
4734 #[serde(default, skip_serializing_if = "Option::is_none")]
4735 pub max_header_bytes: Option<u64>,
4736 /// Monitor mode (DW-041): evaluate the limits, LOG and count every
4737 /// would-be rejection (`dwara_policy_dry_run_total{phase="route_
4738 /// limits"}` + a `dwara::policy` warn event), but let the request
4739 /// PROCEED — including its body: the streaming `max_body_bytes` guard
4740 /// is not armed, so a chunked body that would have been aborted mid-
4741 /// stream streams through (only the cheap up-front checks — header
4742 /// count/bytes, a declared `Content-Length` — are observable in dry
4743 /// run). The staging tool for turning limits on against live traffic
4744 /// without breaking clients.
4745 #[serde(default, skip_serializing_if = "is_false")]
4746 pub dry_run: bool,
4747}
4748
4749/// Matching rules for incoming requests.
4750#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4751#[serde(deny_unknown_fields)]
4752pub struct RouteMatch {
4753 pub path: PathMatch,
4754 /// Exact host match (e.g. `api.example.com`).
4755 #[serde(skip_serializing_if = "Option::is_none")]
4756 pub host: Option<String>,
4757 /// Allowed HTTP methods; empty means all methods.
4758 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4759 pub methods: Vec<String>,
4760 /// Exact header matches.
4761 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
4762 pub headers: std::collections::BTreeMap<String, String>,
4763 /// Query-parameter matches; every entry must match (AND). Name-only
4764 /// entries match on presence; a `value` requires an exact raw match (no
4765 /// percent-decoding in v1).
4766 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4767 pub query: Vec<NameValueMatch>,
4768 /// Cookie matches (parsed from the `Cookie` header); every entry must
4769 /// match (AND). Name-only entries match on presence; a `value` requires
4770 /// an exact match (no cookie-unquoting in v1).
4771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4772 pub cookies: Vec<NameValueMatch>,
4773 /// Media-type version selection (DW-048): a bare `type/subtype` the
4774 /// request's `Accept` header must name explicitly, e.g.
4775 /// `application/vnd.acme.v2+json`. A comma-separated Accept list
4776 /// matches on ANY entry; parameters and q-values on the request side
4777 /// are ignored; wildcard entries (`*/*`, `type/*`) and a missing
4778 /// Accept header never match — version selection requires the client
4779 /// to NAME the version, so unconstrained clients fall through to the
4780 /// route without this criterion (the unversioned default). Like every
4781 /// non-path criterion this is AND-ed and applied AFTER path
4782 /// resolution: a miss does not fall through to another route (404),
4783 /// and two routes cannot share one path to offer multiple versions —
4784 /// version families use distinct paths (`/v1/`, `/v2/`) or this
4785 /// criterion on a versioned path. Validation rejects anything but a
4786 /// bare lowercase-able `type/subtype` (`config::versioning` owns the
4787 /// grammar). Responses of a matched route carry `Vary: Accept`
4788 /// (merged), so shared caches key on the negotiated representation.
4789 #[serde(default, skip_serializing_if = "Option::is_none")]
4790 pub accept: Option<String>,
4791}
4792
4793/// One query-parameter or cookie criterion: the parameter/cookie must be
4794/// present; when `value` is given it must equal that exact string.
4795#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4796#[serde(deny_unknown_fields)]
4797pub struct NameValueMatch {
4798 pub name: String,
4799 /// Exact value required; `None` means "present is enough".
4800 #[serde(skip_serializing_if = "Option::is_none")]
4801 pub value: Option<String>,
4802}
4803
4804/// How a route's path pattern is interpreted.
4805#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4806#[serde(deny_unknown_fields)]
4807pub struct PathMatch {
4808 #[serde(rename = "type")]
4809 pub kind: PathMatchKind,
4810 /// The pattern value, e.g. `/v1/users` or `/v1/.*`.
4811 pub value: String,
4812}
4813
4814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4815#[serde(rename_all = "lowercase")]
4816pub enum PathMatchKind {
4817 Exact,
4818 Prefix,
4819 Regex,
4820}
4821
4822/// What the gateway does when a route matches.
4823#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4824#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
4825pub enum RouteAction {
4826 /// Forward to the route's service (its upstream).
4827 ///
4828 /// `rewrite` (at most ONE per action in v1) is applied to the inbound
4829 /// path before the request is sent upstream; the query string is always
4830 /// preserved verbatim.
4831 Proxy {
4832 #[serde(skip_serializing_if = "Option::is_none")]
4833 rewrite: Option<PathRewrite>,
4834 },
4835 Redirect {
4836 #[serde(skip_serializing_if = "Option::is_none")]
4837 scheme: Option<String>,
4838 #[serde(skip_serializing_if = "Option::is_none")]
4839 host: Option<String>,
4840 #[serde(skip_serializing_if = "Option::is_none")]
4841 path: Option<String>,
4842 status: u16,
4843 },
4844 Respond {
4845 status: u16,
4846 #[serde(skip_serializing_if = "Option::is_none")]
4847 body: Option<String>,
4848 /// Extra response headers (name -> value), emitted verbatim.
4849 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
4850 headers: std::collections::BTreeMap<String, String>,
4851 },
4852 /// Serve a canned response without contacting any upstream (DW-047
4853 /// mock mode). The route's `service` is still required by the
4854 /// schema (the frozen vocabulary), but a mock route never dials
4855 /// it — the whole point is serving without a backend. Pair with
4856 /// `request_validation` to mock a write endpoint that still
4857 /// rejects malformed bodies. See [`MockAction`].
4858 Mock {
4859 #[serde(flatten)]
4860 mock: MockAction,
4861 },
4862 /// AI provider translation (DW-075): the request body is parsed as
4863 /// an OpenAI chat-completions request, its `model` resolved through
4864 /// the gateway's `ai:` block to a provider, translated to that
4865 /// provider's wire format (OpenAI/Anthropic/Gemini), sent through
4866 /// the provider's upstream, and the response translated back to the
4867 /// OpenAI shape. The route's `service` is still required by the
4868 /// schema but never dialed. Requires an `ai:` block (validation
4869 /// rejects the pairing otherwise). `stream: true` requests are
4870 /// served via zero-buffer SSE pass-through (DW-077).
4871 ///
4872 /// AI-02: the `endpoint` field selects which AI endpoint the route
4873 /// serves. The default is `chat` (the existing behavior). Other
4874 /// endpoints (`embeddings`, `images`, `audio`, `moderation`) are
4875 /// proxied as a passthrough: the request body is forwarded to the
4876 /// provider's upstream as-is (no adapter translation), and the
4877 /// response is returned as-is. Model governance (allowlist check)
4878 /// still applies using the `model` field from the request body.
4879 Ai {
4880 #[serde(default)]
4881 endpoint: AiEndpoint,
4882 /// AI-05 (#195): The client-facing request dialect. Defaults
4883 /// to `openai` (the historical behavior). When set to
4884 /// `anthropic` or `gemini`, the gateway parses the incoming
4885 /// request using that dialect's native format and translates
4886 /// to the canonical `ChatRequest`. When set to `passthrough`,
4887 /// the request body is forwarded to the provider as-is (no
4888 /// translation, still metered/governed).
4889 #[serde(default)]
4890 dialect: AiIngressDialect,
4891 },
4892 /// WASM route handler (nano-service, DW-106): instead of proxying
4893 /// to an upstream, the route action runs a WASM module that
4894 /// generates the response directly. The module implements a simple
4895 /// request->response handler ABI over the existing wasmtime runtime
4896 /// (compiled into every build; no cargo feature). The route's
4897 /// `service` is still
4898 /// required by the schema (the frozen vocabulary) but never dialed
4899 /// -- the whole point is serving without a backend. See
4900 /// [`NanoServiceAction`].
4901 NanoService {
4902 #[serde(flatten)]
4903 nano: NanoServiceAction,
4904 },
4905}
4906
4907/// AI-05 (#195): The client-facing request dialect for an AI route.
4908/// Determines how the gateway parses the incoming request body.
4909#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4910#[serde(rename_all = "snake_case")]
4911pub enum AiIngressDialect {
4912 /// OpenAI chat-completions shape (the historical default). The
4913 /// request is parsed by `openai_compat::parse_chat_request`.
4914 #[default]
4915 Openai,
4916 /// Anthropic Messages API shape. The request is parsed by
4917 /// `ingress::parse_anthropic_request` and translated to the
4918 /// canonical `ChatRequest`.
4919 Anthropic,
4920 /// Gemini generateContent shape. The request is parsed by
4921 /// `ingress::parse_gemini_request` and translated to the
4922 /// canonical `ChatRequest`.
4923 Gemini,
4924 /// Passthrough mode: the request body is forwarded to the
4925 /// provider as-is (no translation, still metered/governed).
4926 /// The response is returned as-is.
4927 Passthrough,
4928}
4929
4930/// AI-02: which AI endpoint a route serves. The `chat` endpoint (the
4931/// default) uses the full adapter translation pipeline (DW-075). The
4932/// other endpoints are proxied as a passthrough: the request body is
4933/// forwarded to the provider's upstream as-is, and the response is
4934/// returned as-is. Model governance (allowlist check) still applies
4935/// using the `model` field from the request body.
4936#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4937#[serde(rename_all = "snake_case")]
4938pub enum AiEndpoint {
4939 /// OpenAI chat-completions (`POST /v1/chat/completions`). The
4940 /// full adapter translation pipeline applies (DW-075). This is the
4941 /// default.
4942 #[default]
4943 Chat,
4944 /// OpenAI embeddings (`POST /v1/embeddings`). Proxied as a
4945 /// passthrough; the request body `{model, input}` is forwarded
4946 /// as-is. Token usage from the response is recorded for budget
4947 /// accounting.
4948 Embeddings,
4949 /// OpenAI images (`POST /v1/images/generations`). Proxied as a
4950 /// passthrough; no token usage (image generation is priced per
4951 /// image, not per token).
4952 Images,
4953 /// OpenAI audio (TTS `POST /v1/audio/speech`, transcription
4954 /// `POST /v1/audio/transcriptions`). Proxied as a passthrough.
4955 Audio,
4956 /// OpenAI moderation (`POST /v1/moderations`). Proxied as a
4957 /// passthrough.
4958 Moderation,
4959 /// AI-04 (#194): OpenAI Batch API (`POST /v1/batches`). Proxied
4960 /// as a passthrough for batch creation/status/cancel. When batch
4961 /// results are retrieved (`GET /v1/batches/{id}/output`), the
4962 /// JSONL result file is parsed and each line's `usage` is metered
4963 /// at the batch (50% discount) rate.
4964 Batch,
4965}
4966
4967/// A WASM route handler (nano-service) action (DW-106,
4968/// `routes[].action` with `type: nano_service`). The gateway loads the
4969/// named WASM module, calls its `handle` export with the serialized
4970/// request (method, path, headers, body), and returns the response the
4971/// module produces (status, headers, body). No upstream is contacted.
4972///
4973/// `module` is the filesystem path to the `.wasm` module (checked for
4974/// existence and readability at config publish time). `memory_limit` is
4975/// the maximum linear memory in bytes the module may allocate (must be
4976/// positive and at most 64 MiB). `execution_timeout_ms` is the maximum
4977/// wall-clock time the `handle` call may run (must be positive and at
4978/// most 5000ms); a module that exceeds it is interrupted and the route
4979/// answers 504.
4980#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4981#[serde(deny_unknown_fields)]
4982pub struct NanoServiceAction {
4983 /// Filesystem path to the `.wasm` module. Must exist and be
4984 /// readable at config publish time.
4985 pub module: String,
4986 /// Maximum linear memory the module may allocate, in bytes. Must be
4987 /// > 0 and <= 67108864 (64 MiB). Default: 1048576 (1 MiB).
4988 #[serde(default = "default_nano_memory_limit")]
4989 pub memory_limit: usize,
4990 /// Maximum wall-clock execution time for the `handle` call, in
4991 /// milliseconds. Must be > 0 and <= 5000. Default: 100.
4992 #[serde(default = "default_nano_execution_timeout_ms")]
4993 pub execution_timeout_ms: u64,
4994}
4995
4996fn default_nano_memory_limit() -> usize {
4997 1_048_576
4998}
4999
5000fn default_nano_execution_timeout_ms() -> u64 {
5001 100
5002}
5003
5004/// A canned response served without any upstream contact (DW-047,
5005/// `routes[].action.mock`). The body comes from either `body` (an
5006/// inline string) or `body_file` (a path relative to the config file
5007/// directory); exactly one of the two may be set, or neither for an
5008/// empty body. `delay_ms` simulates latency (useful for testing
5009/// timeouts and retries against a mock). Validation rejects a status
5010/// outside 100..=599, a `delay_ms` above 30000, and setting both
5011/// `body` and `body_file`.
5012#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5013#[serde(deny_unknown_fields)]
5014pub struct MockAction {
5015 /// HTTP status code (100..=599).
5016 pub status: u16,
5017 /// Extra response headers (name -> value), emitted verbatim. When
5018 /// `content-type` is not set here, the gateway defaults it to
5019 /// `application/json` if the body parses as JSON, else `text/plain`.
5020 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
5021 pub headers: std::collections::BTreeMap<String, String>,
5022 /// Inline response body. Mutually exclusive with `body_file`.
5023 #[serde(default, skip_serializing_if = "Option::is_none")]
5024 pub body: Option<String>,
5025 /// Path to a file whose contents are served as the body, read at
5026 /// config publish time (the bytes are held in the snapshot). The
5027 /// path is relative to the config file directory. Mutually
5028 /// exclusive with `body`.
5029 #[serde(default, skip_serializing_if = "Option::is_none")]
5030 pub body_file: Option<String>,
5031 /// Simulated latency in milliseconds (0..=30000). The response is
5032 /// delayed by this long before the status/headers/body are sent.
5033 #[serde(default, skip_serializing_if = "Option::is_none")]
5034 pub delay_ms: Option<u32>,
5035}
5036
5037/// Path rewrite applied before proxying (DW-010). Exactly one variant per
5038/// proxy action; there is no rewrite chaining in v1. All variants operate
5039/// on the path component only — the inbound query string is re-attached
5040/// untouched.
5041#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5042#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
5043pub enum PathRewrite {
5044 /// Strip the route's matched prefix (the `match.path.value` with
5045 /// trailing slashes trimmed) from the front of the request path.
5046 /// Meaningful for prefix-kind routes; for other kinds it strips the
5047 /// pattern's byte length when the path starts with the pattern value
5048 /// and is a no-op otherwise. If nothing remains (or the remainder
5049 /// lacks a leading `/`), the result is normalized to `/rest`.
5050 StripPrefix {},
5051 /// Replace a literal prefix: when the request path starts with
5052 /// `prefix`, that prefix is replaced by `replacement`; otherwise this
5053 /// rewrite is a no-op (the path is forwarded unchanged).
5054 ReplacePrefix { prefix: String, replacement: String },
5055 /// Replace the FIRST regex match on the request path with
5056 /// `substitution`. Substitution references: `$1`..`$9` / `${n}` for
5057 /// capture groups of `pattern`; `$name` / `${name}` for named capture
5058 /// groups of `pattern`, falling back to path parameters captured by
5059 /// the route's `{param}` template. Unknown references expand to the
5060 /// empty string. The pattern must compile — checked at config compile
5061 /// time, never at request time.
5062 Regex {
5063 pattern: String,
5064 substitution: String,
5065 },
5066}
5067
5068/// The logical API being exposed: base path, version, default policies;
5069/// targets exactly one upstream, or a weighted split of upstreams
5070/// since DW-040 (canary / blue-green).
5071#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5072#[serde(deny_unknown_fields)]
5073pub struct Service {
5074 pub name: String,
5075 /// Name of the upstream this service targets. Exactly one of
5076 /// `upstream` and [`Service::split`] must be set: a single-target
5077 /// service names its pool here; a canary/blue-green service uses
5078 /// `split` instead and leaves this unset. Optional since DW-040
5079 /// (it was the only shape before).
5080 #[serde(default, skip_serializing_if = "Option::is_none")]
5081 pub upstream: Option<String>,
5082 /// Weighted traffic split across upstreams (DW-040, canary and
5083 /// blue-green): requests for this service are dispatched to one of
5084 /// the named upstreams by a deterministic weighted pick (the
5085 /// sticky cookie's value when [`Service::sticky`] is configured
5086 /// and the request carries one — sessions stay on their branch;
5087 /// otherwise the request id — per-request distribution whose
5088 /// ratios converge on the weights statistically). A weight of 0
5089 /// parks an upstream in the split without serving traffic (the
5090 /// blue-green switch: flip the weights and re-publish — the next
5091 /// request dispatches by the new generation, no restart). Ramp
5092 /// rule: change weights by RE-BALANCING (keep the total constant,
5093 /// e.g. 95/5 -> 90/10) — growing one side alone changes the pick's
5094 /// modulus and reshuffles every session's branch.
5095 #[serde(default, skip_serializing_if = "Option::is_none")]
5096 pub split: Option<ServiceSplit>,
5097 /// Cookie affinity (DW-040): when set, the gateway sets a cookie
5098 /// on responses for this service whose VALUE consistently selects
5099 /// the split branch (and, when the branch upstream runs the
5100 /// `ip_hash` balancer, the endpoint — the cookie value becomes the
5101 /// ring key, reusing the consistent-hash machinery). The value is
5102 /// an opaque affinity handle: not a secret, carrying no identity,
5103 /// and never trusted as one.
5104 #[serde(default, skip_serializing_if = "Option::is_none")]
5105 pub sticky: Option<StickyAffinity>,
5106 /// Base path prefix the API is served under (e.g. `/v1`).
5107 #[serde(skip_serializing_if = "Option::is_none")]
5108 pub base_path: Option<String>,
5109 #[serde(skip_serializing_if = "Option::is_none")]
5110 pub version: Option<String>,
5111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5112 pub policies: Vec<String>,
5113 /// Service-level authorization rules (#123): applies to every request
5114 /// whose route targets this service. Link of the frozen chain
5115 /// consumer > route > service > listener > global (a deny at any
5116 /// level wins; otherwise the most specific level with rules governs —
5117 /// see [`Authz`] and the `authz` module). Same validation rules as
5118 /// route-level authorization.
5119 #[serde(default, skip_serializing_if = "Option::is_none")]
5120 pub authorization: Option<Authz>,
5121}
5122
5123/// Weighted traffic split across upstreams (DW-040,
5124/// `services[].split`): canary releases and blue-green switches at the
5125/// SERVICE level — each target is a full upstream (its own endpoints,
5126/// protocol, health, and balancer), so a canary pool is isolated from
5127/// the stable one by construction. The pick is a deterministic
5128/// weighted hash (see [`Service::split`]): ratios hold statistically
5129/// per request and exactly per session when sticky affinity is on.
5130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5131#[serde(deny_unknown_fields)]
5132pub struct ServiceSplit {
5133 /// The weighted targets. Validation enforces 2..=8 entries (one
5134 /// target is what `services[].upstream` expresses), existing
5135 /// upstream names, no duplicates, and a positive total weight (a
5136 /// total of 0 would route nowhere; individual zeros are the
5137 /// parked side of a blue-green pair).
5138 pub targets: Vec<SplitTarget>,
5139 /// Auto-canary analysis (DW-091): when set, a background
5140 /// controller compares the canary (second target) vs the baseline
5141 /// (first target) on error rate or latency and adjusts the split
5142 /// weights automatically. Only valid when the split has exactly 2
5143 /// targets (baseline + canary). Weight changes are transient
5144 /// (Generation swap) and revert on config reload.
5145 #[serde(default, skip_serializing_if = "Option::is_none")]
5146 pub canary_analysis: Option<CanaryAnalysis>,
5147}
5148
5149/// One weighted target of a service split (DW-040).
5150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5151#[serde(deny_unknown_fields)]
5152pub struct SplitTarget {
5153 /// Name of an upstream in this config.
5154 pub upstream: String,
5155 /// Relative share of requests (default 1). `0` parks the target
5156 /// (compiled, validated, serving nothing — the blue-green
5157 /// shape); validation caps the sum at a sane bound.
5158 #[serde(
5159 default = "default_split_weight",
5160 skip_serializing_if = "is_default_split_weight"
5161 )]
5162 pub weight: u32,
5163}
5164
5165fn default_split_weight() -> u32 {
5166 1
5167}
5168fn is_default_split_weight(w: &u32) -> bool {
5169 *w == 1
5170}
5171
5172/// Auto-canary analysis (DW-091): metrics-driven promotion/rollback of
5173/// canary split weights. A background controller compares the canary
5174/// side against the baseline on error rate or latency; on regression it
5175/// rolls the canary weight back (toward 0), on success it promotes
5176/// (toward the total weight). Severe regression (>2x the rollback
5177/// threshold) triggers an immediate rollback to 0. The total weight
5178/// stays constant: when the canary weight changes, the baseline
5179/// absorbs the difference, preserving the hash distribution for
5180/// existing sessions (the DW-040 invariant). Weight changes are
5181/// TRANSIENT (a Generation swap) and revert on config reload.
5182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5183#[serde(deny_unknown_fields)]
5184pub struct CanaryAnalysis {
5185 /// Master switch. Default true: an analysis block is active unless
5186 /// explicitly disabled.
5187 #[serde(default = "default_true")]
5188 pub enabled: bool,
5189 /// Look-back window for metrics, in seconds. The controller
5190 /// evaluates canary health over the recent window of observations.
5191 pub window_seconds: u64,
5192 /// Weight delta per adjustment (in weight units). Promote:
5193 /// increase canary weight by `step`. Rollback: decrease by `step`.
5194 /// Severe regression (>2x the rollback threshold) rolls back to 0
5195 /// immediately, ignoring the step.
5196 pub step: u32,
5197 /// Minimum total requests (baseline + canary) before the
5198 /// controller acts. Prevents acting on insufficient data.
5199 pub min_requests: u64,
5200 /// Cooldown between adjustments, in seconds. Prevents rapid
5201 /// oscillation: after one adjustment, the next is suppressed until
5202 /// the cooldown elapses.
5203 pub cooldown_seconds: u64,
5204 /// The promotion rule: when the canary metric is BELOW this
5205 /// threshold (good), the controller promotes (increases canary
5206 /// weight by `step`).
5207 pub promote: CanaryRule,
5208 /// The rollback rule: when the canary metric is ABOVE this
5209 /// threshold (bad), the controller rolls back (decreases canary
5210 /// weight by `step`). When the canary metric exceeds 2x this
5211 /// threshold (severe regression), the controller rolls back to 0
5212 /// immediately.
5213 pub rollback: CanaryRule,
5214}
5215
5216/// One canary analysis rule (DW-091): a metric to compare and the
5217/// threshold that triggers the rule.
5218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5219#[serde(deny_unknown_fields)]
5220pub struct CanaryRule {
5221 /// The metric to compare canary vs baseline.
5222 pub metric: CanaryMetric,
5223 /// The threshold. For `promote`: the canary metric must be BELOW
5224 /// this (good) to promote. For `rollback`: the canary metric must
5225 /// be ABOVE this (bad) to roll back.
5226 pub threshold: f64,
5227}
5228
5229/// The metric a canary rule compares (DW-091).
5230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5231#[serde(rename_all = "snake_case")]
5232pub enum CanaryMetric {
5233 /// HTTP error rate (status >= 500 / total requests).
5234 ErrorRate,
5235 /// P99 latency in milliseconds.
5236 LatencyP99,
5237 /// P95 latency in milliseconds.
5238 LatencyP95,
5239 /// P50 (median) latency in milliseconds.
5240 LatencyP50,
5241}
5242
5243/// Cookie affinity for a service (DW-040, `services[].sticky`).
5244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5245#[serde(deny_unknown_fields)]
5246pub struct StickyAffinity {
5247 /// The cookie name the gateway reads and sets. Must be a valid
5248 /// cookie-name token (RFC 6265: the RFC 2616 `token` grammar);
5249 /// validation rejects anything else.
5250 pub cookie: String,
5251 /// `Max-Age` of the set cookie, in seconds (default 3600 — one
5252 /// hour; validated to 1..=2_592_000, 30 days).
5253 #[serde(
5254 default = "default_sticky_ttl_s",
5255 skip_serializing_if = "is_default_sticky_ttl_s"
5256 )]
5257 pub ttl_s: u64,
5258}
5259
5260fn default_sticky_ttl_s() -> u64 {
5261 3_600
5262}
5263fn is_default_sticky_ttl_s(t: &u64) -> bool {
5264 *t == 3_600
5265}
5266
5267/// DNS-based dynamic upstream discovery (DW-042): when present on an
5268/// upstream, a background discovery task resolves `hostname` via DNS,
5269/// watches the record TTL, and re-resolves when the TTL expires (or at
5270/// `refresh_interval_s`, whichever comes first), updating the upstream's
5271/// endpoint set live — without a restart. The `endpoints` field becomes
5272/// the INITIAL/fallback set: used until the first resolution completes
5273/// and as a fallback when DNS fails and `fail_open` is true. When
5274/// absent, endpoints remain static (the current behavior — no change).
5275///
5276/// Only A and SRV records are supported. A records pair each resolved
5277/// address with `port`; SRV records carry their own port. Consul watch
5278/// and Kubernetes EndpointSlice watch are deferred to a future
5279/// milestone — DNS is the first discovery source.
5280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5281#[serde(deny_unknown_fields)]
5282pub struct DnsDiscovery {
5283 /// The DNS hostname to resolve (e.g. `my-service.example.com`).
5284 pub hostname: String,
5285 /// The port to pair with each resolved A-record address. Ignored
5286 /// for SRV records (they carry their own port).
5287 pub port: u16,
5288 /// How often to re-resolve, in seconds (default 30; bounded to
5289 /// 1..=3600). The actual refresh interval is
5290 /// `min(refresh_interval_s, record_ttl)` so a short-TTL record is
5291 /// refreshed sooner.
5292 #[serde(
5293 default = "default_dns_discovery_refresh_s",
5294 skip_serializing_if = "is_default_dns_discovery_refresh_s"
5295 )]
5296 pub refresh_interval_s: u64,
5297 /// DNS record type: `"A"` (default) or `"SRV"`.
5298 #[serde(
5299 default = "default_dns_discovery_record_type",
5300 skip_serializing_if = "is_default_dns_discovery_record_type"
5301 )]
5302 pub record_type: String,
5303 /// If true (default), keep the last resolved endpoint set when DNS
5304 /// fails. If false, clear endpoints on failure (the upstream
5305 /// answers 503 until DNS recovers).
5306 #[serde(
5307 default = "default_dns_discovery_fail_open",
5308 skip_serializing_if = "is_default_dns_discovery_fail_open"
5309 )]
5310 pub fail_open: bool,
5311 /// Minimum endpoints to keep serving (default 1). If a resolution
5312 /// yields fewer than this, the previous set is kept (the upstream
5313 /// does not shrink below the floor).
5314 #[serde(
5315 default = "default_dns_discovery_min_endpoints",
5316 skip_serializing_if = "is_default_dns_discovery_min_endpoints"
5317 )]
5318 pub min_endpoints: u32,
5319}
5320
5321fn default_dns_discovery_refresh_s() -> u64 {
5322 30
5323}
5324fn is_default_dns_discovery_refresh_s(v: &u64) -> bool {
5325 *v == 30
5326}
5327fn default_dns_discovery_record_type() -> String {
5328 "A".to_string()
5329}
5330fn is_default_dns_discovery_record_type(v: &String) -> bool {
5331 v == "A"
5332}
5333fn default_dns_discovery_fail_open() -> bool {
5334 true
5335}
5336fn is_default_dns_discovery_fail_open(v: &bool) -> bool {
5337 *v
5338}
5339fn default_dns_discovery_min_endpoints() -> u32 {
5340 1
5341}
5342fn is_default_dns_discovery_min_endpoints(v: &u32) -> bool {
5343 *v == 1
5344}
5345
5346/// Load-balancing pool: algorithm, protocol, endpoints.
5347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5348#[serde(deny_unknown_fields)]
5349pub struct Upstream {
5350 pub name: String,
5351 #[serde(default = "default_load_balancer")]
5352 pub load_balancer: LoadBalancer,
5353 /// DP-07 (#236): what to hash for consistent-hash load balancers
5354 /// (`ip_hash` and `maglev`). Defaults to `client_ip` (the pre-#236
5355 /// behavior). When set to `cookie` or `header`, the gateway extracts
5356 /// the value from the request and uses it as the hash key; if the
5357 /// cookie/header is absent, it falls back to the client IP.
5358 #[serde(default, skip_serializing_if = "Option::is_none")]
5359 pub hash_on: Option<HashOn>,
5360 /// Protocol used toward upstream endpoints.
5361 #[serde(default = "default_upstream_protocol")]
5362 pub protocol: UpstreamProtocol,
5363 /// Path to a PEM file of CA certificates this upstream's TLS
5364 /// connections trust INSTEAD of the default public (webpki) root set
5365 /// (#121): the way to proxy an `https`/`http2` upstream whose server
5366 /// certificate chains to a private CA. The file may carry several
5367 /// certificates (a typical CA bundle). Only meaningful for the TLS
5368 /// protocols — no TLS is negotiated toward `http1` endpoints, so
5369 /// validation rejects that combination. Active `http` health probes
5370 /// for this upstream verify against the same roots. The file must
5371 /// exist and be readable at config compile time; validation names
5372 /// this field otherwise.
5373 #[serde(default, skip_serializing_if = "Option::is_none")]
5374 pub trusted_ca_file: Option<String>,
5375 /// REL-08 (#219): when true, the upstream's TLS connections trust the
5376 /// OS-native root CA store (loaded via `rustls-native-certs`) INSTEAD
5377 /// of the bundled webpki root set. Useful in deployments whose
5378 /// private CAs are provisioned through the host's trust store (e.g.
5379 /// corporate PKI, cloud metadata CAs). Mutually exclusive with
5380 /// `trusted_ca_file` (validation rejects a configuration that sets
5381 /// both). Only meaningful for the TLS protocols (`https`/`http2`/`h3`)
5382 /// — no TLS is negotiated toward `http1` endpoints, so validation
5383 /// rejects that combination. Active health probes for this upstream
5384 /// verify against the same roots. Defaults to `false` (webpki roots).
5385 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
5386 pub use_system_roots: bool,
5387 /// Static endpoint list for this upstream. When `dns_discovery` is
5388 /// present, this becomes the initial/fallback set (used until the
5389 /// first DNS resolution completes and as a fallback when DNS fails
5390 /// and `fail_open` is true); it may be empty. When `dns_discovery`
5391 /// is absent, this is the complete endpoint set and must be
5392 /// non-empty (validation rejects an empty list).
5393 #[serde(default)]
5394 pub endpoints: Vec<Endpoint>,
5395 /// Maximum number of concurrent outbound connections to this upstream
5396 /// (active plus pooled idle). Defaults to 256 when absent. Enforced by
5397 /// the upstream client (DW-008); excess connection attempts wait for a
5398 /// slot rather than fail. Raised from 64 to 256 (#270) to avoid
5399 /// unbounded memory growth under high concurrency.
5400 #[serde(default, skip_serializing_if = "Option::is_none")]
5401 pub connection_cap: Option<u32>,
5402 /// Slow-start window in milliseconds (DW-011): an endpoint entering the
5403 /// upstream's set ramps its effective load-balancing weight from ~0 up
5404 /// to its configured weight over this window. Absent (or 0) disables
5405 /// the ramp. Applies to the weighted algorithms (round_robin; ip_hash
5406 /// vnode counts stay fixed so ring consistency is preserved).
5407 /// Validation bounds the value to at most 10 minutes.
5408 #[serde(default, skip_serializing_if = "Option::is_none")]
5409 pub slow_start_ms: Option<u64>,
5410 /// Passive health checking / outlier detection (DW-012): eject
5411 /// endpoints that fail real traffic (transport errors and 5xx), let
5412 /// them back via half-open trial probes after `eject_ms`. Absent
5413 /// disables passive health entirely (no ejections).
5414 #[serde(default, skip_serializing_if = "Option::is_none")]
5415 pub health: Option<PassiveHealth>,
5416 /// Active health checks (DW-013): synthetic HTTP/TCP probes per
5417 /// endpoint on a fixed interval with full jitter. Probe results report
5418 /// into the SAME per-endpoint ejection machinery as passive health, so
5419 /// an endpoint failing its probes leaves load-balancer rotation and a
5420 /// success streak returns it. Requires the passive `health` block
5421 /// (which owns the ejection/recovery windows); rejected by validation
5422 /// otherwise. Absent disables active probing.
5423 #[serde(default, skip_serializing_if = "Option::is_none")]
5424 pub active_health: Option<ActiveHealth>,
5425 /// Upstream retries (DW-014): bounded per-request retry attempts with
5426 /// exponential backoff + full jitter, a retry budget, and opt-in
5427 /// size-capped request-body buffering. Absent (or `attempts` left at
5428 /// its default 0) disables retries entirely: every request gets exactly
5429 /// one attempt and the proxy path keeps its zero-copy streaming body.
5430 #[serde(default, skip_serializing_if = "Option::is_none")]
5431 pub retries: Option<RetryConfig>,
5432 /// Per-upstream circuit breaker (DW-015): opens the WHOLE upstream on
5433 /// consecutive failures or a rolling error ratio, fails fast with 503
5434 /// while open, probes half-open after `breaker.open_ms`. Absent
5435 /// disables the breaker entirely (no fail-fast, behavior identical to
5436 /// pre-DW-015).
5437 #[serde(default, skip_serializing_if = "Option::is_none")]
5438 pub breaker: Option<BreakerConfig>,
5439 /// Maximum number of requests WAITING for an outbound connection slot
5440 /// to this upstream (DW-015). 0/absent (the default) means a bounded
5441 /// queue of 256 — the default bounds memory under high concurrency
5442 /// (#270). A positive value rejects excess requests IMMEDIATELY with
5443 /// 503 "upstream saturated" instead of letting them wait. To restore
5444 /// the pre-#270 unbounded queueing behavior, set a very large value.
5445 #[serde(default, skip_serializing_if = "Option::is_none")]
5446 pub max_pending: Option<u32>,
5447 #[serde(skip_serializing_if = "Option::is_none")]
5448 pub timeouts: Option<Timeouts>,
5449 /// OAuth2 client-credentials proxying (DW-035): the gateway acts as
5450 /// an OAuth2 client itself, obtaining an access token from an
5451 /// external token endpoint via the client-credentials flow and
5452 /// forwarding it to this upstream as a `Bearer` token (replacing
5453 /// any client-supplied `Authorization` header). For service-to-
5454 /// service calls where the gateway authenticates to the upstream
5455 /// with a token it obtains from an OAuth2 token endpoint. Absent
5456 /// (the default): no token acquisition; the upstream sees whatever
5457 /// `Authorization` the client sent (subject to the authn family's
5458 /// pass-through rules). See the `security::oauth2` module docs for
5459 /// the flow, caching, and the mTLS-to-the-token-endpoint option.
5460 #[serde(default, skip_serializing_if = "Option::is_none")]
5461 pub oauth2_client_credentials: Option<OAuth2ClientCredentials>,
5462 /// DNS-based dynamic upstream discovery (DW-042): when present, a
5463 /// background task resolves `hostname` via DNS and updates the
5464 /// endpoint set live as records change. The `endpoints` field
5465 /// becomes the initial/fallback set. Absent (the default): endpoints
5466 /// remain static (the current behavior — no change).
5467 #[serde(default, skip_serializing_if = "Option::is_none")]
5468 pub dns_discovery: Option<DnsDiscovery>,
5469 /// Peak-EWMA tuning (DW-090): only meaningful when
5470 /// `load_balancer: peak_ewma`. Absent (the default) uses the built-in
5471 /// defaults (10 s decay tau, 250 ms initial cost). Ignored for the
5472 /// other algorithms.
5473 #[serde(default, skip_serializing_if = "Option::is_none")]
5474 pub peak_ewma: Option<PeakEwmaConfig>,
5475 /// DW-094 (Ent): locality-aware routing and data residency. When
5476 /// present, the balancer filters or prefers endpoints by region/zone
5477 /// before applying the configured `load_balancer` algorithm. Ent-only:
5478 /// rejected at validation when the `ent` cargo feature is off.
5479 #[serde(default, skip_serializing_if = "Option::is_none")]
5480 pub locality: Option<UpstreamLocality>,
5481 /// DW-105: opt in to post-quantum hybrid key exchange
5482 /// (X25519+ML-KEM) for this upstream's TLS connections. When
5483 /// `true`, the X25519+ML-KEM
5484 /// hybrid kx group is prepended to the rustls client config's kx
5485 /// group list, PREFERRING the hybrid group while keeping the
5486 /// classical X25519 fallback for non-PQ upstreams. Compiled into
5487 /// every build (no cargo feature).
5488 /// EXPERIMENTAL: the rustls PQ API is not yet stable. Only
5489 /// meaningful for the TLS protocols (`https`, `http2`); validation
5490 /// rejects `pq: true` on an `http1` upstream (no TLS is
5491 /// negotiated). Rejected when combined with FIPS mode (ML-KEM is
5492 /// not on the FIPS-validated list for aws-lc-rs).
5493 #[serde(default, skip_serializing_if = "is_false")]
5494 pub pq: bool,
5495 /// DW-109 / SEC-04: upstream TLS certificate pinning by SPKI
5496 /// SHA-256 hash. When present, the gateway's outbound TLS client
5497 /// installs a custom certificate verifier that extracts the peer
5498 /// cert's SubjectPublicKeyInfo, computes its SHA-256, and compares
5499 /// against the configured pins. A mismatch rejects the connection
5500 /// (fail-closed: no fallback to CA-based verification). Compiled
5501 /// into every build (no cargo feature). Only meaningful for the
5502 /// TLS protocols
5503 /// (`https`, `http2`); validation rejects it on an `http1`
5504 /// upstream.
5505 #[serde(default, skip_serializing_if = "Option::is_none")]
5506 pub cert_pinning: Option<CertPinningConfig>,
5507 /// SEC-03: upstream mTLS client certificates. When present, the
5508 /// gateway presents this client certificate to the upstream during
5509 /// the TLS handshake (in addition to verifying the upstream's
5510 /// server cert against `trusted_ca_file` or the public roots).
5511 /// Required for upstreams that demand client authentication
5512 /// (banking/payment APIs, mesh-internal services). Only meaningful
5513 /// for the TLS protocols (`https`, `http2`); validation rejects it
5514 /// on an `http1` upstream.
5515 #[serde(default, skip_serializing_if = "Option::is_none")]
5516 pub mtls: Option<UpstreamMtls>,
5517 /// DP-03: hyper connection-pool and HTTP/2 tuning for this
5518 /// upstream's outbound client. Absent (the default): every knob
5519 /// falls back to hyper-util's built-in defaults (the prior
5520 /// behavior — only the pool timer was wired). When present, the
5521 /// configured knobs are applied to the per-upstream hyper-util
5522 /// legacy client builder; omitted sub-fields keep their hyper
5523 /// defaults. The pool timer is ALWAYS installed (it is required
5524 /// for idle-timeout eviction to fire), so this block only needs to
5525 /// carry the knobs an operator wants to change. Meaningful for
5526 /// every protocol: `http1`/`https` connections reuse the pool's
5527 /// idle-timeout and max-idle-per-host knobs, while `http2`
5528 /// connections additionally honor the keep-alive and
5529 /// concurrent-streams knobs.
5530 #[serde(default, skip_serializing_if = "Option::is_none")]
5531 pub pool: Option<UpstreamPoolConfig>,
5532}
5533
5534/// DP-03: hyper connection-pool and HTTP/2 tuning for an upstream
5535/// (`upstreams[].pool`). Every field is optional; an omitted field
5536/// keeps hyper-util's built-in default (the same value the gateway
5537/// used before this block existed), so the block is purely additive.
5538/// Validation rejects zero/negative-forbidden values; the runtime
5539/// clamps nothing beyond the documented bounds.
5540#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5541#[serde(deny_unknown_fields)]
5542pub struct UpstreamPoolConfig {
5543 /// How long an idle connection may sit in the pool before it is
5544 /// closed (hyper-util `pool_idle_timeout`). In milliseconds.
5545 /// Absent: hyper-util's default (90 s). `0` is rejected at
5546 /// validation (use a very small positive value to evict
5547 /// aggressively, or omit the block for the default). Bounds: at
5548 /// most 10 minutes (600000 ms).
5549 #[serde(default, skip_serializing_if = "Option::is_none")]
5550 pub pool_idle_timeout_ms: Option<u64>,
5551 /// Maximum number of idle connections retained per host
5552 /// (hyper-util `pool_max_idle_per_host`). Absent: hyper-util's
5553 /// default. The per-upstream `connection_cap` still bounds the
5554 /// total (active + idle) connections, so this only shapes the
5555 /// idle fraction. Bounds: at most 1024.
5556 #[serde(default, skip_serializing_if = "Option::is_none")]
5557 pub pool_max_idle_per_host: Option<u32>,
5558 /// HTTP/2 PING keep-alive interval (hyper-util
5559 /// `http2_keep_alive_interval`). In milliseconds. Absent: disabled
5560 /// (hyper's default — no PINGs). A positive value sends a PING
5561 /// this often on an otherwise-idle h2 connection so proxies/ALB
5562 /// in front of the upstream do not reap it. Only meaningful for
5563 /// the `http2` protocol; ignored for `http1`/`https`. Bounds: at
5564 /// most 10 minutes (600000 ms).
5565 #[serde(default, skip_serializing_if = "Option::is_none")]
5566 pub http2_keep_alive_interval_ms: Option<u64>,
5567 /// HTTP/2 keep-alive timeout (hyper-util
5568 /// `http2_keep_alive_timeout`): the time the client waits for a
5569 /// PING ACK before closing the connection. In milliseconds.
5570 /// Absent: hyper's default (20 s). Does nothing when
5571 /// `http2_keep_alive_interval_ms` is absent. Only meaningful for
5572 /// the `http2` protocol. Bounds: at most 10 minutes (600000 ms).
5573 #[serde(default, skip_serializing_if = "Option::is_none")]
5574 pub http2_keep_alive_timeout_ms: Option<u64>,
5575 /// Enable HTTP/2 adaptive flow-control window (hyper-util
5576 /// `http2_adaptive_window`). Absent: false (hyper's default —
5577 /// fixed initial window). When true, the connection window grows
5578 /// and shrinks based on throughput, which helps large/long
5579 /// responses on high-BDP paths. Only meaningful for the `http2`
5580 /// protocol; ignored otherwise.
5581 #[serde(default, skip_serializing_if = "is_false")]
5582 pub http2_adaptive_window: bool,
5583 /// Initial cap on the number of concurrent HTTP/2 streams the
5584 /// client will send over a single connection (hyper-util
5585 /// `http2_initial_max_send_streams`). This is an *initial* value
5586 /// only: hyper-util's default is 100, and the effective cap is
5587 /// further bounded by the server's advertised
5588 /// SETTINGS_MAX_CONCURRENT_STREAMS once the peer's SETTINGS frame
5589 /// arrives. A finite value bounds head-of-line blocking when one
5590 /// stream stalls. Only meaningful for the `http2` protocol.
5591 /// Bounds: at least 1, at most 1_000_000.
5592 #[serde(default, skip_serializing_if = "Option::is_none")]
5593 pub max_concurrent_streams: Option<u32>,
5594 /// SCALE-10 (#189): number of connections to pre-establish per
5595 /// endpoint on startup/reload. Pre-warming removes cold-start
5596 /// latency spikes after reloads and upgrade hand-offs. `0` or
5597 /// absent: no pre-warming (the default — connections are
5598 /// established on first use). Bounds: at most 64 per endpoint.
5599 #[serde(default, skip_serializing_if = "Option::is_none")]
5600 pub pre_warm: Option<u32>,
5601 /// SCALE-10 (#189): per-endpoint connection cap, bounding
5602 /// concurrent connections to a SINGLE endpoint (address:port)
5603 /// within the upstream. The per-upstream `connection_cap` still
5604 /// bounds the total across all endpoints; this knob prevents one
5605 /// busy endpoint from monopolizing the upstream's connection
5606 /// budget. Absent: no per-endpoint cap (only the per-upstream
5607 /// cap applies). Bounds: at least 1, at most 1024.
5608 #[serde(default, skip_serializing_if = "Option::is_none")]
5609 pub per_endpoint_cap: Option<u32>,
5610 /// SCALE-10 (#189): maximum age of a pooled connection before it
5611 /// is recycled. In milliseconds. Absent: no max-age (connections
5612 /// live until the idle timeout or a peer-initiated close). A
5613 /// positive value sets the pool idle timeout to the minimum of
5614 /// this and `pool_idle_timeout_ms`, so connections are evicted
5615 /// before they become stale. Bounds: at most 10 minutes
5616 /// (600000 ms).
5617 #[serde(default, skip_serializing_if = "Option::is_none")]
5618 pub max_connection_age_ms: Option<u64>,
5619 /// Initial stream-level window size in bytes for the upstream h2
5620 /// client (hyper `http2_initial_stream_window_size`). Absent:
5621 /// hyper's default. Larger values reduce flow-control stalls on
5622 /// streaming (SSE) responses from the upstream but increase
5623 /// memory per stream. Only meaningful for the `http2` protocol.
5624 /// Bounds: at most 16 MiB. (PERF-11, #244)
5625 #[serde(default, skip_serializing_if = "Option::is_none")]
5626 pub http2_initial_stream_window_size: Option<u32>,
5627 /// Initial connection-level window size in bytes for the upstream
5628 /// h2 client (hyper `http2_initial_connection_window_size`).
5629 /// Absent: hyper's default. Bounds: at most 64 MiB.
5630 /// (PERF-11, #244)
5631 #[serde(default, skip_serializing_if = "Option::is_none")]
5632 pub http2_initial_connection_window_size: Option<u32>,
5633}
5634
5635/// SEC-03: upstream mTLS client certificate configuration. Reuses the
5636/// two-path shape (cert file + key file) proven by the OAuth2 mTLS
5637/// config (`OAuth2Mtls`).
5638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5639#[serde(deny_unknown_fields)]
5640pub struct UpstreamMtls {
5641 /// Path to the PEM client certificate chain to present to the
5642 /// upstream during the TLS handshake.
5643 pub client_cert_file: String,
5644 /// Path to the PEM private key for `client_cert_file`.
5645 pub client_key_file: String,
5646}
5647
5648/// DW-109 / SEC-04: certificate pinning configuration for an upstream.
5649/// Holds a list of allowed SPKI SHA-256 hashes (lowercase hex, 64
5650/// chars). A peer cert whose SPKI hash matches ANY entry is accepted;
5651/// otherwise the connection is rejected (fail-closed).
5652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5653#[serde(deny_unknown_fields)]
5654pub struct CertPinningConfig {
5655 /// The allowed SPKI SHA-256 hashes (lowercase hex, 64 chars each).
5656 /// Must be non-empty (validation rejects an empty pin list — a
5657 /// pin-less pinning block is an authoring mistake, not "pin
5658 /// nothing").
5659 pub pins: Vec<CertPin>,
5660}
5661
5662/// A single certificate pin: the SHA-256 hash of the upstream
5663/// certificate's SubjectPublicKeyInfo (SPKI), as lowercase hex (64
5664/// chars). Pinning the SPKI (rather than the full certificate) allows
5665/// rotation of the leaf certificate as long as the key pair is
5666/// unchanged.
5667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5668#[serde(deny_unknown_fields)]
5669pub struct CertPin {
5670 /// The SHA-256 hash of the SPKI, as lowercase hex (64 chars).
5671 pub spki_sha256: String,
5672}
5673
5674/// DW-094 (Ent): locality-aware routing and data residency configuration
5675/// for an upstream. Controls how the balancer uses endpoint `region`/
5676/// `zone` fields to filter or prefer endpoints.
5677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5678#[serde(deny_unknown_fields)]
5679pub struct UpstreamLocality {
5680 /// The routing mode (DW-094):
5681 /// - `prefer`: prefer endpoints in the edge's region; fall back to
5682 /// other regions if none are available (healthy).
5683 /// - `strict`: only route to endpoints in the edge's region; fail
5684 /// with 503 if none are available (no cross-region fallback).
5685 /// - `failover`: prefer the edge's region, fall back to other
5686 /// regions only when all local endpoints are ejected (like
5687 /// `prefer` but with a stricter local-first policy — local
5688 /// endpoints are always tried first even if unhealthy).
5689 #[serde(default)]
5690 pub mode: LocalityMode,
5691 /// Data residency enforcement: when true, the balancer drops
5692 /// endpoints whose `region` is not in `allowed_regions` BEFORE
5693 /// applying the locality mode. This is a hard constraint —
5694 /// endpoints outside the allowed set are never used, even as a
5695 /// fallback. Use this to comply with data sovereignty requirements
5696 /// (e.g. EU traffic must stay in EU regions).
5697 #[serde(default)]
5698 pub enforce_data_residency: bool,
5699 /// The allowed regions when `enforce_data_residency` is true. An
5700 /// endpoint with no `region` field is always allowed (locality-
5701 /// agnostic). An endpoint with a `region` not in this list is
5702 /// dropped. Must be non-empty when `enforce_data_residency` is true.
5703 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5704 pub allowed_regions: Vec<String>,
5705 /// The denied regions (optional). An endpoint with a `region` in
5706 /// this list is always dropped, regardless of other settings. Use
5707 /// this to exclude specific regions without enumerating all
5708 /// allowed ones.
5709 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5710 pub denied_regions: Vec<String>,
5711}
5712
5713/// DW-094 (Ent): locality routing mode.
5714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
5715#[serde(rename_all = "snake_case")]
5716pub enum LocalityMode {
5717 /// Prefer endpoints in the edge's region; fall back to other
5718 /// regions if none are available (healthy). The default.
5719 #[default]
5720 Prefer,
5721 /// Only route to endpoints in the edge's region; fail with 503 if
5722 /// none are available (no cross-region fallback).
5723 Strict,
5724 /// Prefer the edge's region, fall back to other regions only when
5725 /// all local endpoints are ejected (local-first with failover).
5726 Failover,
5727}
5728
5729/// DW-094 (Ent): the edge's locality context — its region and zone.
5730/// Threaded into the balancer at request time to enable locality-aware
5731/// endpoint selection. When both `region` and `zone` are `None`, the
5732/// balancer skips locality filtering (the OSS behavior).
5733///
5734/// The context is set from `EdgeState.labels` (the `region` and `zone`
5735/// keys) when running as an edge, or from the `DWARA_REGION`/
5736/// `DWARA_ZONE` environment variables in embedded mode.
5737#[derive(Debug, Clone, Default, PartialEq, Eq)]
5738pub struct LocalityContext {
5739 /// The edge's region (e.g. `us-east-1`).
5740 pub region: Option<String>,
5741 /// The edge's zone (e.g. `us-east-1a`).
5742 pub zone: Option<String>,
5743}
5744
5745impl LocalityContext {
5746 /// Create a new locality context.
5747 pub fn new(region: Option<String>, zone: Option<String>) -> Self {
5748 Self { region, zone }
5749 }
5750
5751 /// Whether locality-aware routing should be applied (at least one
5752 /// of region/zone is set).
5753 pub fn is_set(&self) -> bool {
5754 self.region.is_some() || self.zone.is_some()
5755 }
5756
5757 /// Whether an endpoint region matches this context's region.
5758 /// An endpoint with no region is always a match (locality-agnostic).
5759 pub fn region_matches(&self, endpoint_region: Option<&str>) -> bool {
5760 match (&self.region, endpoint_region) {
5761 (Some(ctx_region), Some(ep_region)) => ctx_region == ep_region,
5762 _ => true,
5763 }
5764 }
5765
5766 /// Whether an endpoint zone matches this context's zone.
5767 /// An endpoint with no zone is always a match (zone-agnostic).
5768 pub fn zone_matches(&self, endpoint_zone: Option<&str>) -> bool {
5769 match (&self.zone, endpoint_zone) {
5770 (Some(ctx_zone), Some(ep_zone)) => ctx_zone == ep_zone,
5771 _ => true,
5772 }
5773 }
5774
5775 /// Load the locality context from environment variables
5776 /// (`DWARA_REGION`, `DWARA_ZONE`). Returns a context with `None`
5777 /// values when the variables are not set.
5778 pub fn from_env() -> Self {
5779 Self {
5780 region: std::env::var("DWARA_REGION").ok(),
5781 zone: std::env::var("DWARA_ZONE").ok(),
5782 }
5783 }
5784}
5785
5786/// Peak-EWMA load-balancing tuning (DW-090, `upstreams[].peak_ewma`).
5787/// Only consulted when `load_balancer: peak_ewma`; ignored otherwise.
5788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5789#[serde(deny_unknown_fields)]
5790pub struct PeakEwmaConfig {
5791 /// Decay tau in milliseconds (default 10 000 = 10 s). The EWMA weight
5792 /// is `exp(-td / tau)` where `td` is the time since the last update.
5793 /// A larger tau makes the cost track slow endpoints more sluggishly
5794 /// (more stable, less reactive); a smaller tau reacts faster to
5795 /// latency changes. Must be > 0.
5796 #[serde(default, skip_serializing_if = "Option::is_none")]
5797 pub decay_ms: Option<u64>,
5798 /// Initial cost in milliseconds (default 250). A fresh endpoint's
5799 /// EWMA cost starts here (converted to nanoseconds) so the first
5800 /// picks have a reasonable baseline before any real RTT is observed.
5801 /// Must be > 0.
5802 #[serde(default, skip_serializing_if = "Option::is_none")]
5803 pub default_rtt_ms: Option<u64>,
5804}
5805
5806/// OAuth2 client-credentials configuration for an upstream (DW-035,
5807/// `upstreams[].oauth2_client_credentials`).
5808///
5809/// The gateway obtains an access token from `token_endpoint` using the
5810/// client-credentials grant (RFC 6749 section 4.4) and forwards it to
5811/// the upstream as `Authorization: Bearer <token>`. The client
5812/// authenticates to the token endpoint with HTTP Basic auth
5813/// (`client_id:client_secret`, RFC 6749 section 2.3.1). Tokens are
5814/// cached per upstream (keyed by upstream name on the dataplane) and
5815/// refreshed lazily on the first request after expiry — no background
5816/// refresh task. An optional `mtls` block configures a client
5817/// certificate for the TLS handshake to the token endpoint itself
5818/// (RFC 8705, the `tls_client_auth` method).
5819#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5820#[serde(deny_unknown_fields)]
5821pub struct OAuth2ClientCredentials {
5822 /// The token endpoint URL (RFC 6749 section 3.2). Must be an
5823 /// absolute `http(s)://` URL; validation rejects anything else.
5824 pub token_endpoint: String,
5825 /// The client identifier presented to the token endpoint
5826 /// (RFC 6749 section 2.2). Sent as the username of the HTTP Basic
5827 /// auth header.
5828 pub client_id: String,
5829 /// The client secret presented to the token endpoint (RFC 6749
5830 /// section 2.3.1). Either the value INLINE (redacted in every
5831 /// config echo, DW-045) or a `${...}` secret reference (env or
5832 /// file) resolved at config-compile time and re-read on every
5833 /// reload. Sent as the password of the HTTP Basic auth header.
5834 pub client_secret: String,
5835 /// Optional scope string (RFC 6749 section 3.3): a space-delimited
5836 /// list of requested scopes sent in the token request body. Absent
5837 /// = no scope parameter (the endpoint's default scopes apply).
5838 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5839 pub scopes: Vec<String>,
5840 /// Optional mTLS client certificate for the TLS handshake to the
5841 /// token endpoint itself (RFC 8705 `tls_client_auth`). Absent: the
5842 /// token endpoint's TLS is negotiated with no client certificate
5843 /// (the default webpki root set, or a `trusted_ca_file` when the
5844 /// token endpoint is behind a private CA — note the token endpoint
5845 /// trust is NOT the upstream's `trusted_ca_file`; the token
5846 /// endpoint is a separate entity).
5847 #[serde(default, skip_serializing_if = "Option::is_none")]
5848 pub mtls: Option<OAuth2Mtls>,
5849 /// Token cache TTL override in seconds. Absent (the default): use
5850 /// the token's own `expires_in` (minus a refresh skew margin). When
5851 /// set, the cache TTL is `min(token_expires_in - skew,
5852 /// token_cache_ttl_s)` so a shorter override never extends a
5853 /// token's real lifetime. Must be > 0.
5854 #[serde(default, skip_serializing_if = "Option::is_none")]
5855 pub token_cache_ttl_s: Option<u64>,
5856}
5857
5858/// mTLS client certificate for an OAuth2 token endpoint (DW-035,
5859/// `oauth2_client_credentials.mtls`).
5860#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5861#[serde(deny_unknown_fields)]
5862pub struct OAuth2Mtls {
5863 /// Path to a PEM file containing the client certificate chain.
5864 pub client_cert: String,
5865 /// Path to a PEM file containing the matching private key.
5866 pub client_key: String,
5867}
5868
5869/// Upstream retry knobs (DW-014). All fields default; a `retries:` block
5870/// with no keys is equivalent to retries off (`attempts` defaults to 0).
5871///
5872/// Frozen semantics (see `upstream`/`proxy`/`retries` module docs):
5873/// - `attempts` is the maximum number of RETRIES beyond the first attempt
5874/// (0 = off). Validation caps it at 10.
5875/// - Only requests whose body was fully buffered within `buffer_max_bytes`
5876/// may be retried; an over-cap body streams without retry. Buffering is
5877/// opt-in: the default (0) buffers only empty bodies, so the default
5878/// proxy path stays unbuffered.
5879/// - Retries happen strictly BEFORE response headers arrive on the final
5880/// attempt; a response body that dies mid-stream is never retried (its
5881/// failure is reported to passive health instead).
5882/// - Every retried attempt is charged against the upstream's rolling-window
5883/// retry budget (`budget_percent`).
5884/// - `total_deadline_ms` (REL-01) caps the wall-clock time from the first
5885/// attempt to the last, INCLUDING backoff delays between attempts. Absent
5886/// leaves the cross-attempt budget unbounded (the v1 default, kept for
5887/// backwards compatibility). When set, a retry whose backoff would cross
5888/// the deadline is aborted and the last response/error is returned to the
5889/// client; the per-attempt `read_ms` timeout still bounds each individual
5890/// attempt. Standard in Envoy and NGINX.
5891#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5892#[serde(deny_unknown_fields)]
5893pub struct RetryConfig {
5894 /// Maximum retries beyond the first attempt (default 0 = off).
5895 #[serde(default = "default_retry_attempts")]
5896 pub attempts: u32,
5897 /// Retry non-idempotent POST requests. Default false: POST is never
5898 /// retried unless an operator explicitly opts in here (a retried POST
5899 /// may replay a body the upstream already partially processed).
5900 #[serde(default = "default_retry_post", skip_serializing_if = "is_false")]
5901 pub retry_post: bool,
5902 /// Exponential backoff base in milliseconds (default 25): the nominal
5903 /// delay before retry n is `min(base * 2^(n-1), backoff_cap_ms)`.
5904 #[serde(default = "default_retry_backoff_base_ms")]
5905 pub backoff_base_ms: u64,
5906 /// Exponential backoff ceiling in milliseconds (default 250). Must be
5907 /// >= `backoff_base_ms`.
5908 #[serde(default = "default_retry_backoff_cap_ms")]
5909 pub backoff_cap_ms: u64,
5910 /// Response statuses that trigger a retry when received as the upstream
5911 /// response status (default `[502, 503, 504]`). Each entry must be a
5912 /// valid 4xx/5xx status. An empty list disables status-based retries.
5913 #[serde(default = "default_retry_statuses")]
5914 pub retry_statuses: Vec<u16>,
5915 /// Retry on transport errors (connect timeout/refusal/reset/framing)
5916 /// and per-attempt read timeouts (default true).
5917 #[serde(default = "default_retry_transport", skip_serializing_if = "is_true")]
5918 pub retry_transport: bool,
5919 /// Retry budget: the maximum percentage of requests to this upstream,
5920 /// in a rolling window, that may be retries (default 10). Must be in
5921 /// (0, 100]. When the budget is exhausted, failing requests fail
5922 /// through to the client instead of retrying.
5923 #[serde(default = "default_retry_budget_percent")]
5924 pub budget_percent: u32,
5925 /// Request-body buffering cap in bytes (default 0 = no buffering). A
5926 /// request body is buffered (and becomes replayable) only while it fits
5927 /// within this cap; larger bodies stream and are never retried.
5928 #[serde(default)]
5929 pub buffer_max_bytes: u64,
5930 /// Request hedging (DW-063): after `hedge_after_ms` milliseconds
5931 /// without a response, a speculative duplicate request is sent to a
5932 /// different endpoint; the first response wins and the loser is
5933 /// cancelled. Requires `buffer_max_bytes > 0` (the body must be
5934 /// replayable to send a hedge copy) and idempotent methods (GET,
5935 /// HEAD, OPTIONS, TRACE, PUT) — POST is hedged only when
5936 /// `retry_post` is also true. Absent (or `hedge_after_ms` = 0)
5937 /// disables hedging. At most `hedge_max` speculative copies are
5938 /// sent per request (default 1).
5939 #[serde(default, skip_serializing_if = "Option::is_none")]
5940 pub hedge: Option<HedgeConfig>,
5941 /// Cross-attempt total retry deadline in milliseconds (REL-01): caps
5942 /// the wall-clock time from the first attempt to the last, INCLUDING
5943 /// backoff delays between attempts. Absent leaves the cross-attempt
5944 /// budget unbounded (the v1 default, kept for backwards
5945 /// compatibility). When set, a retry whose backoff would cross the
5946 /// deadline is aborted and the last response/error is returned to the
5947 /// client; the per-attempt `read_ms` timeout still bounds each
5948 /// individual attempt. Must be in (0, `MAX_RETRY_TOTAL_DEADLINE_MS`];
5949 /// `0` is rejected (omit the field for unbounded).
5950 #[serde(default, skip_serializing_if = "Option::is_none")]
5951 pub total_deadline_ms: Option<u64>,
5952 /// Streaming failover first-frame buffer cap in bytes (REL-05, #216).
5953 /// When > 0, the proxy buffers up to this many bytes of the upstream
5954 /// response body BEFORE forwarding any bytes to the client. If the
5955 /// upstream fails (connection reset, read timeout, framing error)
5956 /// before the buffer fills or the body completes, the proxy can
5957 /// still retry the request to a different endpoint — the client has
5958 /// not yet received any bytes, so the response is not committed.
5959 /// Once the buffer fills (or the body completes), the buffered
5960 /// prefix is flushed to the client and the rest of the body streams
5961 /// frame-by-frame (the stream is then committed; a later mid-body
5962 /// failure is terminal, as in v1). Default 0 = no first-frame
5963 /// buffering (v1 behavior: the response is committed as soon as
5964 /// headers resolve). Requires `attempts > 0` and
5965 /// `buffer_max_bytes > 0` (the request body must be replayable).
5966 #[serde(default)]
5967 pub buffer_first_frame_bytes: u64,
5968}
5969
5970impl Default for RetryConfig {
5971 fn default() -> Self {
5972 RetryConfig {
5973 attempts: default_retry_attempts(),
5974 retry_post: default_retry_post(),
5975 backoff_base_ms: default_retry_backoff_base_ms(),
5976 backoff_cap_ms: default_retry_backoff_cap_ms(),
5977 retry_statuses: default_retry_statuses(),
5978 retry_transport: default_retry_transport(),
5979 budget_percent: default_retry_budget_percent(),
5980 buffer_max_bytes: 0,
5981 hedge: None,
5982 total_deadline_ms: None,
5983 buffer_first_frame_bytes: 0,
5984 }
5985 }
5986}
5987
5988/// Request hedging configuration (DW-063, `upstreams[].retries.hedge`).
5989///
5990/// After `hedge_after_ms` without a response, a speculative duplicate
5991/// request is sent to a different endpoint. The first response (headers
5992/// resolved) wins; the loser is cancelled. Hedging requires a replayable
5993/// body (`buffer_max_bytes > 0`) and idempotent semantics (POST is hedged
5994/// only when `retry_post` is true).
5995#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5996#[serde(deny_unknown_fields)]
5997pub struct HedgeConfig {
5998 /// Milliseconds to wait before sending a hedge copy (the "tail
5999 /// latency" threshold). Must be > 0. A typical value is the p90
6000 /// latency of the upstream — requests slower than this get a
6001 /// speculative parallel copy.
6002 pub hedge_after_ms: u64,
6003 /// Maximum number of speculative hedge copies per request (default
6004 /// 1). Must be in [1, 4]. Hedge copies are NOT charged against the
6005 /// retry budget — the budget prevents retry storms after failures,
6006 /// while hedging is a proactive performance optimization.
6007 #[serde(default = "default_hedge_max")]
6008 pub hedge_max: u32,
6009}
6010
6011fn default_hedge_max() -> u32 {
6012 1
6013}
6014
6015fn default_retry_attempts() -> u32 {
6016 0
6017}
6018
6019fn default_retry_post() -> bool {
6020 false
6021}
6022
6023fn default_retry_backoff_base_ms() -> u64 {
6024 25
6025}
6026
6027fn default_retry_backoff_cap_ms() -> u64 {
6028 250
6029}
6030
6031fn default_retry_statuses() -> Vec<u16> {
6032 vec![502, 503, 504]
6033}
6034
6035fn default_retry_transport() -> bool {
6036 true
6037}
6038
6039fn default_retry_budget_percent() -> u32 {
6040 10
6041}
6042
6043fn is_false(b: &bool) -> bool {
6044 !*b
6045}
6046
6047/// Per-upstream circuit breaker knobs (DW-015). All fields default; a
6048/// `breaker:` block with no keys enables the breaker with the defaults.
6049///
6050/// Frozen semantics (see the `breaker` module docs):
6051/// - The breaker gates the WHOLE upstream (all endpoints); per-endpoint
6052/// ejection (DW-012) is an independent layer beneath it.
6053/// - It opens on `consecutive_failures` consecutive failures (5xx or
6054/// transport) OR an in-window error ratio >= `error_ratio` once at least
6055/// `error_volume` observations are in the 60 s window.
6056/// - While open, requests fail fast with 503 and a `Retry-After` header
6057/// (seconds until half-open); in-flight requests complete normally.
6058/// - After `open_ms` a half-open probe (`half_open_probes` concurrent
6059/// trials) closes the breaker on success or re-opens it on failure.
6060#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
6061#[serde(deny_unknown_fields)]
6062pub struct BreakerConfig {
6063 /// Consecutive failures (5xx + transport) that open the breaker
6064 /// (default 5).
6065 #[serde(default = "default_breaker_consecutive_failures")]
6066 pub consecutive_failures: u32,
6067 /// In-window error ratio in (0, 1] that opens the breaker once
6068 /// `error_volume` observations exist (default 0.5).
6069 #[serde(default = "default_breaker_error_ratio")]
6070 pub error_ratio: f64,
6071 /// Minimum observations in the 60 s window before the ratio is
6072 /// evaluated (default 20).
6073 #[serde(default = "default_breaker_error_volume")]
6074 pub error_volume: u32,
6075 /// Cooling-off period in milliseconds before a half-open probe is
6076 /// admitted (default 30000).
6077 #[serde(default = "default_breaker_open_ms")]
6078 pub open_ms: u64,
6079 /// Concurrent trial requests admitted in half-open (default 1).
6080 #[serde(default = "default_breaker_half_open_probes")]
6081 pub half_open_probes: u32,
6082}
6083
6084impl Default for BreakerConfig {
6085 fn default() -> Self {
6086 BreakerConfig {
6087 consecutive_failures: default_breaker_consecutive_failures(),
6088 error_ratio: default_breaker_error_ratio(),
6089 error_volume: default_breaker_error_volume(),
6090 open_ms: default_breaker_open_ms(),
6091 half_open_probes: default_breaker_half_open_probes(),
6092 }
6093 }
6094}
6095
6096fn default_breaker_consecutive_failures() -> u32 {
6097 5
6098}
6099
6100fn default_breaker_error_ratio() -> f64 {
6101 0.5
6102}
6103
6104fn default_breaker_error_volume() -> u32 {
6105 20
6106}
6107
6108fn default_breaker_open_ms() -> u64 {
6109 30_000
6110}
6111
6112fn default_breaker_half_open_probes() -> u32 {
6113 1
6114}
6115
6116fn is_true(b: &bool) -> bool {
6117 *b
6118}
6119
6120fn default_false() -> bool {
6121 false
6122}
6123
6124/// Passive health / outlier detection knobs (DW-012). All fields default;
6125/// a `health:` block with no keys enables ejection with the defaults.
6126///
6127/// Failure classification: transport errors (connect timeout, refusal,
6128/// reset) and HTTP statuses >= 500 are failures; 1xx-4xx are successes.
6129/// 429/408 are deliberately successes in v1 (they describe the caller or
6130/// queueing, not endpoint health).
6131#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
6132#[serde(deny_unknown_fields)]
6133pub struct PassiveHealth {
6134 /// Rolling observation window for the failure ratio, in milliseconds
6135 /// (default 60000).
6136 #[serde(default = "default_health_window_ms")]
6137 pub window_ms: u64,
6138 /// Eject after this many consecutive failures (default 5).
6139 #[serde(default = "default_health_consecutive_failures")]
6140 pub consecutive_failures: u32,
6141 /// Eject when the in-window failure share is >= this ratio AND volume
6142 /// is >= `failure_min_volume`. Must be in (0, 1] (default 0.5).
6143 #[serde(default = "default_health_failure_ratio")]
6144 pub failure_ratio: f64,
6145 /// Minimum observations in the window before `failure_ratio` applies
6146 /// (default 20).
6147 #[serde(default = "default_health_failure_min_volume")]
6148 pub failure_min_volume: u32,
6149 /// How long an ejected endpoint stays out of rotation, in milliseconds
6150 /// (default 30000).
6151 #[serde(default = "default_health_eject_ms")]
6152 pub eject_ms: u64,
6153 /// Trial requests allowed through per half-open recovery attempt
6154 /// (default 1). A successful probe restores health; a failed probe
6155 /// re-ejects for another `eject_ms`.
6156 #[serde(default = "default_health_half_open_probes")]
6157 pub half_open_probes: u32,
6158 /// Recovery ramp window in milliseconds (REL-06, #217). When > 0,
6159 /// an endpoint that recovers from ejection (a half-open probe
6160 /// succeeds) does NOT immediately return to full traffic. Instead
6161 /// it ramps its effective weight from a floor (1) to its
6162 /// configured weight over this window, reducing the blast radius
6163 /// of a partial recovery (the endpoint may still be slow or
6164 /// partially degraded). Default 0 = no ramp (v1 behavior: a
6165 /// successful probe restores full weight immediately).
6166 #[serde(default)]
6167 pub recovery_ramp_ms: u64,
6168 /// Whether the in-flight counter is held until the response body
6169 /// completes (REL-06, #217). Default false: the in-flight counter
6170 /// is released when response headers resolve (v1 behavior). When
6171 /// true, the counter is held until the body stream ends or is
6172 /// dropped, giving `least_requests`, `random`, and `peak_ewma` a
6173 /// more accurate view of actual endpoint load. The trade-off is a
6174 /// slightly higher in-flight count under streaming workloads.
6175 #[serde(default = "default_false", skip_serializing_if = "is_false")]
6176 pub body_completion_inflight: bool,
6177}
6178
6179impl Default for PassiveHealth {
6180 fn default() -> Self {
6181 PassiveHealth {
6182 window_ms: 60_000,
6183 consecutive_failures: 5,
6184 failure_ratio: 0.5,
6185 failure_min_volume: 20,
6186 eject_ms: 30_000,
6187 half_open_probes: 1,
6188 recovery_ramp_ms: 0,
6189 body_completion_inflight: false,
6190 }
6191 }
6192}
6193
6194fn default_health_window_ms() -> u64 {
6195 60_000
6196}
6197
6198fn default_health_consecutive_failures() -> u32 {
6199 5
6200}
6201
6202fn default_health_failure_ratio() -> f64 {
6203 0.5
6204}
6205
6206fn default_health_failure_min_volume() -> u32 {
6207 20
6208}
6209
6210fn default_health_eject_ms() -> u64 {
6211 30_000
6212}
6213
6214fn default_health_half_open_probes() -> u32 {
6215 1
6216}
6217
6218/// Active health check knobs (DW-013). A block with no keys enables HTTP
6219/// probes with the defaults.
6220///
6221/// Probe semantics (frozen):
6222/// - `http` probes issue `GET {path}` over HTTP/1.1 DIRECTLY to the
6223/// endpoint (bypassing load balancing and the pooled client); success is
6224/// a 2xx status. Redirects (3xx) are NOT followed — a health endpoint
6225/// answering 3xx is treated as a failure (a load balancer must not chase
6226/// redirects to decide health).
6227/// - `tcp` probes succeed when a TCP connection completes within
6228/// `timeout_ms`.
6229/// - Full jitter: each loop sleeps `interval_ms` plus a uniform random
6230/// `0..jitter_ms` before the next probe.
6231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6232#[serde(deny_unknown_fields)]
6233pub struct ActiveHealth {
6234 /// Probe kind (default `http`).
6235 #[serde(default = "default_probe_kind")]
6236 pub kind: ProbeKind,
6237 /// Path probed by `http` checks (default `/healthz`). Ignored by `tcp`.
6238 #[serde(
6239 default = "default_probe_path",
6240 skip_serializing_if = "is_default_probe_path"
6241 )]
6242 pub path: String,
6243 /// Time between probe attempts in milliseconds (default 5000). Must be
6244 /// >= `timeout_ms` and >= `jitter_ms`.
6245 #[serde(default = "default_probe_interval_ms")]
6246 pub interval_ms: u64,
6247 /// Per-probe timeout in milliseconds (default 2000), covering connect
6248 /// plus the response for http probes.
6249 #[serde(default = "default_probe_timeout_ms")]
6250 pub timeout_ms: u64,
6251 /// Consecutive probe SUCCESSES required to (re)admit an ejected
6252 /// endpoint (default 2).
6253 #[serde(default = "default_probe_success_threshold")]
6254 pub success_threshold: u32,
6255 /// Consecutive probe FAILURES required to eject a healthy endpoint
6256 /// (default 3). Reports the same per-endpoint streak the passive
6257 /// checker uses; see the active-health module docs for precedence.
6258 #[serde(default = "default_probe_failure_threshold")]
6259 pub failure_threshold: u32,
6260 /// Full-jitter bound in milliseconds (default 500): each loop sleeps a
6261 /// uniform random `0..jitter_ms` in addition to `interval_ms`. Must be
6262 /// <= `interval_ms`.
6263 #[serde(default = "default_probe_jitter_ms")]
6264 pub jitter_ms: u64,
6265}
6266
6267impl Default for ActiveHealth {
6268 fn default() -> Self {
6269 ActiveHealth {
6270 kind: default_probe_kind(),
6271 path: default_probe_path(),
6272 interval_ms: default_probe_interval_ms(),
6273 timeout_ms: default_probe_timeout_ms(),
6274 success_threshold: default_probe_success_threshold(),
6275 failure_threshold: default_probe_failure_threshold(),
6276 jitter_ms: default_probe_jitter_ms(),
6277 }
6278 }
6279}
6280
6281fn default_probe_kind() -> ProbeKind {
6282 ProbeKind::Http
6283}
6284
6285fn default_probe_path() -> String {
6286 "/healthz".to_string()
6287}
6288
6289fn is_default_probe_path(p: &str) -> bool {
6290 p == "/healthz"
6291}
6292
6293fn default_probe_interval_ms() -> u64 {
6294 5_000
6295}
6296
6297fn default_probe_timeout_ms() -> u64 {
6298 2_000
6299}
6300
6301fn default_probe_success_threshold() -> u32 {
6302 2
6303}
6304
6305fn default_probe_failure_threshold() -> u32 {
6306 3
6307}
6308
6309fn default_probe_jitter_ms() -> u64 {
6310 500
6311}
6312
6313/// Kind of active health probe.
6314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6315#[serde(rename_all = "lowercase")]
6316pub enum ProbeKind {
6317 /// HTTP/1.1 GET to `path`; success = 2xx.
6318 Http,
6319 /// HTTP/2 GET to `path` over a pooled H2 connection (REL-07, #218);
6320 /// for HTTP/2-only and gRPC upstreams that refuse HTTP/1.1 on a
6321 /// separate connection. Success = 2xx.
6322 Http2,
6323 /// TCP connect within the timeout.
6324 Tcp,
6325}
6326
6327fn default_load_balancer() -> LoadBalancer {
6328 LoadBalancer::RoundRobin
6329}
6330
6331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6332#[serde(rename_all = "snake_case")]
6333pub enum LoadBalancer {
6334 RoundRobin,
6335 LeastRequests,
6336 Random,
6337 IpHash,
6338 /// Maglev consistent hashing (DP-07, #236): Google's Maglev algorithm
6339 /// uses a fixed-size lookup table (default 65537 entries) populated
6340 /// by each endpoint's permutation. Provides better distribution than
6341 /// ketama for large endpoint pools with tight balance bounds. The
6342 /// hash key is determined by `hash_on` (client IP, cookie, or header).
6343 Maglev,
6344 /// Peak-EWMA latency-aware load balancing (DW-090, Finagle-style):
6345 /// favors low-latency endpoints and degrades outliers. Each endpoint
6346 /// carries an atomic EWMA cost tracker (nanosecond resolution) updated
6347 /// with the Finagle peak-EWMA formula: a new RTT that EXCEEDS the
6348 /// current cost replaces it outright (the "peak"), otherwise the cost
6349 /// decays toward the new RTT at rate `exp(-td / tau)`. Selection picks
6350 /// the endpoint with the lowest `cost_ns * (inflight + 1)` score, so a
6351 /// slow endpoint with few in-flight requests can still win a pick
6352 /// while a fast one is saturated. Tunable via `upstreams[].peak_ewma`
6353 /// (decay tau and the initial cost); defaults are 10 s decay and a
6354 /// 250 ms initial cost. Carried across rebuilds by `address:port`,
6355 /// exactly like in-flight counters and health trackers.
6356 PeakEwma,
6357}
6358
6359/// What to hash for consistent-hash load balancers (`ip_hash` and
6360/// `maglev`). DP-07 (#236): extends the hash key source beyond the
6361/// client IP to support cookie-based sticky sessions and header-based
6362/// affinity.
6363#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6364#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
6365pub enum HashOn {
6366 /// Hash the client IP (the default behavior, backward-compatible
6367 /// with pre-#236 configs).
6368 #[default]
6369 ClientIp,
6370 /// Hash a cookie value. If the cookie is not present in the request,
6371 /// the gateway falls back to the client IP. Used for sticky sessions
6372 /// without the `services[].sticky` split mechanism.
6373 Cookie {
6374 /// The cookie name to read.
6375 cookie: String,
6376 },
6377 /// Hash a request header value. If the header is not present, falls
6378 /// back to the client IP. Common for routing by `X-User-Id` or
6379 /// `X-Session-Id` for session affinity.
6380 Header {
6381 /// The header name to read (case-insensitive).
6382 header: String,
6383 },
6384}
6385
6386fn default_upstream_protocol() -> UpstreamProtocol {
6387 UpstreamProtocol::Http1
6388}
6389
6390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6391#[serde(rename_all = "snake_case")]
6392pub enum UpstreamProtocol {
6393 Http1,
6394 Http2,
6395 /// Cleartext HTTP/2 with prior knowledge (h2c, DP-02 #235). No TLS;
6396 /// the client speaks HTTP/2 directly over TCP. Common for internal
6397 /// east-west gRPC traffic without TLS.
6398 H2c,
6399 Https,
6400 /// HTTP/3 over QUIC (DW-108). QUIC mandates TLS 1.3, so an `h3`
6401 /// upstream always negotiates TLS with ALPN `h3`; the
6402 /// `trusted_ca_file` field selects the trust roots (the default
6403 /// webpki public set, or a private-CA bundle). Requires the `h3`
6404 /// cargo feature on dwara-bin to actually proxy: when the feature is
6405 /// off the protocol is accepted at validation but the upstream is
6406 /// inert (every dispatch fails closed with a clear error rather than
6407 /// silently falling back to a wrong transport).
6408 H3,
6409}
6410
6411/// One `address:port` inside an upstream.
6412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6413#[serde(deny_unknown_fields)]
6414pub struct Endpoint {
6415 pub address: String,
6416 pub port: u16,
6417 /// Relative traffic weight (default 1 for all endpoints).
6418 #[serde(
6419 default = "default_endpoint_weight",
6420 skip_serializing_if = "is_default_weight"
6421 )]
6422 pub weight: u32,
6423 /// DW-094 (Ent): the region this endpoint belongs to (e.g.
6424 /// `us-east-1`, `eu-west-1`). Used by locality-aware load balancing
6425 /// and data residency enforcement. Optional — endpoints without a
6426 /// region are treated as locality-agnostic (always eligible).
6427 #[serde(default, skip_serializing_if = "Option::is_none")]
6428 pub region: Option<String>,
6429 /// DW-094 (Ent): the zone within a region (e.g. `us-east-1a`).
6430 /// Used for zone-aware routing within a region. Optional.
6431 #[serde(default, skip_serializing_if = "Option::is_none")]
6432 pub zone: Option<String>,
6433}
6434
6435fn default_endpoint_weight() -> u32 {
6436 1
6437}
6438
6439fn is_default_weight(w: &u32) -> bool {
6440 *w == 1
6441}
6442
6443/// Timeout hints, in milliseconds.
6444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6445#[serde(deny_unknown_fields)]
6446pub struct Timeouts {
6447 /// Connect timeout in milliseconds.
6448 #[serde(skip_serializing_if = "Option::is_none")]
6449 pub connect_ms: Option<u64>,
6450 /// Read timeout in milliseconds.
6451 #[serde(skip_serializing_if = "Option::is_none")]
6452 pub read_ms: Option<u64>,
6453 /// Write timeout in milliseconds.
6454 #[serde(skip_serializing_if = "Option::is_none")]
6455 pub write_ms: Option<u64>,
6456 /// Happy-eyeballs inter-connection delay in milliseconds (DW-030,
6457 /// RFC 8305): when an endpoint's address resolves to multiple
6458 /// addresses, the first is dialed immediately and each subsequent
6459 /// address — alternating address families after the resolver's
6460 /// first — is diailed this long after the previous START; the first
6461 /// successful connection wins and cancels the losers. Absent: the
6462 /// RFC 8305 recommended 250 ms. `0` disables racing (addresses are
6463 /// tried strictly in resolver order, one at a time). The upstream's
6464 /// `connect_ms` remains the bound over the WHOLE dial: resolution
6465 /// plus every interleaved attempt plus, for TLS upstreams, the
6466 /// handshake. Only the overall dial's single outcome reaches
6467 /// breaker/passive-health accounting — the losing arms of one dial
6468 /// are never counted as endpoint failures. Validation bounds the
6469 /// value to at most 10 minutes.
6470 #[serde(skip_serializing_if = "Option::is_none")]
6471 pub happy_eyeballs_ms: Option<u64>,
6472}
6473
6474/// The principal kind of a consumer (DW-113): `user` (the default —
6475/// a human or application calling the gateway) or `agent` (a bot
6476/// principal with bot credentials, per-agent quotas/token budgets,
6477/// tool allowlists, and a session audit trail). Agent traffic is
6478/// identifiable, governable, and billable; the type threads through
6479/// Identity into analytics for typed attribution.
6480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
6481#[serde(rename_all = "snake_case")]
6482pub enum ConsumerType {
6483 /// A human or application caller (the default).
6484 #[default]
6485 User,
6486 /// A bot principal: tool allowlists, per-agent token budgets,
6487 /// and typed analytics attribution apply.
6488 Agent,
6489}
6490
6491/// Identity of an API caller (app/team/user); owns credentials, quotas, and
6492/// the analytics identity.
6493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6494#[serde(deny_unknown_fields)]
6495pub struct Consumer {
6496 pub name: String,
6497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
6498 pub credentials: Vec<Credential>,
6499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
6500 pub policies: Vec<String>,
6501 /// The principal kind (DW-113): `user` (default) or `agent`.
6502 /// Agent consumers gain a tool allowlist, a per-agent token
6503 /// budget, and typed analytics attribution. The field is renamed
6504 /// to `type` in YAML (`type` is a Rust keyword).
6505 #[serde(default, rename = "type")]
6506 pub consumer_type: ConsumerType,
6507 /// MCP tool allowlist (DW-113): when non-empty, the consumer may
6508 /// only call the named tools through the MCP gateway. Empty (the
6509 /// default) = no restriction. Validation checks that every name
6510 /// references a configured `ai.mcp.tools` entry.
6511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
6512 pub tool_allowlist: Vec<String>,
6513 /// Per-agent AI token budget (DW-113): when set, this consumer's
6514 /// AI traffic is bounded by this budget BEFORE the policy chain
6515 /// (the most-specific budget). Same shape as a policy's
6516 /// `token_budget`. `None` (the default) = no per-agent budget;
6517 /// the policy chain governs as before.
6518 #[serde(default, skip_serializing_if = "Option::is_none")]
6519 pub token_budget: Option<TokenBudget>,
6520 /// Consumer priority class for load shedding (DW-016): 0 (lowest) to 10
6521 /// (highest). Stored and validated now; it takes effect only once
6522 /// authentication (DW-019/DW-020) identifies the consumer on a request —
6523 /// until then, shedding priority comes from the matched route (or the
6524 /// default 5). Consumer priority overrides the route's when known, but
6525 /// it does NOT trigger reserved-bucket carving today — only a
6526 /// high-priority route does.
6527 #[serde(default, skip_serializing_if = "Option::is_none")]
6528 pub priority: Option<u8>,
6529 /// Consumer group memberships (DW-020): group names this consumer
6530 /// belongs to, consulted by authorization `allowed_groups` /
6531 /// `denied_groups` rules. Empty (the default) = no groups. Group
6532 /// names are free-form strings; validation checks that authorization
6533 /// rules referencing groups resolve against at least one consumer's
6534 /// membership. Store-managed consumers carry their own groups in the
6535 /// state store (#124) — the group NAMESPACE is shared, but
6536 /// config-time validation can only see config consumers, so a rule
6537 /// referencing a group that exists solely on store consumers is
6538 /// flagged (grant at least one config consumer the same group name,
6539 /// or accept the issue is a false positive for store-only groups).
6540 #[serde(default, skip_serializing_if = "Vec::is_empty")]
6541 pub groups: Vec<String>,
6542 /// Consumer-level authorization rules (#123): the MOST specific link
6543 /// of the frozen chain consumer > route > service > listener >
6544 /// global — applies to every request authenticated as this consumer
6545 /// (naturally only once authentication has identified it). A deny at
6546 /// any level wins; otherwise the most specific level with rules
6547 /// governs (see [`Authz`] and the `authz` module). Same validation
6548 /// rules as route-level authorization.
6549 #[serde(default, skip_serializing_if = "Option::is_none")]
6550 pub authorization: Option<Authz>,
6551 /// Request budgets for this consumer (DW-033): a daily and/or
6552 /// monthly request cap counted in fixed UTC calendar windows
6553 /// (midnight-to-midnight UTC; the first through the last instant of
6554 /// the UTC month). Budgets are DISTINCT from rate limits: a rate
6555 /// limit shapes traffic inside seconds or minutes, a budget bounds
6556 /// total volume across a day or month, and the two mechanisms run
6557 /// independently (both apply when both are configured). Enforcement
6558 /// needs the state store (`DWARA_STATE_DB`) for durable counters —
6559 /// without one, quota config is inert (logged at request time, see
6560 /// the `state::quotas` module docs). Store-managed consumers have no
6561 /// config record, so budgets are config-consumer-only in this
6562 /// edition; a distributed shared-counter variant is the Ent
6563 /// follow-up (issue "DW-155").
6564 #[serde(default, skip_serializing_if = "Option::is_none")]
6565 pub quotas: Option<ConsumerQuotas>,
6566 /// AI prompt/response logging override (DW-081). None = inherit
6567 /// the global `ai.logging.enabled` setting; Some(false) = disable
6568 /// capture for this consumer even if global is on; Some(true) =
6569 /// enable capture for this consumer even if global is off
6570 /// (requires the `ai.logging` block to exist — the redactor and
6571 /// retention are always needed). The per-consumer toggle respects
6572 /// tenant preference: a consumer with capture disabled produces
6573 /// no stored prompts/responses.
6574 #[serde(default, skip_serializing_if = "Option::is_none")]
6575 pub ai_logging: Option<bool>,
6576 /// AI-16 (#204): agent principal metadata. Only meaningful when
6577 /// `consumer_type` is `agent`; ignored otherwise. Carries the
6578 /// agent's display name, description, owner, and permission level
6579 /// for first-class agent principal attribution.
6580 #[serde(default, skip_serializing_if = "Option::is_none")]
6581 pub agent: Option<AgentPrincipal>,
6582}
6583
6584/// AI-16 (#204): First-class agent principal metadata. Attached to a
6585/// `Consumer` with `consumer_type: agent` to carry agent-specific
6586/// attribution and permission data.
6587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6588#[serde(deny_unknown_fields)]
6589pub struct AgentPrincipal {
6590 /// The agent's display name (human-readable). Defaults to the
6591 /// consumer name when not set.
6592 #[serde(default, skip_serializing_if = "Option::is_none")]
6593 pub display_name: Option<String>,
6594 /// A human-readable description of the agent's purpose.
6595 #[serde(default, skip_serializing_if = "Option::is_none")]
6596 pub description: Option<String>,
6597 /// The owner of this agent (a user or team name). Used for
6598 /// attribution and audit.
6599 #[serde(default, skip_serializing_if = "Option::is_none")]
6600 pub owner: Option<String>,
6601 /// The agent's permission level. One of: `read_only`,
6602 /// `read_write`, `admin`. Default `read_only`.
6603 #[serde(default, skip_serializing_if = "Option::is_none")]
6604 pub permissions: Option<AgentPermissionLevel>,
6605}
6606
6607/// AI-16 (#204): Agent permission levels for first-class agent
6608/// principals. Mirrors the `Permission` enum in the MCP module but
6609/// lives in config for declarative assignment.
6610#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
6611#[serde(rename_all = "snake_case")]
6612pub enum AgentPermissionLevel {
6613 /// Read-only access (list, get, stats, health, config).
6614 #[default]
6615 ReadOnly,
6616 /// Read-write access (read + create, update, delete).
6617 ReadWrite,
6618 /// Full admin access (all tools including purge).
6619 Admin,
6620}
6621
6622/// Per-consumer request budgets (DW-033): daily and/or monthly request
6623/// caps in fixed UTC calendar windows, counted by the state store's
6624/// quota counters. At least one budget must be set and every set budget
6625/// must be > 0 (validation); a budget of 0 would deny the consumer's
6626/// first request and is expressed by omitting the field instead.
6627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6628#[serde(deny_unknown_fields)]
6629pub struct ConsumerQuotas {
6630 /// Maximum requests per UTC calendar day (midnight to midnight).
6631 #[serde(skip_serializing_if = "Option::is_none")]
6632 pub daily_requests: Option<u64>,
6633 /// Maximum requests per UTC calendar month.
6634 #[serde(skip_serializing_if = "Option::is_none")]
6635 pub monthly_requests: Option<u64>,
6636 /// SEC-14 / CFG-14: dry-run mode. When true, quota counters are
6637 /// evaluated and would-be rejections are recorded
6638 /// (`dwara_policy_dry_run_total{phase="quota"}` + a structured
6639 /// log) but the request is NOT rejected — it proceeds even when
6640 /// the quota is exceeded. Useful for calibrating quota limits
6641 /// before enforcing. Default false (reject on quota exceeded).
6642 #[serde(default, skip_serializing_if = "is_false")]
6643 pub dry_run: bool,
6644}
6645
6646/// One authenticator bound to a consumer: API key, JWT issuer/audience
6647/// binding, mTLS client-certificate match, or HMAC signing key (DW-036).
6648///
6649/// `Debug` is manual (DW-045): the api-key and hmac-secret values are
6650/// SECRET and a derived impl would print them — this keeps the config
6651/// tree safe to Debug-log as a whole (`Gateway` and every holder derive
6652/// `Debug` over this type).
6653#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6654#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
6655pub enum Credential {
6656 ApiKey {
6657 /// The API key; hashed at rest by the state layer, never logged.
6658 /// Either the value INLINE (accepted, but redacted to a
6659 /// `${redacted:...}` placeholder in every config echo — admin
6660 /// `GET /config`, DW-045) or a reference resolved at
6661 /// config-compile time: `${ENV_NAME}` (an environment variable)
6662 /// or `${file:/path/to/secret}` (one trailing newline trimmed).
6663 /// References are re-read on every reload; an unresolvable
6664 /// reference fails validation naming this field.
6665 key: String,
6666 },
6667 /// HMAC request-signing credential (DW-036): the consumer presents
6668 /// per-request signatures over the canonical string documented in
6669 /// the `security::authn` module docs, carried in the
6670 /// `X-Dwara-Signature` header family. Unlike API keys the secret
6671 /// cannot be hash-at-rest stored — recomputing an HMAC needs the
6672 /// RAW key bytes — so this credential is config-declared only (the
6673 /// state store's hashed rows cannot serve it) and the resolved
6674 /// bytes live solely in the authenticator's memory, zeroized on
6675 /// drop. The pepper (#124) deliberately does NOT apply here: it
6676 /// guards stored hashes, and there is no stored hash to guard.
6677 Hmac {
6678 /// Public key identifier: the `X-Dwara-Key-Id` header value the
6679 /// client presents to SELECT this credential (the AWS
6680 /// access-key-id shape — it names the key, it is not secret).
6681 /// Non-empty visible ASCII, at most 128 bytes; unique across
6682 /// every consumer (validation rejects duplicates — an ambiguous
6683 /// selector cannot pick a consumer deterministically).
6684 key_id: String,
6685 /// The HMAC-SHA256 signing secret. INLINE values are accepted
6686 /// (redacted in every config echo, like api keys) but
6687 /// `${ENV_NAME}` / `${file:/path/to/secret}` references are the
6688 /// recommended shape; resolved at config-compile time and
6689 /// re-read on every reload. Must be non-empty; use at least 32
6690 /// bytes of entropy (shorter keys weaken the MAC construction
6691 /// — documented, not enforced, the same posture as the pepper).
6692 secret: String,
6693 },
6694 Jwt {
6695 issuer: String,
6696 #[serde(default, skip_serializing_if = "Vec::is_empty")]
6697 audiences: Vec<String>,
6698 },
6699 Mtls {
6700 /// SHA-256 fingerprint (lowercase hex of the DER) of the client
6701 /// certificate. Optional when `subject` is set (#124); a
6702 /// fingerprint match binds the credential to ONE exact
6703 /// certificate (a re-issued cert needs a new credential).
6704 #[serde(default, skip_serializing_if = "Option::is_none")]
6705 fingerprint: Option<String>,
6706 /// Subject CommonName of the client certificate (#124): the
6707 /// credential matches any verified client certificate whose
6708 /// subject CN is exactly this string (case-sensitive). Binding
6709 /// by subject survives certificate re-issue under the same CN;
6710 /// the certificate must still chain to a listener's
6711 /// `client_ca_file` (or be otherwise verified) at the TLS layer
6712 /// — the matcher only maps an ALREADY-VERIFIED certificate to a
6713 /// consumer. Exactly one of `subject` / `fingerprint` must be
6714 /// set (validation rejects both-empty and both-set).
6715 #[serde(default, skip_serializing_if = "Option::is_none")]
6716 subject: Option<String>,
6717 },
6718}
6719
6720impl std::fmt::Debug for Credential {
6721 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6722 // DW-045: the api-key and hmac-secret values are the inline
6723 // secret material in the schema; everything else is binding
6724 // metadata (issuer, fingerprint, subject, key id) that
6725 // validation matches, not a secret. Redact exactly the secrets
6726 // and keep the variant shape so debug output stays useful.
6727 match self {
6728 Credential::ApiKey { .. } => f
6729 .debug_struct("ApiKey")
6730 .field("key", &"[redacted]")
6731 .finish(),
6732 Credential::Hmac { key_id, .. } => f
6733 .debug_struct("Hmac")
6734 .field("key_id", key_id)
6735 .field("secret", &"[redacted]")
6736 .finish(),
6737 Credential::Jwt { issuer, audiences } => f
6738 .debug_struct("Jwt")
6739 .field("issuer", issuer)
6740 .field("audiences", audiences)
6741 .finish(),
6742 Credential::Mtls {
6743 subject,
6744 fingerprint,
6745 } => f
6746 .debug_struct("Mtls")
6747 .field("subject", subject)
6748 .field("fingerprint", fingerprint)
6749 .finish(),
6750 }
6751 }
6752}
6753
6754/// Named reusable rule bundle (rate limit, timeouts, ...); attachable at
6755/// several scopes. Plugin-backed phases arrive with the M3 plugin system.
6756#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6757#[serde(deny_unknown_fields)]
6758pub struct Policy {
6759 pub name: String,
6760 #[serde(skip_serializing_if = "Option::is_none")]
6761 pub rate_limit: Option<RateLimit>,
6762 /// Stacked GCRA rate-limit rules (DW-017). Each rule stacks one or
6763 /// more windows (e.g. `s` AND `hour`); a request is admitted only if
6764 /// EVERY window of EVERY applicable rule allows it. The legacy
6765 /// single-window `rate_limit` field above still applies when set (see
6766 /// its mapping in the rate-limiter module docs); use `rate_limits`
6767 /// for new configs.
6768 #[serde(default, skip_serializing_if = "Vec::is_empty")]
6769 pub rate_limits: Vec<RateLimitRule>,
6770 #[serde(skip_serializing_if = "Option::is_none")]
6771 pub timeouts: Option<Timeouts>,
6772 /// Monitor mode (DW-041) for this bundle's rate-limit rules: they
6773 /// still EVALUATE (their GCRA buckets advance exactly as if
6774 /// enforcing, so denial rates reflect what enforcement would do),
6775 /// but a request they would deny is LOGGED and counted
6776 /// (`dwara_policy_dry_run_total{phase="rate_limit"}` + a
6777 /// `dwara::policy` warn event) and PROCEEDS — no 429, and none of
6778 /// the rule's `X-RateLimit-*` headers on the response. LIVE policies
6779 /// attached to the same request still enforce: the flag mutes only
6780 /// this bundle's own denials. The flag sits on the named bundle (not
6781 /// on each `policies: [...]` attachment list entry) because rate
6782 /// limits attach BY NAME at the five precedence levels — marking the
6783 /// bundle dries every attachment of it uniformly. Timeouts carry no
6784 /// rejection and are unaffected.
6785 #[serde(default, skip_serializing_if = "is_false")]
6786 pub dry_run: bool,
6787 /// AI token budget (DW-078): caps provider-reported tokens per
6788 /// minute and/or priced cost per UTC day for the budget holder.
6789 /// Distinct from every rule above: those count REQUESTS, this
6790 /// counts TOKENS. Resolution follows the frozen precedence chain
6791 /// (consumer > route > service > listener > global) with the MOST
6792 /// SPECIFIC level's budget governing (a limit-of-totals, not an
6793 /// AND-composed rule). `scope: consumer` (default) keys the budget
6794 /// to each attaching consumer; `scope: policy` makes one SHARED
6795 /// (team) budget keyed by the policy name. Consumers with no
6796 /// binding budget are unlimited.
6797 #[serde(default, skip_serializing_if = "Option::is_none")]
6798 pub token_budget: Option<TokenBudget>,
6799 /// Anomaly scoring (DW-090): lightweight statistical detection of
6800 /// abusive request patterns (header entropy, request-shape outliers).
6801 /// When enabled, every matched request is scored against the
6802 /// configured signals; a score at or above `threshold` is blocked
6803 /// (403 `anomaly_blocked`) unless `dry_run` is set (scored and
6804 /// logged, request proceeds). Resolution follows the frozen
6805 /// precedence chain (consumer > route > service > listener > global);
6806 /// every level with an attachment applies and the applicable rules
6807 /// AND together. Absent (the default): no anomaly scoring.
6808 #[serde(default, skip_serializing_if = "Option::is_none")]
6809 pub anomaly: Option<AnomalyPolicy>,
6810 /// Adaptive + origin-driven rate-limit tuning (DW-089): an EWMA of
6811 /// upstream error rate and latency dynamically scales this policy's
6812 /// rate-limit quotas, and upstream-originated signals (`Retry-After`)
6813 /// trigger an immediate backoff for the advertised window. The
6814 /// adaptive factor is bounded `[min_factor, max_factor]` (never 0,
6815 /// never unbounded); tightening under stress is faster than relaxing
6816 /// when healthy (asymmetric). Absent (the default): no adaptive
6817 /// tuning, the policy's quotas apply as configured.
6818 #[serde(default, skip_serializing_if = "Option::is_none")]
6819 pub adaptive: Option<AdaptiveRateLimit>,
6820}
6821
6822/// An AI token budget (DW-078, `policies[].token_budget`): limits of
6823/// TOTALS over windows — provider-reported tokens per minute and/or
6824/// cost per UTC calendar day, enforced per consumer or shared per
6825/// policy (team). See the `ai::budget` module docs for the
6826/// check-then-spend contract.
6827#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6828#[serde(deny_unknown_fields)]
6829pub struct TokenBudget {
6830 /// Maximum provider-reported tokens per fixed 60-second window
6831 /// (epoch-minute aligned). Spent tokens come from the provider's
6832 /// own usage reports — the gateway never estimates.
6833 #[serde(default, skip_serializing_if = "Option::is_none")]
6834 pub tokens_per_min: Option<u64>,
6835 /// Maximum spend per UTC calendar day, in integer micro-USD
6836 /// (1_000_000 = $1.00 — no floating-point money). Enforced end to
6837 /// end; the pricing input arrives with the DW-079 pricing tables,
6838 /// so the cost window is enforced-but-inert until then.
6839 #[serde(default, skip_serializing_if = "Option::is_none")]
6840 pub cost_per_day_micros: Option<u64>,
6841 /// Whose budget this is: `consumer` (default — each attaching
6842 /// consumer gets its own window) or `policy` (one SHARED team
6843 /// budget keyed by the policy name).
6844 #[serde(
6845 default = "default_token_budget_scope",
6846 skip_serializing_if = "is_default_token_budget_scope"
6847 )]
6848 pub scope: TokenBudgetScope,
6849}
6850
6851/// The budget's key scope (DW-078).
6852#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6853#[serde(rename_all = "lowercase")]
6854pub enum TokenBudgetScope {
6855 /// One budget per attaching consumer.
6856 Consumer,
6857 /// One shared (team) budget per policy.
6858 Policy,
6859}
6860
6861fn default_token_budget_scope() -> TokenBudgetScope {
6862 TokenBudgetScope::Consumer
6863}
6864
6865fn is_default_token_budget_scope(s: &TokenBudgetScope) -> bool {
6866 *s == TokenBudgetScope::Consumer
6867}
6868
6869/// One rate-limit rule (DW-017): a key selector plus one or more stacked
6870/// windows. `selector` names the request attributes that form the
6871/// counter key (all listed attributes are joined into ONE key, so
6872/// `[ip, route]` limits each (client IP, route) pair independently);
6873/// `requests_per` carries the sustained rates per window (at least one
6874/// window must be present); `burst` is the bucket size (defaults to the
6875/// window's request count).
6876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6877#[serde(deny_unknown_fields)]
6878pub struct RateLimitRule {
6879 /// Optional label (documentation only; not part of the key).
6880 #[serde(skip_serializing_if = "Option::is_none")]
6881 pub name: Option<String>,
6882 /// Key components: `ip`, `credential`, and/or `route` (at least one;
6883 /// order does not matter). `credential` falls back to the client IP
6884 /// until authentication (DW-019) identifies consumers.
6885 pub selector: Vec<RateLimitSelector>,
6886 pub requests_per: RateRequestsPer,
6887 /// Bucket size (burst capacity); must be >= 1 when present. Defaults
6888 /// to the window's request count.
6889 #[serde(skip_serializing_if = "Option::is_none")]
6890 pub burst: Option<u32>,
6891}
6892
6893/// One attribute of a rate-limit key (DW-017).
6894#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6895#[serde(rename_all = "lowercase")]
6896pub enum RateLimitSelector {
6897 /// The direct connection peer (the same IP used for X-Real-IP).
6898 Ip,
6899 /// The authenticated consumer; until DW-019 this falls back to `ip`.
6900 Credential,
6901 /// The matched route's name.
6902 Route,
6903}
6904
6905/// Sustained rates per window (DW-017). At least one field must be set
6906/// and every set field must be > 0; each set field becomes one stacked
6907/// GCRA cell (a request must satisfy ALL set windows).
6908#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
6909#[serde(deny_unknown_fields)]
6910pub struct RateRequestsPer {
6911 /// Requests per second.
6912 #[serde(rename = "s", skip_serializing_if = "Option::is_none")]
6913 pub per_second: Option<u32>,
6914 /// Requests per minute.
6915 #[serde(skip_serializing_if = "Option::is_none")]
6916 pub minute: Option<u32>,
6917 /// Requests per hour.
6918 #[serde(skip_serializing_if = "Option::is_none")]
6919 pub hour: Option<u32>,
6920}
6921
6922/// Local GCRA-style rate limit.
6923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6924#[serde(deny_unknown_fields)]
6925pub struct RateLimit {
6926 /// Maximum requests allowed per window.
6927 pub requests: u64,
6928 /// Window length in seconds.
6929 pub window_seconds: u64,
6930}
6931
6932/// Parse a YAML configuration document into a [`Gateway`], rejecting invalid
6933/// input with path-precise error messages.
6934///
6935/// Error-path guarantee: serde-level failures (type mismatches, unknown
6936/// fields, missing fields) carry a precise dotted path from
6937/// `serde_path_to_error`, e.g. `routes[0].action`. Raw YAML syntax errors
6938/// are detected by the scanner before path tracking applies, so their path
6939/// is coarse (often the root); the precise location is still available as
6940/// line/column inside the message text itself.
6941pub fn parse_gateway(text: &str) -> Result<Gateway, ConfigError> {
6942 let de = serde_path_to_error::deserialize(serde_yaml_ng::Deserializer::from_str(text))
6943 .map_err(|e| ConfigError {
6944 path: e.path().to_string(),
6945 message: e.inner().to_string(),
6946 })?;
6947 Ok(de)
6948}
6949
6950/// Serialize a [`Gateway`] to normalized YAML text.
6951///
6952/// Field order follows struct declaration order; defaulted-empty collections
6953/// are omitted, so output is stable for a given typed value.
6954pub fn gateway_to_yaml(gateway: &Gateway) -> Result<String, serde_yaml_ng::Error> {
6955 serde_yaml_ng::to_string(gateway)
6956}
6957
6958/// Build the JSON Schema for the root [`Gateway`] type.
6959pub fn json_schema() -> schemars::Schema {
6960 schemars::schema_for!(Gateway)
6961}
6962
6963// --- DW-055: proxy-wasm plugin config -------------------------------------
6964
6965/// A plugin definition (DW-055 / DW-119). Each plugin is either a
6966/// proxy-wasm `.wasm` module loaded at startup (`wasm`) or a compiled-in
6967/// native Rust filter (`native`), run on the request pipeline phases it
6968/// declares. Routes reference plugins by name. Exactly one of `wasm` or
6969/// `native` must be set (validation enforces this).
6970#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
6971#[serde(deny_unknown_fields)]
6972pub struct PluginConfig {
6973 /// Unique plugin name. Referenced by routes' `plugins` field.
6974 pub name: String,
6975 /// Path to the .wasm module file. Must be readable at startup.
6976 /// Mutually exclusive with `native`; exactly one of the two must be
6977 /// set. Absent when this is a native filter.
6978 #[serde(default, skip_serializing_if = "Option::is_none")]
6979 pub wasm: Option<String>,
6980 /// Registered native filter implementation name (DW-119). The
6981 /// `plugins` cargo feature must be enabled and a factory registered
6982 /// under this name at startup. Mutually exclusive with `wasm`;
6983 /// exactly one of the two must be set. Absent when this is a WASM
6984 /// plugin.
6985 #[serde(default, skip_serializing_if = "Option::is_none")]
6986 pub native: Option<String>,
6987 /// SCALE-12 (#192)/DW-165: remote source for this plugin. When
6988 /// set, the gateway resolves the artifact at every publish
6989 /// (startup and reload): cache-first, downloading on a miss, then
6990 /// verifying the mandatory SHA-256 digest and any configured
6991 /// Ed25519 signature BEFORE the wasm load step. A source that
6992 /// cannot be resolved or fails verification never loads (the
6993 /// plugin reads Crashed; its routes fail closed). Mutually
6994 /// exclusive with `native` and `wasm`. See
6995 /// [`PluginSourceConfig`] and the plugin registry guide for the
6996 /// cache layout and the registry spec.
6997 #[serde(default, skip_serializing_if = "Option::is_none")]
6998 pub source: Option<PluginSourceConfig>,
6999 /// Phases this plugin hooks. Must be a non-empty subset of:
7000 /// `request_headers`, `request_body`, `response_headers`,
7001 /// `response_body`. The host calls the plugin's corresponding
7002 /// `proxy_on_*` export at each declared phase (WASM) or the
7003 /// `NativeFilter` trait method (native).
7004 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7005 pub phases: Vec<PluginPhase>,
7006 /// Plugin-specific configuration, passed to the plugin's
7007 /// `proxy_on_configure` as a byte string (WASM) or to the native
7008 /// filter's factory (native). Typically a JSON or YAML blob the
7009 /// plugin parses itself.
7010 #[serde(default, skip_serializing_if = "Option::is_none")]
7011 pub config: Option<String>,
7012 /// Resource limits for this plugin (fuel, memory, time). Absent
7013 /// uses the defaults: 1M fuel, 32MB memory, 100ms timeout. Only
7014 /// meaningful for WASM plugins (native filters run in-process and
7015 /// are bounded by the gateway's own resource limits).
7016 #[serde(default, skip_serializing_if = "Option::is_none")]
7017 pub limits: Option<PluginLimitsConfig>,
7018}
7019
7020/// SCALE-12 (#192)/DW-165: remote/signed plugin source configuration.
7021/// The gateway resolves the .wasm artifact at every publish: cache
7022/// lookup first, then a download from `url` on a miss, then SHA-256
7023/// `digest` verification (mandatory) and Ed25519 `signature`
7024/// verification (when configured) — only then is the artifact loaded.
7025/// Resolution failures are fail-closed per plugin (Crashed at publish,
7026/// its routes answer 500), never a partial load.
7027#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7028#[serde(deny_unknown_fields)]
7029pub struct PluginSourceConfig {
7030 /// The registry URL to fetch the .wasm artifact from. Must be
7031 /// `https://`; fetched with one HTTP/1.1 GET (redirects are not
7032 /// followed — configure the artifact's final URL). `oci://`
7033 /// references are not supported in this version: publish the
7034 /// artifact over https or pre-fetch it with `dwara-cli plugin
7035 /// install` and reference the local file with `wasm`.
7036 pub url: String,
7037 /// Expected SHA-256 digest of the artifact, hex-encoded (64
7038 /// chars, case-insensitive). Verified against the cached AND the
7039 /// downloaded bytes before loading; a mismatch fails the plugin
7040 /// (never a partial load). Required — there are no unsigned
7041 /// remote artifacts (digest-pinning is the floor; signatures are
7042 /// the ceiling).
7043 pub digest: String,
7044 /// Optional Ed25519 signature over the artifact bytes, hex-encoded
7045 /// (64 bytes / 128 chars). When set, it must verify against
7046 /// `public_key` and, when `plugin_registry.public_keys` pins
7047 /// keys, against a pinned key too. Absent means digest-only
7048 /// verification — rejected when the registry pins public keys.
7049 #[serde(default, skip_serializing_if = "Option::is_none")]
7050 pub signature: Option<String>,
7051 /// Ed25519 public key (32 bytes, hex-encoded) for signature
7052 /// verification. Required when `signature` is set unless
7053 /// `plugin_registry.public_keys` pins keys. A `public_key` set
7054 /// WITHOUT a `signature` is rejected (a key that verifies nothing
7055 /// is a config mistake — sign the artifact or remove the key).
7056 #[serde(default, skip_serializing_if = "Option::is_none")]
7057 pub public_key: Option<String>,
7058 /// Local cache path for the resolved artifact. When absent, the
7059 /// artifact is cached content-addressed as
7060 /// `<cache_dir>/<digest>.wasm` (`plugin_registry.cache_dir`,
7061 /// default `./plugin-cache`). When set, the path must be a
7062 /// RELATIVE path under the cache dir with no `..` parent
7063 /// segments (containment: the resolver creates directories and
7064 /// writes artifact bytes at this path, so it must not be able to
7065 /// point outside the cache). The cached file is re-verified
7066 /// (digest and signature) on every publish; a cached file that
7067 /// does not match the pinned digest fails closed instead of being
7068 /// silently re-fetched.
7069 #[serde(default, skip_serializing_if = "Option::is_none")]
7070 pub cache_path: Option<String>,
7071}
7072
7073impl PluginSourceConfig {
7074 /// Why a `cache_path` value violates cache containment, or `None`
7075 /// when it is acceptable. The rule is deliberately simple: a
7076 /// non-empty RELATIVE path with no `..` parent segments — the
7077 /// resolver creates directories and writes artifact bytes at this
7078 /// path, so it must stay inside `plugin_registry.cache_dir`.
7079 /// Shared by snapshot validation (the operator-facing, earliest
7080 /// rejection) and the resolver (defense in depth); lives here
7081 /// because both may import `config` and nothing below it.
7082 pub fn cache_path_violation(cache_path: &str) -> Option<&'static str> {
7083 if cache_path.trim().is_empty() {
7084 Some("the path is empty")
7085 } else if std::path::Path::new(cache_path).is_absolute() {
7086 Some("the path is absolute")
7087 } else if std::path::Path::new(cache_path)
7088 .components()
7089 .any(|c| matches!(c, std::path::Component::ParentDir))
7090 {
7091 Some("the path contains '..' parent segments")
7092 } else {
7093 None
7094 }
7095 }
7096}
7097
7098/// SCALE-12 (#192)/DW-165: plugin registry configuration. When
7099/// present, signatures of `source:`-referenced artifacts are verified
7100/// against the registry's pinned public keys and resolved artifacts
7101/// are cached in the registry's cache directory. This enables
7102/// fleet-consistent plugin pinning: all gateways in a fleet reference
7103/// the same registry and get the same signed artifacts.
7104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7105#[serde(deny_unknown_fields)]
7106pub struct PluginRegistryConfig {
7107 /// Registry base URL (e.g., `https://registry.example.com/plugins`).
7108 /// Informational for fleet tooling (`dwara-cli plugin
7109 /// search/install` default); each plugin's `source.url` is a full
7110 /// URL.
7111 #[serde(default, skip_serializing_if = "Option::is_none")]
7112 pub base_url: Option<String>,
7113 /// Pinned Ed25519 public keys (hex-encoded, 32 bytes each). When
7114 /// set, plugin signatures are verified against these keys in
7115 /// addition to any per-plugin `public_key`, and a plugin with no
7116 /// signature is rejected (unsigned artifacts cannot ride a
7117 /// pinned-key registry).
7118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7119 pub public_keys: Vec<String>,
7120 /// Local cache directory for resolved artifacts, content-addressed
7121 /// as `<cache_dir>/<digest>.wasm`. Default: `./plugin-cache`
7122 /// (relative to the gateway working directory, the same posture
7123 /// as the ACME state directory).
7124 #[serde(default, skip_serializing_if = "Option::is_none")]
7125 pub cache_dir: Option<String>,
7126}
7127
7128/// The phases a plugin can hook (DW-055, §9.3 phase contract).
7129#[derive(
7130 Clone,
7131 Copy,
7132 Debug,
7133 PartialEq,
7134 Eq,
7135 std::hash::Hash,
7136 serde::Serialize,
7137 serde::Deserialize,
7138 schemars::JsonSchema,
7139)]
7140#[serde(rename_all = "snake_case")]
7141pub enum PluginPhase {
7142 /// After route resolution, before authn.
7143 RequestHeaders,
7144 /// After authn/authz/rate-limit, before upstream.
7145 RequestBody,
7146 /// After the upstream responds, before masking.
7147 ResponseHeaders,
7148 /// After masking, before compression.
7149 ResponseBody,
7150}
7151
7152impl PluginPhase {
7153 /// The phase's stable wire label (the serde name), for the plugin
7154 /// status surface (`GET /plugins`, DW-158).
7155 pub fn as_str(self) -> &'static str {
7156 match self {
7157 PluginPhase::RequestHeaders => "request_headers",
7158 PluginPhase::RequestBody => "request_body",
7159 PluginPhase::ResponseHeaders => "response_headers",
7160 PluginPhase::ResponseBody => "response_body",
7161 }
7162 }
7163}
7164
7165/// SCALE-12 (#191): formalized request-pipeline filter phases. The
7166/// default order is the order the variants are declared (the same
7167/// order the dataplane has always executed them). Per-route
7168/// `filter_chain` overrides can reorder a subset of these phases;
7169/// phases not listed in an override keep their default relative
7170/// order.
7171#[derive(
7172 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
7173)]
7174#[serde(rename_all = "snake_case")]
7175pub enum FilterPhase {
7176 /// IP ACL and consumer ACL checks.
7177 Acl,
7178 /// Rate limiting (per-consumer and per-route limiters).
7179 RateLimit,
7180 /// Authentication (API key, JWT, OIDC, mTLS).
7181 Authn,
7182 /// Authorization (route/service/listener/global policy chain).
7183 Authz,
7184 /// Request body validation (JSON schema, size limits).
7185 Validate,
7186 /// Request transforms (path rewrite, header/query transforms).
7187 Transform,
7188 /// Response cache lookup.
7189 Cache,
7190 /// Route dispatch (proxy, redirect, respond, ai, etc.).
7191 Route,
7192}
7193
7194impl FilterPhase {
7195 /// The default pipeline order (the order the dataplane has
7196 /// always executed the phases).
7197 pub const DEFAULT_ORDER: [FilterPhase; 8] = [
7198 FilterPhase::Acl,
7199 FilterPhase::RateLimit,
7200 FilterPhase::Authn,
7201 FilterPhase::Authz,
7202 FilterPhase::Validate,
7203 FilterPhase::Transform,
7204 FilterPhase::Cache,
7205 FilterPhase::Route,
7206 ];
7207
7208 pub fn as_str(self) -> &'static str {
7209 match self {
7210 FilterPhase::Acl => "acl",
7211 FilterPhase::RateLimit => "rate_limit",
7212 FilterPhase::Authn => "authn",
7213 FilterPhase::Authz => "authz",
7214 FilterPhase::Validate => "validate",
7215 FilterPhase::Transform => "transform",
7216 FilterPhase::Cache => "cache",
7217 FilterPhase::Route => "route",
7218 }
7219 }
7220}
7221
7222/// SCALE-12 (#191): per-phase dry-run configuration. When dry-run is
7223/// enabled for a phase, the phase executes its checks but does NOT
7224/// enforce (reject/block). Failures are logged as warnings and the
7225/// request continues. This enables safe policy rollout: enable a
7226/// phase in dry-run mode, observe the would-be rejections, then
7227/// switch to enforcement.
7228#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
7229#[serde(deny_unknown_fields)]
7230pub struct FilterChainConfig {
7231 /// Phases to run in dry-run mode (log but don't enforce). Each
7232 /// entry is a phase name from [`FilterPhase`]. A phase in
7233 /// dry-run mode still executes its logic; the difference is that
7234 /// a rejection is logged as a warning and the request continues.
7235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7236 pub dry_run: Vec<FilterPhase>,
7237 /// Optional ordering override: a list of phase names in the
7238 /// order they should execute. Phases not listed keep their
7239 /// default relative order. The override must contain exactly the
7240 /// same set of phases as the default (no duplicates, no unknown
7241 /// phases); validation rejects invalid overrides.
7242 #[serde(default, skip_serializing_if = "Option::is_none")]
7243 pub order: Option<Vec<FilterPhase>>,
7244}
7245
7246/// Resource limits for a proxy-wasm plugin (DW-055 decision 4; §9.3).
7247#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
7248#[serde(deny_unknown_fields)]
7249pub struct PluginLimitsConfig {
7250 /// Maximum fuel (wasmtime operations). Default: 1,000,000.
7251 #[serde(default, skip_serializing_if = "Option::is_none")]
7252 pub fuel: Option<u64>,
7253 /// Maximum linear memory in MB. Default: 32.
7254 #[serde(default, skip_serializing_if = "Option::is_none")]
7255 pub memory_mb: Option<usize>,
7256 /// Maximum execution time in milliseconds. Default: 100.
7257 #[serde(default, skip_serializing_if = "Option::is_none")]
7258 pub timeout_ms: Option<u64>,
7259}
7260
7261/// Anomaly scoring policy (DW-090, `policies[].anomaly`): lightweight
7262/// statistical detection of abusive request patterns. Each enabled
7263/// signal produces a normalized [0, 1] score from the request shape
7264/// (header entropy, header count/bytes, path length/depth, query count,
7265/// body size, unusual method); the overall score is the average of the
7266/// configured signals' scores. A score at or above `threshold` is
7267/// blocked (403 `anomaly_blocked`) unless `dry_run` is set.
7268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
7269#[serde(deny_unknown_fields)]
7270pub struct AnomalyPolicy {
7271 /// Whether anomaly scoring is enabled (default true). A `policies[].anomaly`
7272 /// block with `enabled: false` is a no-op (the scorer is not compiled).
7273 #[serde(default = "default_true")]
7274 pub enabled: bool,
7275 /// Monitor mode (DW-041 synergy): the score is computed and the
7276 /// would-block is logged + counted, but the request proceeds. Default
7277 /// false (enforce).
7278 #[serde(default)]
7279 pub dry_run: bool,
7280 /// The signals to score. Must be non-empty when `enabled` is true.
7281 /// Each signal contributes one normalized [0, 1] sub-score; the
7282 /// overall score is their average.
7283 pub signals: Vec<AnomalySignal>,
7284 /// Block threshold in [0, 1] (default 0.8). A score >= threshold is
7285 /// blocked (or logged in dry-run mode). Must be in (0, 1].
7286 #[serde(default = "default_anomaly_threshold")]
7287 pub threshold: f64,
7288 /// Maximum body bytes to inspect for the `body_size` signal (default
7289 /// 4096). The signal scores `min(body_len, cap) / cap`, so a body
7290 /// at or beyond the cap scores 1.0. Must be > 0.
7291 #[serde(default = "default_max_body_inspect")]
7292 pub max_body_inspect_bytes: u64,
7293}
7294
7295/// One anomaly signal (DW-090). Each produces a normalized [0, 1]
7296/// sub-score from one request-shape dimension.
7297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7298#[serde(rename_all = "snake_case")]
7299pub enum AnomalySignal {
7300 /// Shannon entropy of concatenated header values (normalized by 8,
7301 /// the max entropy for byte values). High entropy can indicate
7302 /// randomized/obfuscated header payloads.
7303 HeaderEntropy,
7304 /// Header count outlier: `count / 50`, capped at 1.0.
7305 HeaderCount,
7306 /// Total header bytes outlier: `bytes / 8192`, capped at 1.0.
7307 HeaderBytes,
7308 /// Path length outlier: `len / 1024`, capped at 1.0.
7309 PathLength,
7310 /// Path depth outlier: segment count / 20, capped at 1.0.
7311 PathDepth,
7312 /// Query parameter count outlier: `count / 50`, capped at 1.0.
7313 QueryCount,
7314 /// Body size outlier: `min(body_len, cap) / cap`, capped at 1.0.
7315 BodySize,
7316 /// Unusual HTTP method: 1.0 if the method is not one of GET, POST,
7317 /// PUT, DELETE, PATCH, HEAD, OPTIONS; 0.0 otherwise.
7318 MethodUnusual,
7319}
7320
7321fn default_anomaly_threshold() -> f64 {
7322 0.8
7323}
7324
7325fn default_max_body_inspect() -> u64 {
7326 4096
7327}
7328
7329// --- DW-089: adaptive + origin-driven rate-limit tuning -------------------
7330
7331/// Adaptive + origin-driven rate-limit tuning (DW-089,
7332/// `policies[].adaptive`): an EWMA of upstream error rate and latency
7333/// dynamically scales this policy's rate-limit quotas at check time
7334/// (the factor multiplies the effective cost of each request against
7335/// the GCRA bucket), and upstream-originated signals trigger an
7336/// immediate backoff. The factor is bounded `[min_factor, max_factor]`
7337/// (never 0, never unbounded); tightening under stress is faster than
7338/// relaxing when healthy (asymmetric, so the gateway errs toward
7339/// protecting upstreams). `origin_signals` selects which upstream
7340/// response signals the controller honors (currently `retry_after`).
7341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
7342#[serde(deny_unknown_fields)]
7343pub struct AdaptiveRateLimit {
7344 /// Whether adaptive tuning is enabled (default true). A
7345 /// `policies[].adaptive` block with `enabled: false` is a no-op
7346 /// (the controller is not compiled for that policy).
7347 #[serde(default = "default_true")]
7348 pub enabled: bool,
7349 /// EWMA decay window in seconds for error-rate and latency tracking
7350 /// (default 60). A larger window smooths transient spikes; a smaller
7351 /// window reacts faster. Must be > 0.
7352 #[serde(default = "default_ewma_window_secs")]
7353 pub ewma_window_secs: u64,
7354 /// Minimum adaptive factor (default 0.1): the tightest the quotas
7355 /// can shrink to (10% of the configured rate). Must be > 0 and <=
7356 /// `max_factor`. The factor is never 0.
7357 #[serde(default = "default_adaptive_min_factor")]
7358 pub min_factor: f64,
7359 /// Maximum adaptive factor (default 2.0): the loosest the quotas
7360 /// can grow to (200% of the configured rate). Must be > 0 and >=
7361 /// `min_factor`. The factor is never unbounded.
7362 #[serde(default = "default_adaptive_max_factor")]
7363 pub max_factor: f64,
7364 /// Error-rate EWMA threshold that triggers tightening (default 0.05
7365 /// = 5% 5xx rate). When the error EWMA exceeds this, the factor
7366 /// decreases toward `min_factor`. Must be in (0, 1].
7367 #[serde(default = "default_adaptive_error_threshold")]
7368 pub error_threshold: f64,
7369 /// Latency EWMA threshold in milliseconds that triggers tightening
7370 /// (default 500). When the latency EWMA exceeds this, the factor
7371 /// decreases toward `min_factor`. Must be > 0.
7372 #[serde(default = "default_adaptive_latency_threshold_ms")]
7373 pub latency_threshold_ms: u64,
7374 /// Upstream-originated signals the controller honors (default
7375 /// empty: EWMA-only tuning). Currently supported: `retry_after`
7376 /// (parse the upstream `Retry-After` header and back off for the
7377 /// advertised window).
7378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7379 pub origin_signals: Vec<OriginSignal>,
7380}
7381
7382/// One upstream-originated signal the adaptive controller honors
7383/// (DW-089).
7384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7385#[serde(rename_all = "snake_case")]
7386pub enum OriginSignal {
7387 /// Parse the upstream `Retry-After` header (integer seconds or
7388 /// HTTP-date) and back off for the advertised window: the factor
7389 /// drops to `min_factor` immediately and stays there until the
7390 /// window elapses.
7391 RetryAfter,
7392}
7393
7394fn default_ewma_window_secs() -> u64 {
7395 60
7396}
7397
7398fn default_adaptive_min_factor() -> f64 {
7399 0.1
7400}
7401
7402fn default_adaptive_max_factor() -> f64 {
7403 2.0
7404}
7405
7406fn default_adaptive_error_threshold() -> f64 {
7407 0.05
7408}
7409
7410fn default_adaptive_latency_threshold_ms() -> u64 {
7411 500
7412}