From b66b1d21b63d4e8545e9b1b1bb7951c3d712a0cc Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Apr 2026 12:49:12 -0500 Subject: [PATCH 1/2] Split routing infrastructure into standalone branch --- crates/integration-tests/Cargo.toml | 5 + crates/integration-tests/tests/routing.rs | 226 ++++++++ .../backends.toml | 176 ++++++ .../test-backends.toml | 21 + crates/trusted-server-core/build.rs | 94 +++- .../trusted-server-core/src/backend_router.rs | 524 ++++++++++++++++++ crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/publisher.rs | 55 +- crates/trusted-server-core/src/settings.rs | 119 ++++ docs/.vitepress/config.mts | 10 +- docs/guide/multi-backend-routing.md | 184 ++++++ scripts/integration-tests.sh | 17 + trusted-server.toml | 21 +- 13 files changed, 1429 insertions(+), 24 deletions(-) create mode 100644 crates/integration-tests/tests/routing.rs create mode 100644 crates/trusted-server-adapter-fastly/backends.toml create mode 100644 crates/trusted-server-adapter-fastly/test-backends.toml create mode 100644 crates/trusted-server-core/src/backend_router.rs create mode 100644 docs/guide/multi-backend-routing.md diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 24d0cdcc..16ea420e 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -9,6 +9,11 @@ name = "integration" path = "tests/integration.rs" harness = true +[[test]] +name = "routing" +path = "tests/routing.rs" +harness = true + [dev-dependencies] testcontainers = { version = "0.25", features = ["blocking"] } reqwest = { version = "0.12", features = ["blocking"] } diff --git a/crates/integration-tests/tests/routing.rs b/crates/integration-tests/tests/routing.rs new file mode 100644 index 00000000..bdbff5fd --- /dev/null +++ b/crates/integration-tests/tests/routing.rs @@ -0,0 +1,226 @@ +#![allow(dead_code)] + +mod common; +mod environments; + +use common::runtime::RuntimeEnvironment as _; +use environments::fastly::FastlyViceroy; +use std::io::{Read as _, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::thread; + +/// In-process HTTP server that returns a fixed response body. +/// +/// Listens on a fixed port. Accepts connections in a background thread, +/// drains each request, and responds with `HTTP/1.1 200 OK` and the +/// configured body. Stopped on [`Drop`] via a shutdown flag + self-connect. +/// +/// Does not store the `JoinHandle` so that `MockOrigin` remains `Sync` +/// (required for placement in a `static OnceLock`). The thread exits +/// naturally when the process ends. +struct MockOrigin { + port: u16, + shutdown: Arc, +} + +impl MockOrigin { + /// Start a mock origin server on `port` that always responds with `body`. + /// + /// # Panics + /// + /// Panics if the port cannot be bound. + fn start(port: u16, body: &'static str) -> Self { + let listener = TcpListener::bind(format!("127.0.0.1:{port}")) + .unwrap_or_else(|e| panic!("should bind MockOrigin to port {port}: {e}")); + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = Arc::clone(&shutdown); + + thread::spawn(move || { + for stream in listener.incoming() { + if shutdown_clone.load(Ordering::Relaxed) { + break; + } + if let Ok(stream) = stream { + serve(stream, body); + } + } + }); + + MockOrigin { port, shutdown } + } +} + +impl Drop for MockOrigin { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + // Unblock the accept() call so the thread can observe the shutdown flag. + let _ = TcpStream::connect(format!("127.0.0.1:{}", self.port)); + } +} + +/// Write a minimal HTTP/1.1 200 response with `body` to `stream`. +/// +/// Drains the incoming request first so the client does not see a broken pipe. +fn serve(mut stream: TcpStream, body: &'static str) { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", + len = body.len(), + ); + let _ = stream.write_all(response.as_bytes()); +} + +/// Shared test state: mock origins + Viceroy process + pre-configured reqwest client. +/// +/// Initialised once via [`get_harness`]. All five test functions share this +/// single instance to avoid the cost of spinning up Viceroy per test. +struct RoutingHarness { + _origins: Vec, + _process: common::runtime::RuntimeProcess, + /// Client with resolve overrides so `http://site-a.test/` connects to Viceroy + /// while sending the correct `Host` header. + client: reqwest::blocking::Client, +} + +static HARNESS: OnceLock> = OnceLock::new(); + +/// Return the shared harness, or `None` if `ROUTING_WASM_PATH` is not set. +/// +/// Returns `None` rather than panicking so that tests pass trivially when +/// invoked outside the routing-specific CI step (e.g. `cargo test --workspace`). +fn get_harness() -> Option<&'static RoutingHarness> { + HARNESS + .get_or_init(|| { + let wasm_path = std::env::var("ROUTING_WASM_PATH").ok()?; + + let origins = vec![ + MockOrigin::start(19090, "default"), + MockOrigin::start(19091, "site-a"), + MockOrigin::start(19092, "site-b"), + MockOrigin::start(19093, "api"), + ]; + + let process = FastlyViceroy + .spawn(std::path::Path::new(&wasm_path)) + .expect("should spawn Viceroy with routing WASM"); + + let viceroy_port: u16 = process + .base_url + .trim_start_matches("http://127.0.0.1:") + .parse() + .expect("should parse Viceroy port from base_url"); + + let viceroy_addr: std::net::SocketAddr = format!("127.0.0.1:{viceroy_port}") + .parse() + .expect("should parse Viceroy socket addr"); + + let client = reqwest::blocking::ClientBuilder::new() + .resolve("site-a.test", viceroy_addr) + .resolve("www.site-a.test", viceroy_addr) + .resolve("site-b.test", viceroy_addr) + .resolve("any.test", viceroy_addr) + .resolve("unknown.test", viceroy_addr) + .build() + .expect("should build reqwest client"); + + Some(RoutingHarness { + _origins: origins, + _process: process, + client, + }) + }) + .as_ref() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn domain_routes_to_site_a() { + let Some(h) = get_harness() else { return }; + + let body = h + .client + .get("http://site-a.test/") + .send() + .expect("should send request to site-a.test") + .text() + .expect("should read response body"); + + assert_eq!(body, "site-a", "should route site-a.test to the site-a backend"); +} + +#[test] +fn domain_routes_to_site_b() { + let Some(h) = get_harness() else { return }; + + let body = h + .client + .get("http://site-b.test/") + .send() + .expect("should send request to site-b.test") + .text() + .expect("should read response body"); + + assert_eq!(body, "site-b", "should route site-b.test to the site-b backend"); +} + +#[test] +fn www_prefix_stripped() { + let Some(h) = get_harness() else { return }; + + let body = h + .client + .get("http://www.site-a.test/") + .send() + .expect("should send request to www.site-a.test") + .text() + .expect("should read response body"); + + assert_eq!( + body, "site-a", + "should strip www. prefix and route to the site-a backend" + ); +} + +#[test] +fn path_routes_to_api() { + let Some(h) = get_harness() else { return }; + + // any.test has no domain entry — path pattern matching fires instead. + let body = h + .client + .get("http://any.test/.api/users") + .send() + .expect("should send request to any.test/.api/users") + .text() + .expect("should read response body"); + + assert_eq!( + body, "api", + "should route /.api/ path prefix to the api backend" + ); +} + +#[test] +fn unknown_host_falls_back_to_default() { + let Some(h) = get_harness() else { return }; + + let body = h + .client + .get("http://unknown.test/") + .send() + .expect("should send request to unknown.test") + .text() + .expect("should read response body"); + + assert_eq!( + body, "default", + "should fall back to publisher.origin_url for unmatched hosts" + ); +} diff --git a/crates/trusted-server-adapter-fastly/backends.toml b/crates/trusted-server-adapter-fastly/backends.toml new file mode 100644 index 00000000..fbb04045 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/backends.toml @@ -0,0 +1,176 @@ +# Arena Group / SayMedia backend routing configuration. +# +# This file is merged into the embedded binary config at build time by +# crates/trusted-server-core/build.rs. It is separate from trusted-server.toml to keep +# customer-specific configuration out of the shared application template. + +[[backends]] +id = "raven" +origin_url = "https://raven-public.prod.saymedia.com" +certificate_check = true +domains = [ + "active.com", + "americansongwriter.com", + "athleticbusiness.com", + "athlonsports.com", + "autoblog.com", + "azbigmedia.com", + "benzinga.com", + "bestproducts.com", + "bicycling.com", + "biography.com", + "bizjournals.com", + "bleacherreport.com", + "blogher.com", + "carsdirect.com", + "catalog.thearenagroup.net", + "cbsnews.com", + "cheatsheet.com", + "chron.com", + "cinemablend.com", + "citybeatnews.com", + "coachmag.co.uk", + "coastalliving.com", + "collegehumor.com", + "countryliving.com", + "ctnewsonline.com", + "dailypress.com", + "delish.com", + "denofgeek.com", + "detroit.cbslocal.com", + "digg.com", + "digitalspy.com", + "diynetwork.com", + "dooyoo.co.uk", + "dualshockers.com", + "eater.com", + "elle.com", + "elledecor.com", + "esquire.com", + "eurweb.com", + "everydayhealth.com", + "fansided.com", + "fightful.com", + "filmschoolrejects.com", + "fitbit.com", + "foodandwine.com", + "fool.com", + "forbes.com", + "freep.com", + "gamerant.com", + "gizmodo.com", + "glam.com", + "goodhousekeeping.com", + "grunge.com", + "health.com", + "healthline.com", + "hercampus.com", + "hgtv.com", + "history.com", + "hollywoodreporter.com", + "housebeautiful.com", + "huffpost.com", + "ibtimes.com", + "ign.com", + "indiewire.com", + "insidehook.com", + "instyle.com", + "investopedia.com", + "io9.com", + "jezebel.com", + "kiplinger.com", + "kotaku.com", + "latimes.com", + "law.com", + "lifehacker.com", + "livestrong.com", + "livescience.com", + "localiq.com", + "looper.com", + "mashable.com", + "mayoclinic.org", + "medicalnewstoday.com", + "menshealth.com", + "mensjournal.com", + "meredith.com", + "metro.co.uk", + "military.com", + "militarytimes.com", + "mlb.com", + "mlive.com", + "mnn.com", + "motorcyclistonline.com", + "msn.com", + "narcity.com", + "nationalreview.com", + "nbcnews.com", + "nerdist.com", + "newsweek.com", + "nj.com", + "nola.com", + "npr.org", + "nypost.com", + "nytimes.com", + "observer.com", + "oregonlive.com", + "outsideonline.com", + "outsports.com", + "oxygen.com", + "parade.com", + "patch.com", + "pcgamer.com", + "pennlive.com", + "people.com", + "petmd.com", + "pgalinks.org", + "philly.com", + "polygon.com", + "popsugar.com", + "prevention.com", + "purewow.com", + "realclearpolitics.com", + "realsimple.com", + "realtor.com", + "refinery29.com", + "rollingstone.com", + "runnersworld.com", + "salon.com", + "scout.com", + "screenrant.com", + "sfgate.com", + "si.com", + "simplemost.com", + "slate.com", + "space.com", + "sporcle.com", + "sportingnews.com", + "sportskeeda.com", + "southernliving.com", + "stltoday.com", + "syracuse.com", + "tampabay.com", + "thedailybeast.com", + "thedailymeal.com", + "thedenverchannel.com", + "thegamer.com", + "thelist.com", + "thepioneerwoman.com", + "thestreet.com", + "thethings.com", + "time.com", + "tmz.com", + "today.com", + "townandcountrymag.com", + "travelandleisure.com", + "usatoday.com", + "variety.com", + "verywellhealth.com", + "vox.com", + "vulture.com", + "washingtonpost.com", + "webmd.com", + "wired.com", + "womansday.com", + "womenshealthmag.com", + "yahoo.com", +] diff --git a/crates/trusted-server-adapter-fastly/test-backends.toml b/crates/trusted-server-adapter-fastly/test-backends.toml new file mode 100644 index 00000000..5485f552 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/test-backends.toml @@ -0,0 +1,21 @@ +# Test-only backend routing config. +# Embedded at compile time when ROUTING_TEST_BACKENDS=1 is set. +# Ports 19090-19093 are used by MockOrigin servers in tests/routing.rs. + +[[backends]] +id = "site-a" +origin_url = "http://127.0.0.1:19091" +domains = ["site-a.test", "www.site-a.test"] + +[[backends]] +id = "site-b" +origin_url = "http://127.0.0.1:19092" +domains = ["site-b.test"] + +[[backends]] +id = "api" +origin_url = "http://127.0.0.1:19093" + + [[backends.path_patterns]] + host = "*" + path_prefix = "/.api/" diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 6f56e85a..b1cc467d 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -18,19 +18,46 @@ mod consent_config; #[path = "src/settings.rs"] mod settings; +use serde_json::Value; +use std::collections::HashSet; use std::fs; use std::path::Path; const TRUSTED_SERVER_INIT_CONFIG_PATH: &str = "../../trusted-server.toml"; const TRUSTED_SERVER_OUTPUT_CONFIG_PATH: &str = "../../target/trusted-server-out.toml"; +const BACKENDS_CONFIG_PATH: &str = "../../crates/trusted-server-adapter-fastly/backends.toml"; +const TEST_BACKENDS_CONFIG_PATH: &str = + "../../crates/trusted-server-adapter-fastly/test-backends.toml"; fn main() { - // Always rerun build.rs: integration settings are stored in a flat - // HashMap, so we cannot enumerate all possible env - // var keys ahead of time. Emitting rerun-if-changed for a nonexistent - // file forces cargo to always rerun the build script. - println!("cargo:rerun-if-changed=_always_rebuild_sentinel_"); + merge_toml(); + rerun_if_changed(); +} + +fn rerun_if_changed() { + // Watch the root trusted-server.toml file for changes + println!("cargo:rerun-if-changed={}", TRUSTED_SERVER_INIT_CONFIG_PATH); + println!("cargo:rerun-if-changed={}", BACKENDS_CONFIG_PATH); + println!("cargo:rerun-if-changed={}", TEST_BACKENDS_CONFIG_PATH); + println!("cargo:rerun-if-env-changed=ROUTING_TEST_BACKENDS"); + + // Create a default Settings instance and convert to JSON to discover all fields + let default_settings = settings::Settings::default(); + let settings_json = serde_json::to_value(&default_settings).unwrap(); + + let mut env_vars = HashSet::new(); + collect_env_vars(&settings_json, &mut env_vars, &[]); + + // Print rerun-if-env-changed for each variable + let mut sorted_vars: Vec<_> = env_vars.into_iter().collect(); + sorted_vars.sort(); + for var in sorted_vars { + println!("cargo:rerun-if-env-changed={}", var); + } +} + +fn merge_toml() { // Read init config let init_config_path = Path::new(TRUSTED_SERVER_INIT_CONFIG_PATH); let toml_content = fs::read_to_string(init_config_path) @@ -38,13 +65,35 @@ fn main() { // Merge base TOML with environment variable overrides and write output. // Panics if admin endpoints are not covered by a handler. - let settings = settings::Settings::from_toml_and_env(&toml_content) + // Note: placeholder secret rejection is intentionally NOT done here. + // The base trusted-server.toml ships with placeholder secrets that + // production deployments override via TRUSTED_SERVER__* env vars at + // build time. Runtime startup (get_settings) rejects any remaining + // placeholders so a misconfigured deployment fails fast. + let mut settings = settings::Settings::from_toml_and_env(&toml_content) .expect("Failed to parse settings at build time"); - let merged_toml = - toml::to_string_pretty(&settings).expect("Failed to serialize settings to TOML"); + // Merge customer-specific backends from crates/fastly/backends.toml, if present + let backends_path = if std::env::var("ROUTING_TEST_BACKENDS").is_ok() { + Path::new(TEST_BACKENDS_CONFIG_PATH) + } else { + Path::new(BACKENDS_CONFIG_PATH) + }; + if backends_path.exists() { + #[derive(serde::Deserialize)] + struct BackendsFile { + backends: Vec, + } + let backends_toml = fs::read_to_string(backends_path) + .unwrap_or_else(|_| panic!("Failed to read {:?}", backends_path)); + let backends_file: BackendsFile = + toml::from_str(&backends_toml).expect("Failed to parse backends.toml"); + settings.backends.extend(backends_file.backends); + } // Only write when content changes to avoid unnecessary recompilation. + let merged_toml = + toml::to_string_pretty(&settings).expect("Failed to serialize settings to TOML"); let dest_path = Path::new(TRUSTED_SERVER_OUTPUT_CONFIG_PATH); let current = fs::read_to_string(dest_path).unwrap_or_default(); if current != merged_toml { @@ -52,3 +101,32 @@ fn main() { .unwrap_or_else(|_| panic!("Failed to write {dest_path:?}")); } } + +fn collect_env_vars(value: &Value, env_vars: &mut HashSet, path: &[String]) { + if let Value::Object(map) = value { + for (key, val) in map { + let mut new_path = path.to_owned(); + new_path.push(key.to_uppercase()); + + match val { + Value::String(_) | Value::Number(_) | Value::Bool(_) => { + // Leaf node - create environment variable + let env_var = format!( + "{}{}{}", + settings::ENVIRONMENT_VARIABLE_PREFIX, + settings::ENVIRONMENT_VARIABLE_SEPARATOR, + new_path.join(settings::ENVIRONMENT_VARIABLE_SEPARATOR) + ); + env_vars.insert(env_var); + } + Value::Object(_) => { + // Recurse into nested objects + collect_env_vars(val, env_vars, &new_path); + } + // Arrays (e.g. `backends`) cannot be overridden per-element via env vars. + // Env overrides replace entire scalar fields; skip array values intentionally. + _ => {} + } + } + } +} diff --git a/crates/trusted-server-core/src/backend_router.rs b/crates/trusted-server-core/src/backend_router.rs new file mode 100644 index 00000000..f862f2f4 --- /dev/null +++ b/crates/trusted-server-core/src/backend_router.rs @@ -0,0 +1,524 @@ +use error_stack::Report; +use regex::Regex; +use std::borrow::Cow; +use std::collections::HashMap; + +use crate::error::TrustedServerError; +use crate::settings::{BackendRoutingConfig, PathPattern}; + +/// Backend routing system that selects the appropriate origin URL based on request host and path. +/// +/// Leverages Trusted Server's dynamic backend creation - we just need to select the right +/// origin URL, and the backend will be created automatically via [`crate::backend::BackendConfig::from_url()`]. +/// +/// Supports: +/// - Domain-based routing (exact match + www normalization) +/// - Path-based routing (optional prefix/regex patterns) +/// - Fallback to default origin +#[derive(Debug, Clone)] +pub struct BackendRouter { + routes: Vec, + domain_index: HashMap, + default_origin: String, + default_certificate_check: bool, +} + +#[derive(Debug, Clone)] +pub struct BackendRoute { + pub origin_url: String, + pub certificate_check: bool, + path_patterns: Vec, +} + +#[derive(Clone)] +struct CompiledPathPattern { + host: Option, + path_prefix: Option, + path_regex: Option, +} + +impl core::fmt::Debug for CompiledPathPattern { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CompiledPathPattern") + .field("host", &self.host) + .field("path_prefix", &self.path_prefix) + .field("path_regex", &self.path_regex.as_ref().map(Regex::as_str)) + .finish() + } +} + +impl CompiledPathPattern { + fn new(pattern: &PathPattern) -> Result> { + let path_regex = pattern + .path_regex + .as_deref() + .map(|s| { + Regex::new(s).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid path_regex pattern `{s}`: {e}"), + }) + }) + }) + .transpose()?; + + Ok(Self { + host: pattern.host.clone(), + path_prefix: pattern.path_prefix.clone(), + path_regex, + }) + } + + fn matches(&self, host: &str, path: &str) -> bool { + let host_matches = match &self.host { + None => true, + Some(pattern) if pattern == "*" => true, + Some(pattern) => { + let normalized_host = normalize_domain(host); + let normalized_pattern = normalize_domain(pattern); + normalized_host == normalized_pattern + } + }; + + if !host_matches { + return false; + } + + if let Some(prefix) = &self.path_prefix { + return path.starts_with(prefix); + } + + if let Some(ref regex) = self.path_regex { + return regex.is_match(path); + } + + true + } +} + +impl BackendRouter { + /// Creates a new [`BackendRouter`] from backend configurations. + /// + /// Backends are stored as origin URL + `certificate_check` pairs. + /// The actual Fastly backend will be created dynamically at request time. + /// + /// # Errors + /// + /// Returns an error if a path regex pattern fails to compile. + pub fn new( + backends: &[BackendRoutingConfig], + default_origin: String, + default_certificate_check: bool, + ) -> Result> { + let mut domain_index = HashMap::new(); + let mut routes = Vec::with_capacity(backends.len()); + + for (idx, backend) in backends.iter().enumerate() { + for domain in &backend.domains { + let normalized = normalize_domain(domain).into_owned(); + if let Some(existing_idx) = domain_index.insert(normalized.clone(), idx) { + log::warn!( + "Backend domain '{}' appears in multiple backends (index {} and {}); using backend {}", + normalized, existing_idx, idx, idx + ); + } + } + + let path_patterns = backend + .path_patterns + .iter() + .map(CompiledPathPattern::new) + .collect::, _>>()?; + + routes.push(BackendRoute { + origin_url: backend.origin_url.clone(), + certificate_check: backend.certificate_check, + path_patterns, + }); + } + + Ok(Self { + routes, + domain_index, + default_origin, + default_certificate_check, + }) + } + + /// Selects the appropriate origin URL and TLS settings based on request host and path. + /// + /// Selection priority: + /// 1. Exact domain match + /// 2. www. prefix normalization (www.example.com → example.com) + /// 3. Path pattern matching (prefix or regex) + /// 4. Fallback to default origin + /// + /// Returns `(origin_url, certificate_check)` tuple. + #[must_use] + pub fn select_origin(&self, host: &str, path: &str) -> (&str, bool) { + let normalized_host = normalize_domain(host); + + // Try domain index first (fastest lookup) + if let Some(&idx) = self.domain_index.get(normalized_host.as_ref()) { + let route = &self.routes[idx]; + return (&route.origin_url, route.certificate_check); + } + + // Try path patterns + for route in &self.routes { + for pattern in &route.path_patterns { + if pattern.matches(host, path) { + return (&route.origin_url, route.certificate_check); + } + } + } + + // Fallback to default + (&self.default_origin, self.default_certificate_check) + } +} + +/// Normalizes a domain by removing "www." prefix and converting to lowercase. +/// +/// Returns a [`Cow::Borrowed`] slice when no transformation is needed (already +/// lowercase, no "www." prefix), avoiding any allocation on the hot request path. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::backend_router::normalize_domain; +/// +/// assert_eq!(normalize_domain("WWW.EXAMPLE.COM"), "example.com"); +/// assert_eq!(normalize_domain("www.example.com"), "example.com"); +/// assert_eq!(normalize_domain("example.com"), "example.com"); +/// assert_eq!(normalize_domain("sub.example.com"), "sub.example.com"); +/// ``` +#[must_use] +pub fn normalize_domain(domain: &str) -> Cow<'_, str> { + if domain.bytes().any(|b| b.is_ascii_uppercase()) { + let mut lower = domain.to_lowercase(); + while lower.starts_with("www.") { + lower.drain(..4); + } + Cow::Owned(lower) + } else { + Cow::Borrowed(domain.trim_start_matches("www.")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_backend(id: &str, domains: Vec<&str>, origin_url: &str) -> BackendRoutingConfig { + BackendRoutingConfig { + id: Some(id.to_string()), + origin_url: origin_url.to_string(), + domains: domains.into_iter().map(String::from).collect(), + path_patterns: vec![], + certificate_check: true, + } + } + + fn create_test_backend_with_patterns( + id: &str, + origin_url: &str, + patterns: Vec, + ) -> BackendRoutingConfig { + BackendRoutingConfig { + id: Some(id.to_string()), + origin_url: origin_url.to_string(), + domains: vec![], + path_patterns: patterns, + certificate_check: true, + } + } + + #[test] + fn test_exact_domain_match() { + let backends = vec![create_test_backend( + "backend-a", + vec!["site-a.example.com", "site-b.example.com"], + "https://backend-a.example.com", + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, cert_check) = router.select_origin("site-a.example.com", "/"); + assert_eq!(origin, "https://backend-a.example.com"); + assert!(cert_check); + + let (origin, _) = router.select_origin("site-b.example.com", "/article"); + assert_eq!(origin, "https://backend-a.example.com"); + } + + #[test] + fn test_www_prefix_normalization() { + let backends = vec![create_test_backend( + "backend-a", + vec!["site-a.example.com"], + "https://backend-a.example.com", + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("www.site-a.example.com", "/"); + assert_eq!(origin, "https://backend-a.example.com"); + + let (origin, _) = router.select_origin("WWW.SITE-A.EXAMPLE.COM", "/"); + assert_eq!(origin, "https://backend-a.example.com"); + } + + #[test] + fn test_subdomain_no_match() { + let backends = vec![create_test_backend( + "backend-a", + vec!["site-a.example.com"], + "https://backend-a.example.com", + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("trending.site-a.example.com", "/"); + assert_eq!( + origin, "https://default-origin.com", + "trending.site-a.example.com should fall back to default" + ); + } + + #[test] + fn test_path_prefix_matching() { + let backends = vec![create_test_backend_with_patterns( + "backend-b", + "https://backend-b.example.com", + vec![ + PathPattern { + host: Some("site-c.example.com".to_string()), + path_prefix: Some("/.api/".to_string()), + path_regex: None, + }, + PathPattern { + host: Some("site-c.example.com".to_string()), + path_prefix: Some("/my-account".to_string()), + path_regex: None, + }, + ], + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("site-c.example.com", "/.api/users"); + assert_eq!(origin, "https://backend-b.example.com"); + + let (origin, _) = router.select_origin("site-c.example.com", "/my-account/settings"); + assert_eq!(origin, "https://backend-b.example.com"); + + let (origin, _) = router.select_origin("site-c.example.com", "/articles"); + assert_eq!(origin, "https://default-origin.com"); + } + + #[test] + fn test_path_regex_matching() { + let backends = vec![create_test_backend_with_patterns( + "backend-c", + "https://backend-c.example.com", + vec![PathPattern { + host: Some("*".to_string()), + path_prefix: None, + path_regex: Some("^/image/upload/".to_string()), + }], + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = + router.select_origin("site-a.example.com", "/image/upload/v1234/photo.jpg"); + assert_eq!(origin, "https://backend-c.example.com"); + + let (origin, _) = router.select_origin("site-a.example.com", "/images/photo.jpg"); + assert_eq!(origin, "https://default-origin.com"); + } + + #[test] + fn test_wildcard_host_pattern() { + let backends = vec![create_test_backend_with_patterns( + "s3", + "http://s3.amazonaws.com", + vec![PathPattern { + host: None, + path_prefix: Some("/bucket/".to_string()), + path_regex: None, + }], + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("anydomain.com", "/bucket/file.txt"); + assert_eq!(origin, "http://s3.amazonaws.com"); + + let (origin, _) = router.select_origin("another.com", "/bucket/"); + assert_eq!(origin, "http://s3.amazonaws.com"); + } + + #[test] + fn test_fallback_to_default() { + let backends = vec![create_test_backend( + "backend-a", + vec!["site-a.example.com"], + "https://backend-a.example.com", + )]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("unknown.com", "/"); + assert_eq!(origin, "https://default-origin.com"); + } + + #[test] + fn test_multiple_backends_priority() { + let backends = vec![ + create_test_backend( + "backend-a", + vec!["site-a.example.com"], + "https://backend-a.example.com", + ), + create_test_backend( + "backend-b", + vec!["site-c.example.com"], + "https://backend-b.example.com", + ), + ]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("site-a.example.com", "/"); + assert_eq!(origin, "https://backend-a.example.com"); + + let (origin, _) = router.select_origin("site-c.example.com", "/"); + assert_eq!(origin, "https://backend-b.example.com"); + } + + #[test] + fn test_normalize_domain() { + assert_eq!(normalize_domain("example.com"), "example.com"); + assert_eq!(normalize_domain("www.example.com"), "example.com"); + assert_eq!(normalize_domain("WWW.EXAMPLE.COM"), "example.com"); + assert_eq!(normalize_domain("Www.Example.Com"), "example.com"); + assert_eq!(normalize_domain("sub.example.com"), "sub.example.com"); + assert_eq!( + normalize_domain("www.sub.example.com"), + "sub.example.com", + "should only strip leading www" + ); + } + + #[test] + fn test_certificate_check_setting() { + let backends = vec![BackendRoutingConfig { + id: Some("custom".to_string()), + origin_url: "https://custom-origin.com".to_string(), + domains: vec!["custom.com".to_string()], + path_patterns: vec![], + certificate_check: false, + }]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, cert_check) = router.select_origin("custom.com", "/"); + assert_eq!(origin, "https://custom-origin.com"); + assert!( + !cert_check, + "should respect backend-specific certificate_check" + ); + } + + #[test] + fn rejects_invalid_path_regex() { + let backends = vec![BackendRoutingConfig { + id: None, + origin_url: "https://example.com".to_string(), + domains: vec![], + path_patterns: vec![PathPattern { + host: None, + path_prefix: None, + path_regex: Some("[invalid".to_string()), + }], + certificate_check: true, + }]; + + let _err = BackendRouter::new(&backends, "https://default.com".to_string(), true) + .expect_err("should reject invalid path_regex pattern"); + } + + #[test] + fn duplicate_domain_uses_last_backend() { + let backends = vec![ + BackendRoutingConfig { + id: None, + origin_url: "https://first.com".to_string(), + domains: vec!["example.com".to_string()], + path_patterns: vec![], + certificate_check: true, + }, + BackendRoutingConfig { + id: None, + origin_url: "https://second.com".to_string(), + domains: vec!["example.com".to_string()], + path_patterns: vec![], + certificate_check: true, + }, + ]; + + let router = BackendRouter::new(&backends, "https://default.com".to_string(), true) + .expect("should succeed even with duplicate domains"); + + let (url, _) = router.select_origin("example.com", "/"); + assert_eq!( + url, "https://second.com", + "should route to last backend when domain appears twice" + ); + } + + #[test] + fn test_domain_and_path_pattern_precedence() { + let backends = vec![ + create_test_backend( + "backend-a", + vec!["site-c.example.com"], + "https://backend-a.example.com", + ), + create_test_backend_with_patterns( + "backend-b", + "https://backend-b.example.com", + vec![PathPattern { + host: Some("site-c.example.com".to_string()), + path_prefix: Some("/.api/".to_string()), + path_regex: None, + }], + ), + ]; + + let router = BackendRouter::new(&backends, "https://default-origin.com".to_string(), true) + .expect("should build router from valid backends config"); + + let (origin, _) = router.select_origin("site-c.example.com", "/"); + assert_eq!( + origin, "https://backend-a.example.com", + "domain match should take precedence over path pattern" + ); + + let (origin, _) = router.select_origin("site-c.example.com", "/.api/users"); + assert_eq!( + origin, "https://backend-a.example.com", + "domain match should still take precedence for API paths" + ); + } +} diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 44fa108d..fc4d7b3a 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub mod auction; pub mod auction_config_types; pub mod auth; pub mod backend; +pub mod backend_router; pub mod consent; pub mod consent_config; pub mod constants; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 9ec3685d..c5edd953 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12,6 +12,7 @@ use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; use crate::backend::BackendConfig; +use crate::backend_router::BackendRouter; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; use crate::cookies::{expire_ec_cookie, handle_request_cookies, set_ec_cookie}; @@ -364,17 +365,57 @@ pub fn handle_publisher_request( let ec_allowed = allows_ec_creation(&consent_context); log::trace!("Proxy EC ID: {}, ec_allowed: {}", ec_id, ec_allowed); - let backend_name = BackendConfig::from_url( - &settings.publisher.origin_url, - settings.proxy.certificate_check, - )?; - let origin_host = settings.publisher.origin_host(); + let request_path = req.get_path(); + + let router = if settings.backends.is_empty() { + None + } else { + BackendRouter::new( + &settings.backends, + settings.publisher.origin_url.clone(), + settings.proxy.certificate_check, + ) + .map_err(|e| { + log::error!("Failed to build backend router: {:?}", e); + e + }) + .ok() + }; + + let (origin_url, certificate_check) = if let Some(ref router) = router { + let (url, cert_check) = router.select_origin(request_host, request_path); + log::info!( + "Backend routing: host={}, path={} → {}", + request_host, + request_path, + url + ); + (url, cert_check) + } else { + ( + settings.publisher.origin_url.as_str(), + settings.proxy.certificate_check, + ) + }; + + let backend_name = BackendConfig::from_url(origin_url, certificate_check)?; + + let origin_host = url::Url::parse(origin_url) + .ok() + .and_then(|url| { + url.host_str().map(|host| match url.port() { + Some(port) => format!("{}:{}", host, port), + None => host.to_string(), + }) + }) + .unwrap_or_else(|| origin_url.to_string()); log::debug!( "Proxying to dynamic backend: {} (from {})", backend_name, - settings.publisher.origin_url + origin_url ); + // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); req.set_header("host", &origin_host); @@ -423,7 +464,7 @@ pub fn handle_publisher_request( let params = ProcessResponseParams { content_encoding: &content_encoding, origin_host: &origin_host, - origin_url: &settings.publisher.origin_url, + origin_url, request_host, request_scheme, settings, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 78549262..ef52ae8a 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -399,6 +399,38 @@ impl Proxy { } } +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[validate(schema(function = validate_path_pattern))] +pub struct PathPattern { + /// Optional host pattern. If None, matches all hosts (wildcard). + pub host: Option, + /// Optional path prefix to match (e.g., "/.api/"). + pub path_prefix: Option, + /// Optional path regex pattern to match (e.g., "^/image/upload/"). + pub path_regex: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +pub struct BackendRoutingConfig { + /// Origin URL for this backend (e.g., ). + /// The actual Fastly backend will be created dynamically at request time. + #[validate(url)] + pub origin_url: String, + /// List of domains that should route to this backend. + #[serde(default)] + pub domains: Vec, + /// Optional path-based routing patterns. + #[serde(default)] + #[validate(nested)] + pub path_patterns: Vec, + /// Enable TLS certificate verification for this backend. + #[serde(default = "default_certificate_check")] + pub certificate_check: bool, + /// Unique identifier for logging/debugging (optional). + #[serde(default)] + pub id: Option, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] pub struct Settings { #[validate(nested)] @@ -423,6 +455,11 @@ pub struct Settings { pub consent: ConsentConfig, #[serde(default)] pub proxy: Proxy, + /// Optional multi-backend routing configuration. + /// Use `BackendRouter::new()` to create a router from this config. + #[serde(default)] + #[validate(nested)] + pub backends: Vec, } #[allow(unused)] @@ -611,6 +648,16 @@ fn validate_cookie_domain(value: &str) -> Result<(), ValidationError> { Ok(()) } +fn validate_path_pattern(pattern: &PathPattern) -> Result<(), ValidationError> { + if pattern.path_prefix.is_some() && pattern.path_regex.is_some() { + let mut err = ValidationError::new("conflicting_path_pattern"); + err.message = + Some("path_prefix and path_regex are mutually exclusive; set only one".into()); + return Err(err); + } + Ok(()) +} + fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> { if value.ends_with('/') { let mut err = ValidationError::new("trailing_slash"); @@ -1961,4 +2008,76 @@ mod tests { ); } } + + #[test] + fn backend_routing_config_accepts_valid_origin_url() { + let config = BackendRoutingConfig { + origin_url: "https://raven-public.prod.saymedia.com".to_string(), + domains: vec![], + path_patterns: vec![], + certificate_check: true, + id: None, + }; + config + .validate() + .expect("should accept a valid HTTPS origin URL"); + } + + #[test] + fn backend_routing_config_rejects_empty_origin_url() { + let config = BackendRoutingConfig { + origin_url: "".to_string(), + domains: vec![], + path_patterns: vec![], + certificate_check: true, + id: None, + }; + config + .validate() + .expect_err("should reject empty origin_url"); + } + + #[test] + fn backend_routing_config_rejects_bare_hostname() { + let config = BackendRoutingConfig { + origin_url: "raven-public.prod.saymedia.com".to_string(), + domains: vec![], + path_patterns: vec![], + certificate_check: true, + id: None, + }; + config + .validate() + .expect_err("should reject bare hostname without scheme"); + } + + #[test] + fn backend_routing_config_rejects_non_url_string() { + let config = BackendRoutingConfig { + origin_url: "not-a-url".to_string(), + domains: vec![], + path_patterns: vec![], + certificate_check: true, + id: None, + }; + config + .validate() + .expect_err("should reject non-URL string as origin_url"); + } + + #[test] + fn backend_routing_config_accepts_http_origin_url() { + // HTTP origins are valid — internal backends may not use TLS. + // The validate(url) rule accepts any valid URL scheme. + let config = BackendRoutingConfig { + origin_url: "http://internal-service.example.com".to_string(), + domains: vec![], + path_patterns: vec![], + certificate_check: true, + id: None, + }; + config + .validate() + .expect("should accept a valid HTTP origin URL"); + } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d66a820c..52eb3bba 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -117,6 +117,10 @@ export default withMermaid( link: '/guide/proxy-signing', }, { text: 'Collective Sync', link: '/guide/collective-sync' }, + { + text: 'Multi-Backend Routing', + link: '/guide/multi-backend-routing', + }, ], }, { @@ -162,12 +166,6 @@ export default withMermaid( text: 'Framework Support', items: [{ text: 'Next.js', link: '/guide/integrations/nextjs' }], }, - { - text: 'Security', - items: [ - { text: 'DataDome', link: '/guide/integrations/datadome' }, - ], - }, ], }, ], diff --git a/docs/guide/multi-backend-routing.md b/docs/guide/multi-backend-routing.md new file mode 100644 index 00000000..6cd4ec22 --- /dev/null +++ b/docs/guide/multi-backend-routing.md @@ -0,0 +1,184 @@ +# Multi-Backend Routing + +Route incoming requests to different origin servers based on the request's host +domain or URL path. This is useful when a single Trusted Server deployment +serves multiple publishers or products that live on different backend origins. + +## Overview + +By default, all requests proxy to the single `origin_url` defined in +`[publisher]`. Multi-backend routing lets you override that origin on a +per-request basis using two matching strategies: + +- **Domain matching** — exact hostname match (with automatic `www.` stripping) +- **Path pattern matching** — URL prefix or regex match, optionally scoped to a + specific host + +Backends are evaluated in order. Domain matches take priority over path +patterns. Unmatched requests fall back to the default `publisher.origin_url`. + +## Configuration + +Backends are declared as `[[backends]]` entries in `trusted-server.toml` (or +a separate `backends.toml` file merged at build time — see +[Separating Customer Config](#separating-customer-config)). + +### Domain-Based Routing + +Route all traffic for a set of domains to a specific origin: + +```toml +[[backends]] +id = "site-a" +origin_url = "https://origin-a.example.com" +domains = ["site-a.example.com", "www.site-a.example.com"] + +[[backends]] +id = "site-b" +origin_url = "https://origin-b.example.com" +domains = ["site-b.example.com"] +``` + +`www.` prefixes are stripped before matching, so `www.site-a.example.com` and +`site-a.example.com` both resolve to the same backend entry. + +### Path-Based Routing + +Route a subset of paths to a different origin, optionally scoped to a specific +host: + +```toml +[[backends]] +id = "api" +origin_url = "https://api.example.com" + + [[backends.path_patterns]] + host = "site-a.example.com" + path_prefix = "/.api/" + + [[backends.path_patterns]] + host = "site-a.example.com" + path_prefix = "/my-account" +``` + +Use a regular expression when prefix matching is not precise enough: + +```toml +[[backends]] +id = "image-cdn" +origin_url = "https://cdn.example.com" + + [[backends.path_patterns]] + host = "*" + path_regex = "^/image/upload/" +``` + +Setting `host = "*"` (or omitting `host`) matches any hostname. + +### TLS Settings + +Each backend can control whether TLS certificates are verified: + +```toml +[[backends]] +id = "internal" +origin_url = "http://internal.corp" +certificate_check = false # disable TLS verification for internal backends +domains = ["internal.example.com"] +``` + +> **Warning:** Only disable `certificate_check` for internal origins you fully +> control. Disabling it for public origins exposes requests to interception. + +### Reference + +| Field | Type | Default | Description | +| ------------------- | ----------------- | ------- | ------------------------------------------------ | +| `id` | string | — | Optional label used in log output for debugging | +| `origin_url` | string (URL) | — | **Required.** Backend origin URL | +| `domains` | array of strings | `[]` | Hostnames to route to this backend | +| `path_patterns` | array of patterns | `[]` | Path-based routing rules (see below) | +| `certificate_check` | boolean | `true` | Verify TLS certificate on the backend connection | + +**Path pattern fields:** + +| Field | Type | Description | +| ------------- | ------ | ------------------------------------------------------------- | +| `host` | string | Hostname to scope this pattern to. `"*"` or omit for any host | +| `path_prefix` | string | Route requests whose path starts with this string | +| `path_regex` | string | Route requests whose path matches this regular expression | + +Only one of `path_prefix` or `path_regex` should be set per pattern entry. + +## Selection Priority + +For each request Trusted Server picks an origin in this order: + +1. **Domain index** — exact hostname match (after `www.` stripping) +2. **Path patterns** — first matching pattern across all backend entries +3. **Default** — `publisher.origin_url` + +Because domain matches are checked before path patterns, a backend that declares +both `domains` and `path_patterns` will always be reached via its domain match; +its path patterns only fire for hostnames not covered by any domain entry. + +## Separating Customer Config + +For deployments serving many sites, keep domain lists out of +`trusted-server.toml` by placing them in a separate file: + +``` +crates/trusted-server-adapter-fastly/backends.toml +``` + +`build.rs` merges this file into the embedded config at compile time. The file +uses the same `[[backends]]` syntax and is invisible to the shared application +template. + +```toml +# crates/trusted-server-adapter-fastly/backends.toml +# Merged at build time by crates/trusted-server-core/build.rs + +[[backends]] +id = "raven" +origin_url = "https://origin.prod.example.com" +certificate_check = true +domains = [ + "site-a.example.com", + "site-b.example.com", + # ... additional domains +] +``` + +## How It Works + +```mermaid +flowchart TD + req["Incoming Request\nHost: site-a.example.com\nPath: /.api/users"] + + subgraph router["BackendRouter"] + domain["Domain index lookup\nsite-a.example.com → backend-a?"] + path["Path pattern scan\npath_prefix: /.api/ match?"] + fallback["Default origin\npublisher.origin_url"] + end + + origin_a["origin-a.example.com"] + origin_b["api.example.com"] + origin_default["default-origin.example.com"] + + req --> domain + domain -->|"match"| origin_a + domain -->|"no match"| path + path -->|"match"| origin_b + path -->|"no match"| fallback + fallback --> origin_default +``` + +The router is built once per request from the embedded config. Dynamic Fastly +backends are created on demand — the backend name encodes the origin URL, port, +TLS settings, and timeout so that configurations never collide. + +## See Also + +- [Configuration](/guide/configuration) +- [Architecture](/guide/architecture) diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 3b9ec974..87dd423f 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -75,3 +75,20 @@ RUST_LOG=info \ --manifest-path crates/integration-tests/Cargo.toml \ --target "$TARGET" \ -- --include-ignored --test-threads=1 "${TEST_ARGS[@]}" + +echo "==> Building routing WASM binary (test backends: ports 19090-19093)..." +ROUTING_TEST_BACKENDS=1 \ +TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:19090" \ +TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ +TRUSTED_SERVER__SYNTHETIC__SECRET_KEY="integration-test-secret-key" \ +TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + +echo "==> Running routing integration tests..." +ROUTING_WASM_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" \ +RUST_LOG=info \ + cargo test \ + --manifest-path crates/integration-tests/Cargo.toml \ + --target "$TARGET" \ + --test routing \ + -- --test-threads=1 diff --git a/trusted-server.toml b/trusted-server.toml index d9189aaa..2c4f8682 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -154,9 +154,24 @@ rewrite_script = true # Note: this list governs only the first-party proxy redirect chain, not integration # endpoints defined under [integrations.*]. # allowed_domains = [ - # "ad.example.com", - # "*.doubleclick.net", - # "*.googlesyndication.com", +# "ad.example.com", +# "*.doubleclick.net", +# "*.googlesyndication.com", +# ] + +# Multi-backend routing — routes traffic to different origins based on host/path. +# Customer-specific backend lists live in crates/fastly/backends.toml and are +# merged into the embedded config at build time. +# +# Schema reference: +# [[backends]] +# id = "my-backend" # Optional label for logging +# origin_url = "https://origin.example.com" +# certificate_check = true +# domains = ["example.com", "www.example.com"] +# path_patterns = [ +# { host = "example.com", path_prefix = "/api/" }, +# { host = "*", path_regex = "^/images/" }, # ] [auction] From a53f6df66fa705c5cc125fe2ee4f33bce7c1f2a0 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Apr 2026 13:26:51 -0500 Subject: [PATCH 2/2] Remove customer backend config and sensitive routing logs --- .../backends.toml | 176 ------------------ crates/trusted-server-core/build.rs | 3 +- crates/trusted-server-core/src/publisher.rs | 15 +- docs/guide/multi-backend-routing.md | 10 +- trusted-server.toml | 6 +- 5 files changed, 13 insertions(+), 197 deletions(-) delete mode 100644 crates/trusted-server-adapter-fastly/backends.toml diff --git a/crates/trusted-server-adapter-fastly/backends.toml b/crates/trusted-server-adapter-fastly/backends.toml deleted file mode 100644 index fbb04045..00000000 --- a/crates/trusted-server-adapter-fastly/backends.toml +++ /dev/null @@ -1,176 +0,0 @@ -# Arena Group / SayMedia backend routing configuration. -# -# This file is merged into the embedded binary config at build time by -# crates/trusted-server-core/build.rs. It is separate from trusted-server.toml to keep -# customer-specific configuration out of the shared application template. - -[[backends]] -id = "raven" -origin_url = "https://raven-public.prod.saymedia.com" -certificate_check = true -domains = [ - "active.com", - "americansongwriter.com", - "athleticbusiness.com", - "athlonsports.com", - "autoblog.com", - "azbigmedia.com", - "benzinga.com", - "bestproducts.com", - "bicycling.com", - "biography.com", - "bizjournals.com", - "bleacherreport.com", - "blogher.com", - "carsdirect.com", - "catalog.thearenagroup.net", - "cbsnews.com", - "cheatsheet.com", - "chron.com", - "cinemablend.com", - "citybeatnews.com", - "coachmag.co.uk", - "coastalliving.com", - "collegehumor.com", - "countryliving.com", - "ctnewsonline.com", - "dailypress.com", - "delish.com", - "denofgeek.com", - "detroit.cbslocal.com", - "digg.com", - "digitalspy.com", - "diynetwork.com", - "dooyoo.co.uk", - "dualshockers.com", - "eater.com", - "elle.com", - "elledecor.com", - "esquire.com", - "eurweb.com", - "everydayhealth.com", - "fansided.com", - "fightful.com", - "filmschoolrejects.com", - "fitbit.com", - "foodandwine.com", - "fool.com", - "forbes.com", - "freep.com", - "gamerant.com", - "gizmodo.com", - "glam.com", - "goodhousekeeping.com", - "grunge.com", - "health.com", - "healthline.com", - "hercampus.com", - "hgtv.com", - "history.com", - "hollywoodreporter.com", - "housebeautiful.com", - "huffpost.com", - "ibtimes.com", - "ign.com", - "indiewire.com", - "insidehook.com", - "instyle.com", - "investopedia.com", - "io9.com", - "jezebel.com", - "kiplinger.com", - "kotaku.com", - "latimes.com", - "law.com", - "lifehacker.com", - "livestrong.com", - "livescience.com", - "localiq.com", - "looper.com", - "mashable.com", - "mayoclinic.org", - "medicalnewstoday.com", - "menshealth.com", - "mensjournal.com", - "meredith.com", - "metro.co.uk", - "military.com", - "militarytimes.com", - "mlb.com", - "mlive.com", - "mnn.com", - "motorcyclistonline.com", - "msn.com", - "narcity.com", - "nationalreview.com", - "nbcnews.com", - "nerdist.com", - "newsweek.com", - "nj.com", - "nola.com", - "npr.org", - "nypost.com", - "nytimes.com", - "observer.com", - "oregonlive.com", - "outsideonline.com", - "outsports.com", - "oxygen.com", - "parade.com", - "patch.com", - "pcgamer.com", - "pennlive.com", - "people.com", - "petmd.com", - "pgalinks.org", - "philly.com", - "polygon.com", - "popsugar.com", - "prevention.com", - "purewow.com", - "realclearpolitics.com", - "realsimple.com", - "realtor.com", - "refinery29.com", - "rollingstone.com", - "runnersworld.com", - "salon.com", - "scout.com", - "screenrant.com", - "sfgate.com", - "si.com", - "simplemost.com", - "slate.com", - "space.com", - "sporcle.com", - "sportingnews.com", - "sportskeeda.com", - "southernliving.com", - "stltoday.com", - "syracuse.com", - "tampabay.com", - "thedailybeast.com", - "thedailymeal.com", - "thedenverchannel.com", - "thegamer.com", - "thelist.com", - "thepioneerwoman.com", - "thestreet.com", - "thethings.com", - "time.com", - "tmz.com", - "today.com", - "townandcountrymag.com", - "travelandleisure.com", - "usatoday.com", - "variety.com", - "verywellhealth.com", - "vox.com", - "vulture.com", - "washingtonpost.com", - "webmd.com", - "wired.com", - "womansday.com", - "womenshealthmag.com", - "yahoo.com", -] diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index b1cc467d..3a53e79b 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -73,7 +73,8 @@ fn merge_toml() { let mut settings = settings::Settings::from_toml_and_env(&toml_content) .expect("Failed to parse settings at build time"); - // Merge customer-specific backends from crates/fastly/backends.toml, if present + // Merge optional customer-specific backends from + // crates/trusted-server-adapter-fastly/backends.toml, if present. let backends_path = if std::env::var("ROUTING_TEST_BACKENDS").is_ok() { Path::new(TEST_BACKENDS_CONFIG_PATH) } else { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c5edd953..e4944c30 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -383,14 +383,7 @@ pub fn handle_publisher_request( }; let (origin_url, certificate_check) = if let Some(ref router) = router { - let (url, cert_check) = router.select_origin(request_host, request_path); - log::info!( - "Backend routing: host={}, path={} → {}", - request_host, - request_path, - url - ); - (url, cert_check) + router.select_origin(request_host, request_path) } else { ( settings.publisher.origin_url.as_str(), @@ -410,11 +403,7 @@ pub fn handle_publisher_request( }) .unwrap_or_else(|| origin_url.to_string()); - log::debug!( - "Proxying to dynamic backend: {} (from {})", - backend_name, - origin_url - ); + log::debug!("Proxying to dynamic backend: {}", backend_name); // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); diff --git a/docs/guide/multi-backend-routing.md b/docs/guide/multi-backend-routing.md index 6cd4ec22..7aff1b5f 100644 --- a/docs/guide/multi-backend-routing.md +++ b/docs/guide/multi-backend-routing.md @@ -20,7 +20,7 @@ patterns. Unmatched requests fall back to the default `publisher.origin_url`. ## Configuration Backends are declared as `[[backends]]` entries in `trusted-server.toml` (or -a separate `backends.toml` file merged at build time — see +an optional separate `backends.toml` file merged at build time — see [Separating Customer Config](#separating-customer-config)). ### Domain-Based Routing @@ -125,15 +125,15 @@ its path patterns only fire for hostnames not covered by any domain entry. ## Separating Customer Config For deployments serving many sites, keep domain lists out of -`trusted-server.toml` by placing them in a separate file: +`trusted-server.toml` by placing them in an optional separate file: ``` crates/trusted-server-adapter-fastly/backends.toml ``` -`build.rs` merges this file into the embedded config at compile time. The file -uses the same `[[backends]]` syntax and is invisible to the shared application -template. +`build.rs` merges this file into the embedded config at compile time when the +file exists. Shared builds do not require it. The file uses the same +`[[backends]]` syntax and is invisible to the shared application template. ```toml # crates/trusted-server-adapter-fastly/backends.toml diff --git a/trusted-server.toml b/trusted-server.toml index 2c4f8682..7939eca4 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -160,8 +160,10 @@ rewrite_script = true # ] # Multi-backend routing — routes traffic to different origins based on host/path. -# Customer-specific backend lists live in crates/fastly/backends.toml and are -# merged into the embedded config at build time. +# Shared builds work without any separate backends file. +# Customer-specific backend lists can be supplied via +# crates/trusted-server-adapter-fastly/backends.toml and are merged into the +# embedded config at build time when that optional file exists. # # Schema reference: # [[backends]]