1use crate::progress::Progress;
11use anyhow::{anyhow, bail, Context, Result};
12use std::process::Command;
13
14const CACHE_DISABLED: &str = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
16const CACHING_OPTIMIZED: &str = "658327ea-f89d-4fab-a63d-7e88639e58f6";
17const ALL_VIEWER_EXCEPT_HOST: &str = "b689b0a8-53d0-40ab-baf2-68738e2966ac";
20
21const PAGE_REWRITE_JS: &str = r#"function handler(event) {
29 var req = event.request;
30 if (req.uri.startsWith('/d/')) { req.uri = '/d/p'; }
31 if (req.uri.startsWith('/r/')) { req.uri = '/r/p'; }
32 return req;
33}
34"#;
35
36pub struct Front {
38 pub distribution_id: String,
39 pub domain: String,
40}
41
42pub fn front_gate(
45 profile: Option<&str>,
46 account: &str,
47 origin_host: &str,
48 existing_distribution: Option<&str>,
49 progress: &dyn Progress,
50) -> Result<Front> {
51 if let Some(dist_id) = existing_distribution {
52 progress.step("cloudfront (reuse)");
53 let page_fn_arn = ensure_page_function(profile, account)?;
65
66 let out = aws(
73 profile,
74 &[
75 "cloudfront",
76 "get-distribution-config",
77 "--id",
78 dist_id,
79 "--output",
80 "json",
81 ],
82 )?;
83 if !out.status.success() {
84 bail!(
85 "get-distribution-config: {}",
86 String::from_utf8_lossy(&out.stderr).trim()
87 );
88 }
89 let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
90 let etag = v["ETag"]
91 .as_str()
92 .ok_or_else(|| anyhow!("no ETag"))?
93 .to_string();
94 let mut cfg = v["DistributionConfig"].clone();
95 merge_request_behaviors(&mut cfg, &page_fn_arn);
96
97 let tmp = std::env::temp_dir().join(format!("dove-dist-{dist_id}.json"));
98 std::fs::write(&tmp, cfg.to_string())?;
99 let cfg_arg = format!("file://{}", tmp.display());
100 let out = aws(
101 profile,
102 &[
103 "cloudfront",
104 "update-distribution",
105 "--id",
106 dist_id,
107 "--distribution-config",
108 &cfg_arg,
109 "--if-match",
110 &etag,
111 "--output",
112 "json",
113 ],
114 );
115 let _ = std::fs::remove_file(&tmp);
116 let out = out?;
117 if !out.status.success() {
118 bail!(
119 "update-distribution: {}",
120 String::from_utf8_lossy(&out.stderr).trim()
121 );
122 }
123
124 let domain = distribution_domain(profile, dist_id)?;
125 progress.done("cloudfront (reuse)");
126 return Ok(Front {
127 distribution_id: dist_id.to_string(),
128 domain,
129 });
130 }
131 progress.step("page cache function");
134 let fn_arn = ensure_page_function(profile, account);
135 if fn_arn.is_ok() {
136 progress.done("page cache function");
137 }
138 let fn_arn = fn_arn?;
139
140 progress.step("cloudfront distribution");
141 let front = create_distribution(profile, origin_host, &fn_arn);
142 if front.is_ok() {
143 progress.done("cloudfront distribution");
144 }
145 front
146}
147
148fn ensure_page_function(profile: Option<&str>, account: &str) -> Result<String> {
151 let arn = format!("arn:aws:cloudfront::{account}:function/dove-page-rewrite");
152 let tmp = std::env::temp_dir().join("dove-page-rewrite.js");
153 std::fs::write(&tmp, PAGE_REWRITE_JS)?;
154 let code_arg = format!("fileb://{}", tmp.display());
155 let out = aws(
156 profile,
157 &[
158 "cloudfront",
159 "create-function",
160 "--name",
161 "dove-page-rewrite",
162 "--function-config",
163 "Comment=dove page cache-key rewrite,Runtime=cloudfront-js-2.0",
164 "--function-code",
165 &code_arg,
166 "--output",
167 "json",
168 ],
169 );
170 let _ = std::fs::remove_file(&tmp);
171 let out = out?;
172 if out.status.success() {
173 let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
174 let etag = v["ETag"]
175 .as_str()
176 .ok_or_else(|| anyhow!("no ETag from create-function"))?;
177 let pub_out = aws(
178 profile,
179 &[
180 "cloudfront",
181 "publish-function",
182 "--name",
183 "dove-page-rewrite",
184 "--if-match",
185 etag,
186 ],
187 )?;
188 if !pub_out.status.success() {
189 bail!(
190 "publish-function: {}",
191 String::from_utf8_lossy(&pub_out.stderr).trim()
192 );
193 }
194 } else if !String::from_utf8_lossy(&out.stderr).contains("FunctionAlreadyExists") {
195 bail!(
196 "create-function: {}",
197 String::from_utf8_lossy(&out.stderr).trim()
198 );
199 }
200 Ok(arn)
201}
202
203fn create_distribution(
204 profile: Option<&str>,
205 origin_host: &str,
206 page_fn_arn: &str,
207) -> Result<Front> {
208 let caller_ref = format!("dove-{origin_host}");
209 let config = distribution_config(&caller_ref, origin_host, page_fn_arn);
210 let out = aws(
211 profile,
212 &[
213 "cloudfront",
214 "create-distribution",
215 "--distribution-config",
216 &config,
217 "--output",
218 "json",
219 ],
220 )?;
221 if !out.status.success() {
222 bail!(
223 "create-distribution: {}",
224 String::from_utf8_lossy(&out.stderr).trim()
225 );
226 }
227 let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
228 Ok(Front {
229 distribution_id: v["Distribution"]["Id"]
230 .as_str()
231 .ok_or_else(|| anyhow!("no distribution Id"))?
232 .to_string(),
233 domain: v["Distribution"]["DomainName"]
234 .as_str()
235 .ok_or_else(|| anyhow!("no distribution DomainName"))?
236 .to_string(),
237 })
238}
239
240pub fn add_alias(
243 profile: Option<&str>,
244 dist_id: &str,
245 domain: &str,
246 cert_arn: &str,
247) -> Result<String> {
248 let out = aws(
249 profile,
250 &[
251 "cloudfront",
252 "get-distribution-config",
253 "--id",
254 dist_id,
255 "--output",
256 "json",
257 ],
258 )?;
259 if !out.status.success() {
260 bail!(
261 "get-distribution-config: {}",
262 String::from_utf8_lossy(&out.stderr).trim()
263 );
264 }
265 let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
266 let etag = v["ETag"]
267 .as_str()
268 .ok_or_else(|| anyhow!("no ETag"))?
269 .to_string();
270 let mut cfg = v["DistributionConfig"].clone();
271 cfg["Aliases"] = serde_json::json!({"Quantity": 1, "Items": [domain]});
272 cfg["ViewerCertificate"] = serde_json::json!({
273 "ACMCertificateArn": cert_arn,
274 "SSLSupportMethod": "sni-only",
275 "MinimumProtocolVersion": "TLSv1.2_2021",
276 "CloudFrontDefaultCertificate": false,
277 });
278 let domain_name = cfg["DomainName"].as_str().unwrap_or_default().to_string();
279
280 let tmp = std::env::temp_dir().join(format!("dove-dist-{dist_id}.json"));
281 std::fs::write(&tmp, cfg.to_string())?;
282 let cfg_arg = format!("file://{}", tmp.display());
283 let out = aws(
284 profile,
285 &[
286 "cloudfront",
287 "update-distribution",
288 "--id",
289 dist_id,
290 "--distribution-config",
291 &cfg_arg,
292 "--if-match",
293 &etag,
294 "--output",
295 "json",
296 ],
297 );
298 let _ = std::fs::remove_file(&tmp);
299 if !out?.status.success() {
300 bail!("update-distribution failed");
301 }
302 Ok(domain_name)
303}
304
305fn distribution_domain(profile: Option<&str>, dist_id: &str) -> Result<String> {
306 let out = aws(
307 profile,
308 &[
309 "cloudfront",
310 "get-distribution",
311 "--id",
312 dist_id,
313 "--output",
314 "json",
315 ],
316 )?;
317 if !out.status.success() {
318 bail!(
319 "get-distribution: {}",
320 String::from_utf8_lossy(&out.stderr).trim()
321 );
322 }
323 let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
324 v["Distribution"]["DomainName"]
325 .as_str()
326 .map(str::to_string)
327 .ok_or_else(|| anyhow!("no DomainName"))
328}
329
330fn cache_behavior(path: &str, cache_policy: &str, fn_arn: Option<&str>) -> serde_json::Value {
335 let fns = match fn_arn {
336 Some(arn) => serde_json::json!({
337 "Quantity": 1,
338 "Items": [{"EventType": "viewer-request", "FunctionARN": arn}]
339 }),
340 None => serde_json::json!({"Quantity": 0}),
341 };
342 serde_json::json!({
343 "PathPattern": path,
344 "TargetOriginId": "gate",
345 "ViewerProtocolPolicy": "redirect-to-https",
346 "CachePolicyId": cache_policy,
347 "Compress": true,
348 "AllowedMethods": {
349 "Quantity": 2, "Items": ["GET", "HEAD"],
350 "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
351 },
352 "FunctionAssociations": fns,
353 "SmoothStreaming": false,
354 "FieldLevelEncryptionId": "",
355 "LambdaFunctionAssociations": {"Quantity": 0},
356 "TrustedSigners": {"Enabled": false, "Quantity": 0},
357 "TrustedKeyGroups": {"Enabled": false, "Quantity": 0}
358 })
359}
360
361fn merge_request_behaviors(config: &mut serde_json::Value, page_fn_arn: &str) {
377 config["DefaultCacheBehavior"]["AllowedMethods"] = serde_json::json!({
378 "Quantity": 7,
379 "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
380 "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
381 });
382
383 if !config["CacheBehaviors"]["Items"].is_array() {
386 config["CacheBehaviors"] = serde_json::json!({"Quantity": 0, "Items": []});
387 }
388 let items = config["CacheBehaviors"]["Items"]
389 .as_array_mut()
390 .expect("CacheBehaviors.Items normalized to an array above");
391 if !items.iter().any(|b| b["PathPattern"] == "/r/*") {
392 items.push(cache_behavior("/r/*", CACHING_OPTIMIZED, Some(page_fn_arn)));
393 }
394 let len = items.len();
395 config["CacheBehaviors"]["Quantity"] = serde_json::json!(len);
396}
397
398pub fn distribution_config(caller_ref: &str, origin_host: &str, page_fn_arn: &str) -> String {
405 serde_json::json!({
406 "CallerReference": caller_ref,
407 "Comment": "dove gate",
408 "Enabled": true,
409 "Origins": {"Quantity": 1, "Items": [{
410 "Id": "gate",
411 "DomainName": origin_host,
412 "CustomOriginConfig": {
413 "HTTPPort": 80,
414 "HTTPSPort": 443,
415 "OriginProtocolPolicy": "https-only",
416 "OriginSslProtocols": {"Quantity": 1, "Items": ["TLSv1.2"]}
417 }
418 }]},
419 "DefaultCacheBehavior": {
420 "TargetOriginId": "gate",
421 "ViewerProtocolPolicy": "redirect-to-https",
422 "AllowedMethods": {
428 "Quantity": 7, "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
429 "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
430 },
431 "CachePolicyId": CACHE_DISABLED,
432 "OriginRequestPolicyId": ALL_VIEWER_EXCEPT_HOST,
433 "Compress": true
434 },
435 "CacheBehaviors": {"Quantity": 3, "Items": [
436 cache_behavior("/d/*", CACHING_OPTIMIZED, Some(page_fn_arn)),
437 cache_behavior("/r/*", CACHING_OPTIMIZED, Some(page_fn_arn)),
438 cache_behavior("/og.png", CACHING_OPTIMIZED, None)
439 ]},
440 "ViewerCertificate": {"CloudFrontDefaultCertificate": true}
441 })
442 .to_string()
443}
444
445fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
446 let mut cmd = Command::new("aws");
447 if let Some(p) = profile {
448 cmd.args(["--profile", p]);
449 }
450 cmd.args(args)
451 .output()
452 .with_context(|| format!("running aws {}", args.join(" ")))
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn distribution_config_fronts_the_api_gateway_origin() {
461 let c = distribution_config(
462 "dove-x",
463 "abc.execute-api.us-east-1.amazonaws.com",
464 "arn:aws:cloudfront::1:function/dove-page-rewrite",
465 );
466 assert!(c.contains("\"DomainName\":\"abc.execute-api.us-east-1.amazonaws.com\""));
467 assert!(c.contains(CACHE_DISABLED)); assert!(c.contains(CACHING_OPTIMIZED)); assert!(c.contains("/d/*") && c.contains("/r/*") && c.contains("/og.png"));
470 assert!(c.contains("\"Quantity\":3")); let parsed: serde_json::Value = serde_json::from_str(&c).unwrap();
477 let behaviors = parsed["CacheBehaviors"]["Items"].as_array().unwrap();
478 let r_behavior = behaviors
479 .iter()
480 .find(|b| b["PathPattern"] == "/r/*")
481 .expect("/r/* behavior present");
482 assert_eq!(
483 r_behavior["FunctionAssociations"]["Items"][0]["FunctionARN"],
484 "arn:aws:cloudfront::1:function/dove-page-rewrite"
485 );
486 assert_eq!(r_behavior["CachePolicyId"], CACHING_OPTIMIZED);
487 assert!(c.contains(ALL_VIEWER_EXCEPT_HOST));
488 assert!(!c.contains("OriginAccessControlId")); let default_methods = &parsed["DefaultCacheBehavior"]["AllowedMethods"];
496 let allowed: Vec<&str> = default_methods["Items"]
497 .as_array()
498 .unwrap()
499 .iter()
500 .map(|v| v.as_str().unwrap())
501 .collect();
502 assert!(
503 allowed.contains(&"POST"),
504 "default behavior must allow POST for /done"
505 );
506 assert!(allowed.contains(&"GET") && allowed.contains(&"HEAD"));
507 let cached: Vec<&str> = default_methods["CachedMethods"]["Items"]
508 .as_array()
509 .unwrap()
510 .iter()
511 .map(|v| v.as_str().unwrap())
512 .collect();
513 assert_eq!(cached, vec!["GET", "HEAD"]);
514 }
515
516 const TEST_FN_ARN: &str = "arn:aws:cloudfront::000000000000:function/dove-page-rewrite";
525
526 fn live_config_with_custom_domain() -> serde_json::Value {
527 serde_json::json!({
528 "CallerReference": "dove-abc.execute-api.us-east-1.amazonaws.com",
529 "Comment": "dove gate",
530 "Enabled": true,
531 "Aliases": {"Quantity": 1, "Items": ["share.example.com"]},
532 "Origins": {"Quantity": 1, "Items": [{
533 "Id": "gate",
534 "DomainName": "abc.execute-api.us-east-1.amazonaws.com"
535 }]},
536 "DefaultCacheBehavior": {
537 "TargetOriginId": "gate",
538 "ViewerProtocolPolicy": "redirect-to-https",
539 "AllowedMethods": {
540 "Quantity": 2, "Items": ["GET", "HEAD"],
541 "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
542 },
543 "CachePolicyId": CACHE_DISABLED,
544 "OriginRequestPolicyId": ALL_VIEWER_EXCEPT_HOST,
545 "Compress": true
546 },
547 "CacheBehaviors": {"Quantity": 2, "Items": [
548 cache_behavior("/d/*", CACHING_OPTIMIZED, Some(TEST_FN_ARN)),
549 cache_behavior("/og.png", CACHING_OPTIMIZED, None)
550 ]},
551 "ViewerCertificate": {
552 "ACMCertificateArn": "arn:aws:acm:us-east-1:000000000000:certificate/test",
553 "SSLSupportMethod": "sni-only",
554 "CloudFrontDefaultCertificate": false
555 }
556 })
557 }
558
559 fn path_patterns(config: &serde_json::Value) -> Vec<String> {
560 config["CacheBehaviors"]["Items"]
561 .as_array()
562 .unwrap()
563 .iter()
564 .map(|b| b["PathPattern"].as_str().unwrap().to_string())
565 .collect()
566 }
567
568 #[test]
571 fn merge_preserves_alias_and_cert() {
572 let mut cfg = live_config_with_custom_domain();
573 merge_request_behaviors(&mut cfg, TEST_FN_ARN);
574
575 assert_eq!(
576 cfg["Aliases"]["Items"].as_array().unwrap(),
577 &vec![serde_json::json!("share.example.com")]
578 );
579 assert_eq!(
580 cfg["ViewerCertificate"]["ACMCertificateArn"],
581 "arn:aws:acm:us-east-1:000000000000:certificate/test"
582 );
583 assert_eq!(
585 cfg["ViewerCertificate"]["CloudFrontDefaultCertificate"],
586 false
587 );
588 assert_eq!(cfg["ViewerCertificate"]["SSLSupportMethod"], "sni-only");
589 assert_eq!(cfg["Comment"], "dove gate");
591 assert_eq!(cfg["Origins"]["Items"][0]["Id"], "gate");
592 }
593
594 #[test]
596 fn merge_allows_post_but_caches_only_get_head() {
597 let mut cfg = live_config_with_custom_domain();
598 merge_request_behaviors(&mut cfg, TEST_FN_ARN);
599
600 let dm = &cfg["DefaultCacheBehavior"]["AllowedMethods"];
601 let allowed: Vec<&str> = dm["Items"]
602 .as_array()
603 .unwrap()
604 .iter()
605 .map(|v| v.as_str().unwrap())
606 .collect();
607 assert!(
608 allowed.contains(&"POST"),
609 "default behavior must allow POST for /done"
610 );
611 let cached: Vec<&str> = dm["CachedMethods"]["Items"]
612 .as_array()
613 .unwrap()
614 .iter()
615 .map(|v| v.as_str().unwrap())
616 .collect();
617 assert_eq!(cached, vec!["GET", "HEAD"]);
618 }
619
620 #[test]
623 fn merge_adds_r_behavior() {
624 let mut cfg = live_config_with_custom_domain();
625 merge_request_behaviors(&mut cfg, TEST_FN_ARN);
626
627 let patterns = path_patterns(&cfg);
628 assert!(patterns.contains(&"/r/*".to_string()));
629 assert!(patterns.contains(&"/d/*".to_string()));
630 assert!(patterns.contains(&"/og.png".to_string()));
631
632 let items = cfg["CacheBehaviors"]["Items"].as_array().unwrap();
633 let r = items
634 .iter()
635 .find(|b| b["PathPattern"] == "/r/*")
636 .expect("/r/* behavior present");
637 assert_eq!(
638 r["FunctionAssociations"]["Items"][0]["FunctionARN"],
639 TEST_FN_ARN
640 );
641 assert_eq!(r["CachePolicyId"], CACHING_OPTIMIZED);
642 assert_eq!(
643 cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap() as usize,
644 items.len()
645 );
646 }
647
648 #[test]
650 fn merge_is_idempotent() {
651 let mut cfg = live_config_with_custom_domain();
652 merge_request_behaviors(&mut cfg, TEST_FN_ARN);
653 let len_after_first = cfg["CacheBehaviors"]["Items"].as_array().unwrap().len();
654 let quantity_after_first = cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap();
655
656 merge_request_behaviors(&mut cfg, TEST_FN_ARN);
657
658 let items = cfg["CacheBehaviors"]["Items"].as_array().unwrap();
659 let r_count = items.iter().filter(|b| b["PathPattern"] == "/r/*").count();
660 assert_eq!(r_count, 1, "exactly one /r/* behavior after two merges");
661 assert_eq!(items.len(), len_after_first);
662 assert_eq!(
663 cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap(),
664 quantity_after_first
665 );
666 assert_eq!(
667 cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap() as usize,
668 items.len()
669 );
670 }
671}