1use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use axum::Router;
14use axum::extract::{OriginalUri, State};
15use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
16use axum::middleware::Next;
17use axum::response::{IntoResponse, Response};
18use bytes::Bytes;
19
20use crate::admin::require_admin;
21use crate::config::Config;
22use crate::events::EventBus;
23use crate::http::{Json, MAX_BODY_BYTES, X_REQUEST_ID};
24use crate::module::HARNESS_API;
25use crate::ports::{Clock, Dispatcher, Kid, Payload, RateLimiter, Signer};
26use crate::problem::Problem;
27use crate::problems::SLUGS;
28use crate::rate_limit::{
29 RateLimit, RateLimitFailure, check_rate_limit, client_ip, rate_limit_keys,
30};
31use crate::scope::Scope;
32use crate::signer::{HmacSigner, KeyRing, MIN_SECRET_BYTES, TokenPolicy};
33
34pub const HARNESS_SIDECARS: &str = "HARNESS_SIDECARS";
37
38pub const X_HARNESS_API: &str = "x-harness-api";
42pub const X_HARNESS_MODULE: &str = "x-harness-module";
44
45pub const SIDECAR_GATEWAY_SECRET: &str = "SIDECAR_GATEWAY_SECRET";
51pub const SIDECAR_REQUIRE_GATEWAY: &str = "SIDECAR_REQUIRE_GATEWAY";
56pub const HARNESS_ONE_WORKER: &str = "HARNESS_ONE_WORKER";
61
62pub const X_HARNESS_GATEWAY: &str = "x-harness-gateway";
65
66pub const GATEWAY_PURPOSE: &str = "sidecar-gateway";
70
71pub const GATEWAY_ADMIN_PURPOSE: &str = "sidecar-gateway-admin";
78
79pub const GATEWAY_TOKEN_TTL_SECS: i64 = 120;
83
84const FORWARDED_REQUEST_HEADERS: &[&str] = &[
89 "content-type",
90 "content-length",
91 "accept",
92 "accept-language",
93 "user-agent",
94];
95
96const FORWARDED_RESPONSE_HEADERS: &[&str] = &[
100 "content-type",
101 "location",
102 "cache-control",
103 "etag",
104 "last-modified",
105 "vary",
106 "retry-after",
107 "content-disposition",
108 X_HARNESS_API,
109 X_HARNESS_MODULE,
110 X_REQUEST_ID,
111];
112
113#[must_use]
117pub(crate) fn truthy(config: &dyn Config, key: &str) -> bool {
118 config.get(key).is_some_and(|raw| {
119 matches!(
120 raw.to_ascii_lowercase().as_str(),
121 "1" | "true" | "yes" | "on"
122 )
123 })
124}
125
126pub(crate) fn gateway_signer(
136 config: &dyn Config,
137 clock: Arc<dyn Clock>,
138) -> Option<Arc<HmacSigner>> {
139 let secret = config
140 .get(SIDECAR_GATEWAY_SECRET)
141 .filter(|value| !value.trim().is_empty())?;
142 let mut ring = KeyRing::new();
143 match ring.rotate_signing(Kid::Cur, secret.into_bytes()) {
144 Ok(_) => {}
145 Err(crate::signer::SignerError::SecretTooShort) => {
146 tracing::error!("{SIDECAR_GATEWAY_SECRET} must be at least {MIN_SECRET_BYTES} bytes");
147 return None;
148 }
149 Err(err) => {
150 tracing::error!(error = %err, "{SIDECAR_GATEWAY_SECRET} could not enter the gateway ring");
151 return None;
152 }
153 }
154 let policy = TokenPolicy::default()
155 .with_max(GATEWAY_PURPOSE, Some(GATEWAY_TOKEN_TTL_SECS))
156 .with_max(GATEWAY_ADMIN_PURPOSE, Some(GATEWAY_TOKEN_TTL_SECS));
157 Some(Arc::new(
158 HmacSigner::from_ring(ring)
159 .with_clock(clock)
160 .with_policy(policy),
161 ))
162}
163
164pub(crate) fn mint_gateway(signer: &HmacSigner, mount: &str, admin: bool) -> String {
169 let purpose = if admin {
170 GATEWAY_ADMIN_PURPOSE
171 } else {
172 GATEWAY_PURPOSE
173 };
174 signer.sign(&Payload {
175 purpose: purpose.to_owned(),
176 subject: mount.to_owned(),
177 exp: None,
178 kid: Kid::Cur,
179 })
180}
181
182pub(crate) fn gateway_grant(signer: &HmacSigner, token: &str) -> Option<bool> {
191 if signer.verify(token, GATEWAY_ADMIN_PURPOSE).is_some() {
192 return Some(true);
193 }
194 signer.verify(token, GATEWAY_PURPOSE).map(|_| false)
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SidecarMount {
200 pub name: String,
202 pub binding: String,
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct SidecarMounts(Vec<SidecarMount>);
209
210impl SidecarMounts {
211 pub fn from_config(config: &dyn Config) -> Result<Self, Vec<String>> {
219 let Some(raw) = config
220 .get(HARNESS_SIDECARS)
221 .filter(|v| !v.trim().is_empty())
222 else {
223 return Ok(Self::default());
224 };
225 if truthy(config, HARNESS_ONE_WORKER) {
226 return Err(vec![format!(
227 "{HARNESS_SIDECARS} names a sidecar while {HARNESS_ONE_WORKER} is set: a one-Worker \
228 deployment serves every module in-process and can mount nothing (issue #131)"
229 )]);
230 }
231 let parsed: BTreeMap<String, String> = serde_json::from_str(&raw).map_err(|err| {
232 vec![format!(
233 "{HARNESS_SIDECARS} must be a JSON object of {{\"module-name\": \"BINDING\"}}: {err}"
234 )]
235 })?;
236
237 let mut errors = Vec::new();
238 let mut mounts = Vec::new();
239 for (name, binding) in parsed {
240 if !is_kebab(&name) {
241 errors.push(format!(
242 "sidecar name `{name}` must be kebab-case ([a-z0-9]+ separated by '-')"
243 ));
244 }
245 if binding.trim().is_empty() {
246 errors.push(format!("sidecar `{name}` has an empty service binding"));
247 }
248 mounts.push(SidecarMount { name, binding });
249 }
250 if errors.is_empty() {
251 Ok(Self(mounts))
252 } else {
253 Err(errors)
254 }
255 }
256
257 #[must_use]
258 pub fn is_empty(&self) -> bool {
259 self.0.is_empty()
260 }
261
262 pub fn iter(&self) -> impl Iterator<Item = &SidecarMount> {
263 self.0.iter()
264 }
265
266 #[must_use]
269 pub fn collisions(&self, module_names: &[&str]) -> Vec<String> {
270 self.0
271 .iter()
272 .filter(|m| module_names.contains(&m.name.as_str()))
273 .map(|m| {
274 format!(
275 "sidecar `{}` claims `/v1/{}`, already served in-process",
276 m.name, m.name
277 )
278 })
279 .collect()
280 }
281}
282
283fn is_kebab(s: &str) -> bool {
284 !s.is_empty()
285 && !s.starts_with('-')
286 && !s.ends_with('-')
287 && !s.contains("--")
288 && s.chars()
289 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
290}
291
292struct SidecarState {
293 mount: SidecarMount,
294 dispatcher: Option<Arc<dyn Dispatcher>>,
295 config: Arc<dyn Config>,
296 rate_limiter: Option<Arc<dyn RateLimiter>>,
297 gateway: Option<Arc<HmacSigner>>,
298}
299
300pub(crate) fn router(
303 mount: SidecarMount,
304 dispatcher: Option<Arc<dyn Dispatcher>>,
305 config: Arc<dyn Config>,
306 rate_limiter: Option<Arc<dyn RateLimiter>>,
307 gateway: Option<Arc<HmacSigner>>,
308) -> Router {
309 Router::new()
310 .fallback(forward)
311 .with_state(Arc::new(SidecarState {
312 mount,
313 dispatcher,
314 config,
315 rate_limiter,
316 gateway,
317 }))
318}
319
320async fn forward(
321 State(state): State<Arc<SidecarState>>,
322 scope: Scope,
323 OriginalUri(uri): OriginalUri,
327 request: axum::extract::Request,
328) -> Response {
329 let unavailable = |detail: String| -> Response {
330 Problem::new(&SLUGS.sidecar_unavailable)
331 .with_detail(detail)
332 .instance(&scope.request_id)
333 .into_response()
334 };
335
336 let Some(dispatcher) = state.dispatcher.clone() else {
337 tracing::warn!(
338 module = state.mount.name,
339 "sidecar mounted but the runtime provides no dispatcher"
340 );
341 return unavailable(format!(
342 "`{}` is mounted as a sidecar but this runtime cannot dispatch",
343 state.mount.name
344 ));
345 };
346 if !dispatcher.has(&state.mount.binding) {
347 tracing::warn!(
348 module = state.mount.name,
349 binding = state.mount.binding,
350 "sidecar binding is not present in this deployment"
351 );
352 return unavailable(format!(
353 "`{}` is mounted on binding `{}`, which this deployment does not have",
354 state.mount.name, state.mount.binding
355 ));
356 }
357
358 let (parts, body) = request.into_parts();
359 let Ok(body) = axum::body::to_bytes(body, MAX_BODY_BYTES).await else {
360 return Problem::new(&SLUGS.request_too_large)
361 .instance(&scope.request_id)
362 .into_response();
363 };
364
365 let admin = is_admin_path(uri.path());
370 if admin && let Err(problem) = require_admin(state.config.as_ref(), &parts.headers) {
371 return problem.instance(&scope.request_id).into_response();
372 }
373
374 if matches!(
381 parts.method,
382 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
383 ) {
384 let keys = rate_limit_keys(client_ip(&parts.headers).as_deref(), None);
385 if let RateLimit::Denied { retry_after } = check_rate_limit(
386 state.rate_limiter.as_ref(),
387 &keys,
388 RateLimitFailure::FailClosed,
389 )
390 .await
391 {
392 return crate::http::rate_limited(retry_after);
393 }
394 }
395
396 let mut outbound = http::Request::builder()
397 .method(parts.method.clone())
398 .uri(uri);
399 if let Some(headers) = outbound.headers_mut() {
400 write_forwarded_headers(headers, &state, &scope, &parts.headers, admin);
401 }
402 let outbound = match outbound.body(body) {
403 Ok(req) => req,
404 Err(err) => return unavailable(format!("could not build the forwarded request: {err}")),
405 };
406
407 match dispatcher.dispatch(&state.mount.binding, outbound).await {
408 Ok(response) => match contract_of(&response) {
409 Some(api) if api != HARNESS_API => {
410 tracing::warn!(
411 module = state.mount.name,
412 sidecar_api = api,
413 host_api = HARNESS_API,
414 "sidecar contract mismatch"
415 );
416 Problem::new(&SLUGS.sidecar_contract_mismatch)
417 .with_detail(format!(
418 "`{}` answers contract {api}; this harness speaks {HARNESS_API}",
419 state.mount.name
420 ))
421 .instance(&scope.request_id)
422 .into_response()
423 }
424 _ => into_axum(response),
425 },
426 Err(err) => {
427 tracing::warn!(module = state.mount.name, error = %err, "sidecar dispatch failed");
428 unavailable(err.to_string())
429 }
430 }
431}
432
433fn write_forwarded_headers(
440 headers: &mut http::HeaderMap,
441 state: &SidecarState,
442 scope: &Scope,
443 inbound: &http::HeaderMap,
444 admin: bool,
445) {
446 for name in FORWARDED_REQUEST_HEADERS {
450 let name = HeaderName::from_static(name);
451 if let Some(value) = inbound.get(&name) {
452 headers.append(name, value.clone());
453 }
454 }
455 if let Ok(value) = scope.request_id.parse() {
458 headers.insert(X_REQUEST_ID, value);
459 }
460 if let Some(ip) = client_ip(inbound)
464 && let Ok(value) = HeaderValue::from_str(&ip)
465 {
466 headers.insert(HeaderName::from_static("cf-connecting-ip"), value);
467 }
468 if let Some(signer) = state.gateway.as_ref() {
474 let token = mint_gateway(signer, &state.mount.name, admin);
475 if let Ok(value) = HeaderValue::from_str(&token) {
476 headers.insert(HeaderName::from_static(X_HARNESS_GATEWAY), value);
477 }
478 }
479}
480
481fn contract_of(response: &http::Response<Bytes>) -> Option<u32> {
482 response
483 .headers()
484 .get(X_HARNESS_API)
485 .and_then(|v| v.to_str().ok())
486 .and_then(|v| v.parse().ok())
487}
488
489fn into_axum(response: http::Response<Bytes>) -> Response {
494 let (parts, body) = response.into_parts();
495 let mut out = Response::new(axum::body::Body::from(body));
496 *out.status_mut() = parts.status;
497 *out.version_mut() = parts.version;
498 for name in FORWARDED_RESPONSE_HEADERS {
499 let name = HeaderName::from_static(name);
500 if let Some(value) = parts.headers.get(&name) {
501 out.headers_mut().append(name.clone(), value.clone());
502 }
503 }
504 out
505}
506
507#[derive(serde::Deserialize)]
513pub(crate) struct EventEnvelope {
514 event: String,
515 payload: serde_json::Value,
516}
517
518pub struct EventForwarder {
523 mounts: Vec<SidecarMount>,
524 dispatcher: Arc<dyn Dispatcher>,
525 gateway: Option<Arc<HmacSigner>>,
526}
527
528impl EventForwarder {
529 pub(crate) fn new(
530 mounts: Vec<SidecarMount>,
531 dispatcher: Arc<dyn Dispatcher>,
532 gateway: Option<Arc<HmacSigner>>,
533 ) -> Self {
534 Self {
535 mounts,
536 dispatcher,
537 gateway,
538 }
539 }
540
541 #[must_use]
543 pub fn is_empty(&self) -> bool {
544 self.mounts.is_empty()
545 }
546
547 pub(crate) async fn forward(&self, request_id: &str, name: &str, payload: &serde_json::Value) {
554 let body = match serde_json::to_vec(&serde_json::json!({
555 "event": name,
556 "payload": payload,
557 })) {
558 Ok(body) => body,
559 Err(err) => {
560 tracing::warn!(event = %name, error = %err, "event payload did not serialize");
561 return;
562 }
563 };
564 for mount in &self.mounts {
565 if !self.dispatcher.has(&mount.binding) {
566 continue;
567 }
568 let mut builder = http::Request::builder()
569 .method(Method::POST)
570 .uri("/__events")
571 .header(HeaderName::from_static("content-type"), "application/json");
572 if let Ok(value) = HeaderValue::from_str(request_id) {
574 builder = builder.header(HeaderName::from_static(X_REQUEST_ID), value);
575 }
576 if let Some(signer) = self.gateway.as_ref() {
577 builder = builder.header(
579 HeaderName::from_static(X_HARNESS_GATEWAY),
580 mint_gateway(signer, &mount.name, false),
581 );
582 }
583 let request = match builder.body(Bytes::from(body.clone())) {
584 Ok(request) => request,
585 Err(err) => {
586 tracing::warn!(event = %name, error = %err, "event forward could not be built");
587 continue;
588 }
589 };
590 match self.dispatcher.dispatch(&mount.binding, request).await {
591 Ok(response) if response.status().is_success() => {}
592 Ok(response) => {
593 let detail = format!(
594 "event forward to `{}` answered {}",
595 mount.name,
596 response.status()
597 );
598 tracing::warn!(event = %name, module = %mount.name, detail, "event forward was not accepted");
599 crate::logging::forward_internal_error(&detail);
600 }
601 Err(err) => {
602 let detail = format!(
603 "event forward to `{mount}` failed: {err}",
604 mount = mount.name
605 );
606 tracing::warn!(event = %name, module = %mount.name, error = %err, "event forward failed");
607 crate::logging::forward_internal_error(&detail);
608 }
609 }
610 }
611 }
612}
613
614pub(crate) struct InboundEvents {
621 pub(crate) bus: EventBus,
622 pub(crate) gateway: Option<Arc<HmacSigner>>,
623}
624
625pub(crate) async fn events_inbound(
631 State(state): State<Arc<InboundEvents>>,
632 scope: Scope,
633 headers: axum::http::HeaderMap,
634 body: bytes::Bytes,
635) -> Response {
636 if let Some(signer) = state.gateway.as_ref() {
639 let presented = headers
640 .get(X_HARNESS_GATEWAY)
641 .and_then(|value| value.to_str().ok());
642 if presented
643 .and_then(|token| gateway_grant(signer, token))
644 .is_none()
645 {
646 return Problem::new(&SLUGS.sidecar_unauthorized)
647 .instance(&scope.request_id)
648 .into_response();
649 }
650 }
651 let Ok(envelope) = serde_json::from_slice::<EventEnvelope>(&body) else {
652 return Problem::new(&SLUGS.validation_failed)
653 .with_detail("body must be `{\"event\": \"<name>\", \"payload\": …}`")
654 .instance(&scope.request_id)
655 .into_response();
656 };
657 let handled = state
658 .bus
659 .deliver_inbound(&scope, &envelope.event, &envelope.payload);
660 if handled == 0 {
661 let detail = format!(
665 "event `{}` arrived over the boundary with no subscriber",
666 envelope.event
667 );
668 tracing::warn!(event = %envelope.event, "{detail}");
669 crate::logging::forward_internal_error(&detail);
670 }
671 (
677 StatusCode::ACCEPTED,
678 Json(serde_json::json!({ "accepted": handled > 0, "handlers": handled })),
679 )
680 .into_response()
681}
682
683fn is_admin_path(path: &str) -> bool {
689 path == "/admin" || path.ends_with("/admin") || path.contains("/admin/")
690}
691
692#[derive(Clone)]
695pub(crate) struct GatewayGuard {
696 pub require: bool,
698 pub signer: Option<Arc<HmacSigner>>,
700 pub admin_token: Option<String>,
703}
704
705pub(crate) async fn gateway_guard(
714 State(guard): State<GatewayGuard>,
715 request: axum::extract::Request,
716 next: Next,
717) -> Response {
718 let path = request.uri().path().to_owned();
719 if !guard.require || !(path.starts_with("/v1/") || path == "/__surface" || path == "/__events")
723 {
724 return next.run(request).await;
725 }
726 let instance = request
727 .extensions()
728 .get::<Scope>()
729 .map(|scope| scope.request_id.clone());
730 let refused = |problem: Problem| match &instance {
731 Some(id) => problem.instance(id),
732 None => problem,
733 };
734 let Some(signer) = guard.signer.as_ref() else {
735 tracing::error!(
736 "{SIDECAR_REQUIRE_GATEWAY} is set but {SIDECAR_GATEWAY_SECRET} is \
737 missing; refusing every guarded request"
738 );
739 return refused(Problem::new(&SLUGS.sidecar_unavailable).with_detail(
740 "this sidecar requires a gateway token but no gateway secret is configured",
741 ))
742 .into_response();
743 };
744 let presented = request
745 .headers()
746 .get(X_HARNESS_GATEWAY)
747 .and_then(|value| value.to_str().ok());
748 let Some(host_authorized_admin) = presented.and_then(|token| gateway_grant(signer, token))
749 else {
750 return refused(Problem::new(&SLUGS.sidecar_unauthorized)).into_response();
751 };
752 let mut request = request;
763 if host_authorized_admin
764 && is_admin_path(&path)
765 && !request.headers().contains_key("authorization")
766 && let Some(token) = &guard.admin_token
767 && let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}"))
768 {
769 request
770 .headers_mut()
771 .insert(HeaderName::from_static("authorization"), value);
772 }
773 next.run(request).await
774}