use std::time::Duration;
use axum::body::Body;
use axum::extract::Request;
use axum::response::Response;
use bytes::Bytes;
use http::{header, HeaderMap, Method, StatusCode};
use tracing::{trace, warn};
use crate::model::DistributionConfig;
use crate::state::{CloudFrontAccounts, SharedCloudFrontState, StoredDistribution};
const ENV_DISABLE: &str = "FAKECLOUD_CLOUDFRONT_DISABLE_DATAPLANE";
pub fn dataplane_enabled() -> bool {
!matches!(
std::env::var(ENV_DISABLE).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
)
}
pub struct CloudFrontDataPlane {
state: SharedCloudFrontState,
upstream: reqwest::Client,
s3_endpoint: String,
enabled: bool,
}
impl CloudFrontDataPlane {
pub fn new(state: SharedCloudFrontState, server_port: u16) -> std::sync::Arc<Self> {
let mut enabled = dataplane_enabled();
let upstream = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!(
"CloudFront data plane: failed to build reqwest client: {e}; serving disabled"
);
enabled = false;
reqwest::Client::new()
});
std::sync::Arc::new(Self {
state,
upstream,
s3_endpoint: format!("127.0.0.1:{server_port}"),
enabled,
})
}
pub async fn serve(&self, req: Request<Body>) -> Result<Response, Request<Body>> {
if !self.enabled {
return Err(req);
}
let host = req
.headers()
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| req.uri().host().map(|h| h.to_string()));
let Some(host) = host else {
return Err(req);
};
let matched: Option<Option<RouteResolution>> = {
let accs = self.state.read();
find_distribution_by_host(&accs, &host)
.map(|d| resolve_route(&d.config, req.uri().path(), &self.s3_endpoint))
};
let Some(route_opt) = matched else {
return Err(req);
};
let (parts, body) = req.into_parts();
let max_body = fakecloud_core::dispatch::max_request_body_bytes();
let body_bytes = match axum::body::to_bytes(body, max_body).await {
Ok(b) => b,
Err(_) => {
return Ok(canned(
StatusCode::PAYLOAD_TOO_LARGE,
"viewer request body too large",
))
}
};
let Some(route) = route_opt else {
return Ok(canned(
StatusCode::BAD_GATEWAY,
"distribution has no matching origin",
));
};
let path_and_query = parts
.uri
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
let url = format!("{}{path_and_query}", route.upstream.url_base);
trace!(%host, path = %parts.uri.path(), origin = %route.upstream.host_header, "CloudFront data plane: proxying");
let resp = self
.fetch_origin(
&parts.method,
&url,
&route.upstream.host_header,
&parts.headers,
&body_bytes,
)
.await;
if let Some(rule) = match_error_rule(&route.error_rules, resp.status().as_u16()) {
let origin_status = resp.status();
let url = format!("{}{}", route.default_upstream.url_base, rule.page_path);
let err_resp = self
.fetch_origin(
&Method::GET,
&url,
&route.default_upstream.host_header,
&HeaderMap::new(),
&Bytes::new(),
)
.await;
if err_resp.status().is_success() {
let mut err_resp = err_resp;
let final_status = rule
.response_code
.and_then(|c| StatusCode::from_u16(c).ok())
.unwrap_or(origin_status);
*err_resp.status_mut() = final_status;
return Ok(err_resp);
}
return Ok(resp);
}
Ok(resp)
}
async fn fetch_origin(
&self,
method: &Method,
url: &str,
host_header: &str,
req_headers: &HeaderMap,
body: &Bytes,
) -> Response {
let mut rb = self.upstream.request(reqwest_method(method), url);
for (k, v) in req_headers.iter() {
let n = k.as_str();
if is_hop_by_hop(n) || n.eq_ignore_ascii_case("host") {
continue;
}
rb = rb.header(k.as_str(), v.as_bytes());
}
rb = rb.header("host", host_header);
if !body.is_empty() {
rb = rb.body(body.to_vec());
}
match rb.send().await {
Ok(up) => {
let status = up.status();
let headers = up.headers().clone();
let bytes = up.bytes().await.unwrap_or_default();
let mut builder = Response::builder().status(status);
for (k, v) in headers.iter() {
if !is_hop_by_hop(k.as_str()) {
builder = builder.header(k, v);
}
}
builder
.body(Body::from(bytes))
.unwrap_or_else(|_| canned(StatusCode::BAD_GATEWAY, "invalid origin response"))
}
Err(e) => canned(StatusCode::BAD_GATEWAY, &format!("origin error: {e}")),
}
}
}
pub(crate) fn find_distribution_by_host<'a>(
accs: &'a CloudFrontAccounts,
host: &str,
) -> Option<&'a StoredDistribution> {
let host = host.split(':').next().unwrap_or(host).trim();
if host.is_empty() {
return None;
}
accs.all_distributions()
.map(|(_, d)| d)
.filter(|d| d.config.enabled)
.find(|d| {
d.domain_name.eq_ignore_ascii_case(host)
|| d.config
.aliases
.as_ref()
.and_then(|a| a.items.as_ref())
.is_some_and(|it| it.cname.iter().any(|c| c.eq_ignore_ascii_case(host)))
})
}
struct RouteResolution {
upstream: UpstreamTarget,
default_upstream: UpstreamTarget,
error_rules: Vec<ErrorRule>,
}
#[derive(Clone)]
struct UpstreamTarget {
url_base: String,
host_header: String,
}
#[derive(Clone)]
struct ErrorRule {
error_code: u16,
page_path: String,
response_code: Option<u16>,
}
fn resolve_route(
cfg: &DistributionConfig,
path: &str,
s3_endpoint: &str,
) -> Option<RouteResolution> {
let items = cfg.origins.items.as_ref()?;
let target = select_target_origin(cfg, path);
let upstream = items
.origin
.iter()
.find(|o| o.id == target)
.map(|o| upstream_for(o, s3_endpoint))?;
let default_target = cfg.default_cache_behavior.target_origin_id.as_str();
let default_upstream = items
.origin
.iter()
.find(|o| o.id == default_target)
.map(|o| upstream_for(o, s3_endpoint))
.unwrap_or_else(|| upstream.clone());
let error_rules = cfg
.custom_error_responses
.as_ref()
.and_then(|c| c.items.as_ref())
.map(|it| {
it.custom_error_response
.iter()
.filter_map(|r| {
r.response_page_path.as_ref().map(|p| ErrorRule {
error_code: r.error_code as u16,
page_path: p.clone(),
response_code: r.response_code.as_ref().and_then(|s| s.parse().ok()),
})
})
.collect()
})
.unwrap_or_default();
Some(RouteResolution {
upstream,
default_upstream,
error_rules,
})
}
fn match_error_rule(rules: &[ErrorRule], status: u16) -> Option<ErrorRule> {
rules.iter().find(|r| r.error_code == status).cloned()
}
fn select_target_origin<'a>(cfg: &'a DistributionConfig, path: &str) -> &'a str {
if let Some(cbs) = &cfg.cache_behaviors {
if let Some(items) = &cbs.items {
for cb in &items.cache_behavior {
if path_pattern_matches(&cb.path_pattern, path) {
return &cb.target_origin_id;
}
}
}
}
&cfg.default_cache_behavior.target_origin_id
}
fn is_s3_website(domain: &str) -> bool {
domain.contains(".s3-website") && domain.ends_with(".amazonaws.com")
}
fn upstream_for(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTarget {
let domain = &origin.domain_name;
if is_s3_website(domain) {
return UpstreamTarget {
url_base: format!("http://{s3_endpoint}"),
host_header: domain.clone(),
};
}
if let Some(cfg) = &origin.custom_origin_config {
let https = cfg
.origin_protocol_policy
.eq_ignore_ascii_case("https-only");
let (scheme, port) = if https {
("https", cfg.https_port)
} else {
("http", cfg.http_port)
};
let has_explicit_port = domain.rsplit(':').next().is_some_and(|s| {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && domain.contains(':')
});
let default_port = (scheme == "http" && port == 80) || (scheme == "https" && port == 443);
let authority = if has_explicit_port || port <= 0 || default_port {
domain.clone()
} else {
format!("{domain}:{port}")
};
return UpstreamTarget {
url_base: format!("{scheme}://{authority}"),
host_header: domain.clone(),
};
}
UpstreamTarget {
url_base: format!("http://{domain}"),
host_header: domain.clone(),
}
}
fn path_pattern_matches(pattern: &str, path: &str) -> bool {
let pat = pattern.trim_start_matches('/');
let p = path.trim_start_matches('/');
glob_match(pat.as_bytes(), p.as_bytes())
}
fn glob_match(pat: &[u8], text: &[u8]) -> bool {
let (mut p, mut t) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while t < text.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == b'*' {
star = Some(p);
mark = t;
p += 1;
} else if let Some(sp) = star {
p = sp + 1;
mark += 1;
t = mark;
} else {
return false;
}
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
fn canned(status: StatusCode, msg: &str) -> Response {
Response::builder()
.status(status)
.body(Body::from(msg.to_string()))
.expect("canned response builds")
}
fn reqwest_method(m: &Method) -> reqwest::Method {
reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET)
}
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
fn is_hop_by_hop(name: &str) -> bool {
HOP_BY_HOP.iter().any(|&h| h.eq_ignore_ascii_case(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AliasItems, Aliases, CustomOriginConfig, Origin};
use crate::state::StoredDistribution;
use chrono::Utc;
fn origin(domain: &str, custom: Option<CustomOriginConfig>) -> Origin {
Origin {
id: "o".into(),
domain_name: domain.into(),
custom_origin_config: custom,
..Default::default()
}
}
fn custom(policy: &str, http_port: i32, https_port: i32) -> CustomOriginConfig {
CustomOriginConfig {
http_port,
https_port,
origin_protocol_policy: policy.into(),
..Default::default()
}
}
fn dist(id: &str, enabled: bool, aliases: &[&str]) -> StoredDistribution {
let mut config = DistributionConfig {
enabled,
..Default::default()
};
if !aliases.is_empty() {
config.aliases = Some(Aliases {
quantity: aliases.len() as i32,
items: Some(AliasItems {
cname: aliases.iter().map(|s| s.to_string()).collect(),
}),
});
}
StoredDistribution {
id: id.to_string(),
arn: format!("arn:aws:cloudfront::123456789012:distribution/{id}"),
status: "Deployed".into(),
last_modified_time: Utc::now(),
domain_name: format!("{}.cloudfront.net", id.to_lowercase()),
in_progress_invalidation_batches: 0,
etag: "E1".into(),
config,
}
}
fn accounts_with(dists: Vec<StoredDistribution>) -> CloudFrontAccounts {
let mut accs = CloudFrontAccounts::new();
let acct = accs.entry("123456789012");
for d in dists {
acct.distributions.insert(d.id.clone(), d);
}
accs
}
#[test]
fn find_by_domain_name() {
let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
let found = find_distribution_by_host(&accs, "e1abc.cloudfront.net").unwrap();
assert_eq!(found.id, "E1ABC");
}
#[test]
fn find_strips_port_and_is_case_insensitive() {
let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
assert!(find_distribution_by_host(&accs, "E1ABC.CloudFront.net:4566").is_some());
}
#[test]
fn find_by_alias_cname() {
let accs = accounts_with(vec![dist("E1ABC", true, &["cdn.example.com"])]);
let found = find_distribution_by_host(&accs, "cdn.example.com").unwrap();
assert_eq!(found.id, "E1ABC");
}
#[test]
fn disabled_distribution_is_not_matched() {
let accs = accounts_with(vec![dist("E1ABC", false, &["cdn.example.com"])]);
assert!(find_distribution_by_host(&accs, "e1abc.cloudfront.net").is_none());
assert!(find_distribution_by_host(&accs, "cdn.example.com").is_none());
}
#[test]
fn unknown_host_and_empty_host_return_none() {
let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
assert!(find_distribution_by_host(&accs, "s3.amazonaws.com").is_none());
assert!(find_distribution_by_host(&accs, "").is_none());
assert!(find_distribution_by_host(&accs, ":4566").is_none());
}
#[test]
fn s3_website_detection_is_precise() {
assert!(is_s3_website("b.s3-website-us-east-1.amazonaws.com"));
assert!(is_s3_website("b.s3-website.us-east-1.amazonaws.com"));
assert!(!is_s3_website("my.s3-website.example.com"));
assert!(!is_s3_website("api.example.com"));
assert!(!is_s3_website("127.0.0.1:8080"));
}
#[test]
fn s3_website_origin_routes_to_local_port() {
let up = upstream_for(
&origin("b.s3-website-us-east-1.amazonaws.com", None),
"127.0.0.1:4566",
);
assert_eq!(up.url_base, "http://127.0.0.1:4566");
assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com");
}
#[test]
fn https_only_custom_origin_uses_https_and_port() {
let up = upstream_for(
&origin("api.example.com", Some(custom("https-only", 80, 8443))),
"127.0.0.1:4566",
);
assert_eq!(up.url_base, "https://api.example.com:8443");
}
#[test]
fn http_custom_origin_default_port_omits_port() {
let up = upstream_for(
&origin("api.example.com", Some(custom("http-only", 80, 443))),
"127.0.0.1:4566",
);
assert_eq!(up.url_base, "http://api.example.com");
}
#[test]
fn explicit_port_in_domain_wins_over_config_port() {
let up = upstream_for(
&origin("127.0.0.1:52111", Some(custom("http-only", 80, 443))),
"127.0.0.1:4566",
);
assert_eq!(up.url_base, "http://127.0.0.1:52111");
}
#[test]
fn bare_origin_defaults_to_http() {
let up = upstream_for(&origin("origin.internal", None), "127.0.0.1:4566");
assert_eq!(up.url_base, "http://origin.internal");
}
}