1use std::time::Duration;
27
28use axum::body::Body;
29use axum::extract::Request;
30use axum::response::Response;
31use bytes::Bytes;
32use http::{header, HeaderMap, Method, StatusCode};
33use tracing::{trace, warn};
34
35use crate::model::DistributionConfig;
36use crate::state::{CloudFrontAccounts, SharedCloudFrontState, StoredDistribution};
37
38const ENV_DISABLE: &str = "FAKECLOUD_CLOUDFRONT_DISABLE_DATAPLANE";
39
40pub fn dataplane_enabled() -> bool {
45 !matches!(
46 std::env::var(ENV_DISABLE).as_deref(),
47 Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
48 )
49}
50
51pub struct CloudFrontDataPlane {
55 state: SharedCloudFrontState,
56 upstream: reqwest::Client,
58 s3_endpoint: String,
63 enabled: bool,
66}
67
68impl CloudFrontDataPlane {
69 pub fn new(state: SharedCloudFrontState, server_port: u16) -> std::sync::Arc<Self> {
76 let mut enabled = dataplane_enabled();
77 let upstream = reqwest::Client::builder()
78 .danger_accept_invalid_certs(true)
79 .redirect(reqwest::redirect::Policy::none())
80 .timeout(Duration::from_secs(30))
81 .build()
82 .unwrap_or_else(|e| {
83 warn!(
84 "CloudFront data plane: failed to build reqwest client: {e}; serving disabled"
85 );
86 enabled = false;
90 reqwest::Client::new()
91 });
92 std::sync::Arc::new(Self {
93 state,
94 upstream,
95 s3_endpoint: format!("127.0.0.1:{server_port}"),
96 enabled,
97 })
98 }
99
100 pub async fn serve(&self, req: Request<Body>) -> Result<Response, Request<Body>> {
111 if !self.enabled {
112 return Err(req);
113 }
114 let host = req
118 .headers()
119 .get(header::HOST)
120 .and_then(|v| v.to_str().ok())
121 .map(|s| s.to_string())
122 .or_else(|| req.uri().host().map(|h| h.to_string()));
123 let Some(host) = host else {
124 return Err(req);
125 };
126
127 let matched: Option<Option<RouteResolution>> = {
132 let accs = self.state.read();
133 find_distribution_by_host(&accs, &host)
134 .map(|d| resolve_route(&d.config, req.uri().path(), &self.s3_endpoint))
135 };
136 let Some(route_opt) = matched else {
137 return Err(req);
138 };
139
140 let (parts, body) = req.into_parts();
142 let max_body = fakecloud_core::dispatch::max_request_body_bytes();
146 let body_bytes = match axum::body::to_bytes(body, max_body).await {
147 Ok(b) => b,
148 Err(_) => {
149 return Ok(canned(
150 StatusCode::PAYLOAD_TOO_LARGE,
151 "viewer request body too large",
152 ))
153 }
154 };
155 let Some(route) = route_opt else {
156 return Ok(canned(
157 StatusCode::BAD_GATEWAY,
158 "distribution has no matching origin",
159 ));
160 };
161
162 let path_and_query = parts
163 .uri
164 .path_and_query()
165 .map(|p| p.as_str())
166 .unwrap_or("/")
167 .to_string();
168 let url = format!("{}{path_and_query}", route.upstream.url_base);
169 trace!(%host, path = %parts.uri.path(), origin = %route.upstream.host_header, "CloudFront data plane: proxying");
170 let resp = self
171 .fetch_origin(
172 &parts.method,
173 &url,
174 &route.upstream.host_header,
175 &parts.headers,
176 &body_bytes,
177 )
178 .await;
179
180 if let Some(rule) = match_error_rule(&route.error_rules, resp.status().as_u16()) {
185 let origin_status = resp.status();
186 let url = format!("{}{}", route.default_upstream.url_base, rule.page_path);
187 let err_resp = self
188 .fetch_origin(
189 &Method::GET,
190 &url,
191 &route.default_upstream.host_header,
192 &HeaderMap::new(),
193 &Bytes::new(),
194 )
195 .await;
196 if err_resp.status().is_success() {
202 let mut err_resp = err_resp;
203 let final_status = rule
206 .response_code
207 .and_then(|c| StatusCode::from_u16(c).ok())
208 .unwrap_or(origin_status);
209 *err_resp.status_mut() = final_status;
210 return Ok(err_resp);
211 }
212 return Ok(resp);
213 }
214 Ok(resp)
215 }
216
217 async fn fetch_origin(
219 &self,
220 method: &Method,
221 url: &str,
222 host_header: &str,
223 req_headers: &HeaderMap,
224 body: &Bytes,
225 ) -> Response {
226 let mut rb = self.upstream.request(reqwest_method(method), url);
227 for (k, v) in req_headers.iter() {
228 let n = k.as_str();
229 if is_hop_by_hop(n) || n.eq_ignore_ascii_case("host") {
230 continue;
231 }
232 rb = rb.header(k.as_str(), v.as_bytes());
233 }
234 rb = rb.header("host", host_header);
235 if !body.is_empty() {
236 rb = rb.body(body.to_vec());
237 }
238 match rb.send().await {
239 Ok(up) => {
240 let status = up.status();
241 let headers = up.headers().clone();
242 let bytes = up.bytes().await.unwrap_or_default();
243 let mut builder = Response::builder().status(status);
244 for (k, v) in headers.iter() {
245 if !is_hop_by_hop(k.as_str()) {
246 builder = builder.header(k, v);
247 }
248 }
249 builder
250 .body(Body::from(bytes))
251 .unwrap_or_else(|_| canned(StatusCode::BAD_GATEWAY, "invalid origin response"))
252 }
253 Err(e) => canned(StatusCode::BAD_GATEWAY, &format!("origin error: {e}")),
254 }
255 }
256}
257
258pub(crate) fn find_distribution_by_host<'a>(
264 accs: &'a CloudFrontAccounts,
265 host: &str,
266) -> Option<&'a StoredDistribution> {
267 let host = host.split(':').next().unwrap_or(host).trim();
268 if host.is_empty() {
269 return None;
270 }
271 accs.all_distributions()
272 .map(|(_, d)| d)
273 .filter(|d| d.config.enabled)
274 .find(|d| {
275 d.domain_name.eq_ignore_ascii_case(host)
276 || d.config
277 .aliases
278 .as_ref()
279 .and_then(|a| a.items.as_ref())
280 .is_some_and(|it| it.cname.iter().any(|c| c.eq_ignore_ascii_case(host)))
281 })
282}
283
284struct RouteResolution {
286 upstream: UpstreamTarget,
288 default_upstream: UpstreamTarget,
291 error_rules: Vec<ErrorRule>,
293}
294
295#[derive(Clone)]
298struct UpstreamTarget {
299 url_base: String,
301 host_header: String,
303}
304
305#[derive(Clone)]
306struct ErrorRule {
307 error_code: u16,
308 page_path: String,
309 response_code: Option<u16>,
310}
311
312fn resolve_route(
315 cfg: &DistributionConfig,
316 path: &str,
317 s3_endpoint: &str,
318) -> Option<RouteResolution> {
319 let items = cfg.origins.items.as_ref()?;
320 let target = select_target_origin(cfg, path);
321 let upstream = items
322 .origin
323 .iter()
324 .find(|o| o.id == target)
325 .map(|o| upstream_for(o, s3_endpoint))?;
326 let default_target = cfg.default_cache_behavior.target_origin_id.as_str();
327 let default_upstream = items
328 .origin
329 .iter()
330 .find(|o| o.id == default_target)
331 .map(|o| upstream_for(o, s3_endpoint))
332 .unwrap_or_else(|| upstream.clone());
333 let error_rules = cfg
334 .custom_error_responses
335 .as_ref()
336 .and_then(|c| c.items.as_ref())
337 .map(|it| {
338 it.custom_error_response
339 .iter()
340 .filter_map(|r| {
341 r.response_page_path.as_ref().map(|p| ErrorRule {
342 error_code: r.error_code as u16,
343 page_path: p.clone(),
344 response_code: r.response_code.as_ref().and_then(|s| s.parse().ok()),
345 })
346 })
347 .collect()
348 })
349 .unwrap_or_default();
350 Some(RouteResolution {
351 upstream,
352 default_upstream,
353 error_rules,
354 })
355}
356
357fn match_error_rule(rules: &[ErrorRule], status: u16) -> Option<ErrorRule> {
359 rules.iter().find(|r| r.error_code == status).cloned()
360}
361
362fn select_target_origin<'a>(cfg: &'a DistributionConfig, path: &str) -> &'a str {
363 if let Some(cbs) = &cfg.cache_behaviors {
364 if let Some(items) = &cbs.items {
365 for cb in &items.cache_behavior {
366 if path_pattern_matches(&cb.path_pattern, path) {
367 return &cb.target_origin_id;
368 }
369 }
370 }
371 }
372 &cfg.default_cache_behavior.target_origin_id
373}
374
375fn is_s3_website(domain: &str) -> bool {
381 domain.contains(".s3-website") && domain.ends_with(".amazonaws.com")
382}
383
384fn upstream_for(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTarget {
394 let domain = &origin.domain_name;
395 if is_s3_website(domain) {
396 return UpstreamTarget {
397 url_base: format!("http://{s3_endpoint}"),
398 host_header: domain.clone(),
399 };
400 }
401 if let Some(cfg) = &origin.custom_origin_config {
402 let https = cfg
403 .origin_protocol_policy
404 .eq_ignore_ascii_case("https-only");
405 let (scheme, port) = if https {
406 ("https", cfg.https_port)
407 } else {
408 ("http", cfg.http_port)
409 };
410 let has_explicit_port = domain.rsplit(':').next().is_some_and(|s| {
413 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && domain.contains(':')
414 });
415 let default_port = (scheme == "http" && port == 80) || (scheme == "https" && port == 443);
416 let authority = if has_explicit_port || port <= 0 || default_port {
417 domain.clone()
418 } else {
419 format!("{domain}:{port}")
420 };
421 return UpstreamTarget {
422 url_base: format!("{scheme}://{authority}"),
423 host_header: domain.clone(),
424 };
425 }
426 UpstreamTarget {
427 url_base: format!("http://{domain}"),
428 host_header: domain.clone(),
429 }
430}
431
432fn path_pattern_matches(pattern: &str, path: &str) -> bool {
437 let pat = pattern.trim_start_matches('/');
438 let p = path.trim_start_matches('/');
439 glob_match(pat.as_bytes(), p.as_bytes())
440}
441
442fn glob_match(pat: &[u8], text: &[u8]) -> bool {
443 let (mut p, mut t) = (0usize, 0usize);
445 let (mut star, mut mark) = (None, 0usize);
446 while t < text.len() {
447 if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
448 p += 1;
449 t += 1;
450 } else if p < pat.len() && pat[p] == b'*' {
451 star = Some(p);
452 mark = t;
453 p += 1;
454 } else if let Some(sp) = star {
455 p = sp + 1;
456 mark += 1;
457 t = mark;
458 } else {
459 return false;
460 }
461 }
462 while p < pat.len() && pat[p] == b'*' {
463 p += 1;
464 }
465 p == pat.len()
466}
467
468fn canned(status: StatusCode, msg: &str) -> Response {
469 Response::builder()
470 .status(status)
471 .body(Body::from(msg.to_string()))
472 .expect("canned response builds")
473}
474
475fn reqwest_method(m: &Method) -> reqwest::Method {
476 reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET)
477}
478
479const HOP_BY_HOP: &[&str] = &[
480 "connection",
481 "keep-alive",
482 "proxy-authenticate",
483 "proxy-authorization",
484 "te",
485 "trailer",
486 "transfer-encoding",
487 "upgrade",
488];
489
490fn is_hop_by_hop(name: &str) -> bool {
491 HOP_BY_HOP.iter().any(|&h| h.eq_ignore_ascii_case(name))
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::model::{AliasItems, Aliases, CustomOriginConfig, Origin};
498 use crate::state::StoredDistribution;
499 use chrono::Utc;
500
501 fn origin(domain: &str, custom: Option<CustomOriginConfig>) -> Origin {
502 Origin {
503 id: "o".into(),
504 domain_name: domain.into(),
505 custom_origin_config: custom,
506 ..Default::default()
507 }
508 }
509
510 fn custom(policy: &str, http_port: i32, https_port: i32) -> CustomOriginConfig {
511 CustomOriginConfig {
512 http_port,
513 https_port,
514 origin_protocol_policy: policy.into(),
515 ..Default::default()
516 }
517 }
518
519 fn dist(id: &str, enabled: bool, aliases: &[&str]) -> StoredDistribution {
520 let mut config = DistributionConfig {
521 enabled,
522 ..Default::default()
523 };
524 if !aliases.is_empty() {
525 config.aliases = Some(Aliases {
526 quantity: aliases.len() as i32,
527 items: Some(AliasItems {
528 cname: aliases.iter().map(|s| s.to_string()).collect(),
529 }),
530 });
531 }
532 StoredDistribution {
533 id: id.to_string(),
534 arn: format!("arn:aws:cloudfront::123456789012:distribution/{id}"),
535 status: "Deployed".into(),
536 last_modified_time: Utc::now(),
537 domain_name: format!("{}.cloudfront.net", id.to_lowercase()),
538 in_progress_invalidation_batches: 0,
539 etag: "E1".into(),
540 config,
541 }
542 }
543
544 fn accounts_with(dists: Vec<StoredDistribution>) -> CloudFrontAccounts {
545 let mut accs = CloudFrontAccounts::new();
546 let acct = accs.entry("123456789012");
547 for d in dists {
548 acct.distributions.insert(d.id.clone(), d);
549 }
550 accs
551 }
552
553 #[test]
554 fn find_by_domain_name() {
555 let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
556 let found = find_distribution_by_host(&accs, "e1abc.cloudfront.net").unwrap();
557 assert_eq!(found.id, "E1ABC");
558 }
559
560 #[test]
561 fn find_strips_port_and_is_case_insensitive() {
562 let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
563 assert!(find_distribution_by_host(&accs, "E1ABC.CloudFront.net:4566").is_some());
564 }
565
566 #[test]
567 fn find_by_alias_cname() {
568 let accs = accounts_with(vec![dist("E1ABC", true, &["cdn.example.com"])]);
569 let found = find_distribution_by_host(&accs, "cdn.example.com").unwrap();
570 assert_eq!(found.id, "E1ABC");
571 }
572
573 #[test]
574 fn disabled_distribution_is_not_matched() {
575 let accs = accounts_with(vec![dist("E1ABC", false, &["cdn.example.com"])]);
576 assert!(find_distribution_by_host(&accs, "e1abc.cloudfront.net").is_none());
577 assert!(find_distribution_by_host(&accs, "cdn.example.com").is_none());
578 }
579
580 #[test]
581 fn unknown_host_and_empty_host_return_none() {
582 let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
583 assert!(find_distribution_by_host(&accs, "s3.amazonaws.com").is_none());
584 assert!(find_distribution_by_host(&accs, "").is_none());
585 assert!(find_distribution_by_host(&accs, ":4566").is_none());
586 }
587
588 #[test]
589 fn s3_website_detection_is_precise() {
590 assert!(is_s3_website("b.s3-website-us-east-1.amazonaws.com"));
591 assert!(is_s3_website("b.s3-website.us-east-1.amazonaws.com"));
592 assert!(!is_s3_website("my.s3-website.example.com"));
594 assert!(!is_s3_website("api.example.com"));
595 assert!(!is_s3_website("127.0.0.1:8080"));
596 }
597
598 #[test]
599 fn s3_website_origin_routes_to_local_port() {
600 let up = upstream_for(
601 &origin("b.s3-website-us-east-1.amazonaws.com", None),
602 "127.0.0.1:4566",
603 );
604 assert_eq!(up.url_base, "http://127.0.0.1:4566");
605 assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com");
606 }
607
608 #[test]
609 fn https_only_custom_origin_uses_https_and_port() {
610 let up = upstream_for(
611 &origin("api.example.com", Some(custom("https-only", 80, 8443))),
612 "127.0.0.1:4566",
613 );
614 assert_eq!(up.url_base, "https://api.example.com:8443");
615 }
616
617 #[test]
618 fn http_custom_origin_default_port_omits_port() {
619 let up = upstream_for(
620 &origin("api.example.com", Some(custom("http-only", 80, 443))),
621 "127.0.0.1:4566",
622 );
623 assert_eq!(up.url_base, "http://api.example.com");
624 }
625
626 #[test]
627 fn explicit_port_in_domain_wins_over_config_port() {
628 let up = upstream_for(
631 &origin("127.0.0.1:52111", Some(custom("http-only", 80, 443))),
632 "127.0.0.1:4566",
633 );
634 assert_eq!(up.url_base, "http://127.0.0.1:52111");
635 }
636
637 #[test]
638 fn bare_origin_defaults_to_http() {
639 let up = upstream_for(&origin("origin.internal", None), "127.0.0.1:4566");
640 assert_eq!(up.url_base, "http://origin.internal");
641 }
642}