1pub mod apigw;
24pub mod breaker;
25pub mod cloudfront;
26pub mod domain;
27pub mod gate;
28
29use crate::config::SelfHostedConfig;
30use crate::progress::Progress;
31use anyhow::{anyhow, bail, Context, Result};
32use std::process::Command;
33
34pub struct ProvisionArgs {
35 pub bucket: Option<String>,
37 pub region: String,
38 pub expire_days: u32,
39}
40
41pub fn provision_simple(
46 args: &ProvisionArgs,
47 profile: Option<String>,
48 progress: &dyn Progress,
49) -> Result<SelfHostedConfig> {
50 let (_account, bucket) = base_provision(args, profile.as_deref(), progress)?;
51 Ok(SelfHostedConfig {
52 bucket,
53 region: args.region.clone(),
54 profile,
55 endpoint: None,
56 table: None,
57 gate_url: None,
58 distribution_id: None,
59 })
60}
61
62pub fn provision_full(
73 args: &ProvisionArgs,
74 profile: Option<String>,
75 existing: Option<SelfHostedConfig>,
76 progress: &dyn Progress,
77) -> Result<SelfHostedConfig> {
78 let (account, bucket) = base_provision(args, profile.as_deref(), progress)?;
79
80 let prior_gate_url = existing.as_ref().and_then(|c| c.gate_url.clone());
81 let existing_dist = existing.and_then(|c| c.distribution_id);
82
83 let infra = build_full_infra(
84 profile.as_deref(),
85 &account,
86 &bucket,
87 &args.region,
88 existing_dist.as_deref(),
89 progress,
90 )?;
91
92 progress.step("scoped key · gate write");
96 let scoped = share_policy_full(&bucket, &args.region, &account, &infra.table);
97 aws_ok(
98 profile.as_deref(),
99 &[
100 "iam",
101 "put-user-policy",
102 "--user-name",
103 &bucket,
104 "--policy-name",
105 "dove-share",
106 "--policy-document",
107 &scoped,
108 ],
109 &[],
110 )?;
111 progress.done("scoped key · gate write");
112
113 let gate_url = match prior_gate_url {
116 Some(existing) if !existing.contains(".cloudfront.net") => existing,
117 _ => infra.gate_url,
118 };
119
120 Ok(SelfHostedConfig {
121 bucket,
122 region: args.region.clone(),
123 profile,
124 endpoint: None,
125 table: Some(infra.table),
126 gate_url: Some(gate_url),
127 distribution_id: infra.distribution_id,
128 })
129}
130
131fn base_provision(
135 args: &ProvisionArgs,
136 profile: Option<&str>,
137 progress: &dyn Progress,
138) -> Result<(String, String)> {
139 let (account, _arn) = caller_identity(profile).with_context(|| {
140 format!(
141 "resolving the AWS identity for {} — is it logged in (e.g. `aws sso login`)?",
142 profile.unwrap_or("the default profile")
143 )
144 })?;
145 let bucket = derive_bucket(&account, args.bucket.as_deref());
146
147 let mut create = vec!["s3api", "create-bucket", "--bucket", bucket.as_str()];
149 let lc = format!("LocationConstraint={}", args.region);
150 if args.region != "us-east-1" {
151 create.push("--region");
152 create.push(&args.region);
153 create.push("--create-bucket-configuration");
154 create.push(&lc);
155 }
156 progress.step("creating bucket");
157 let created = aws_ok(profile, &create, &["BucketAlreadyOwnedByYou"]);
158 if created.is_ok() {
159 progress.done("creating bucket");
160 }
161 created?;
162
163 progress.step("blocking public access");
165 let blocked = aws_ok(
166 profile,
167 &[
168 "s3api",
169 "put-public-access-block",
170 "--bucket",
171 &bucket,
172 "--public-access-block-configuration",
173 PUBLIC_ACCESS_BLOCK,
174 ],
175 &[],
176 );
177 if blocked.is_ok() {
178 progress.done("blocking public access");
179 }
180 blocked?;
181
182 let lifecycle = lifecycle_config(args.expire_days);
184 let lifecycle_label = format!("lifecycle · {} days", args.expire_days);
185 progress.step(&lifecycle_label);
186 let lifecycled = aws_ok(
187 profile,
188 &[
189 "s3api",
190 "put-bucket-lifecycle-configuration",
191 "--bucket",
192 &bucket,
193 "--lifecycle-configuration",
194 &lifecycle,
195 ],
196 &[],
197 );
198 if lifecycled.is_ok() {
199 progress.done(&lifecycle_label);
200 }
201 lifecycled?;
202
203 let iam_user = bucket.clone();
208 progress.step("scoped IAM user");
209 let user_created = aws_ok(
210 profile,
211 &["iam", "create-user", "--user-name", &iam_user],
212 &["EntityAlreadyExists"],
213 );
214 if user_created.is_ok() {
215 progress.done("scoped IAM user");
216 }
217 user_created?;
218
219 progress.step("least-privilege policy");
220 let policy = share_policy(&bucket);
221 let policy_put = aws_ok(
222 profile,
223 &[
224 "iam",
225 "put-user-policy",
226 "--user-name",
227 &iam_user,
228 "--policy-name",
229 "dove-share",
230 "--policy-document",
231 &policy,
232 ],
233 &[],
234 );
235 if policy_put.is_ok() {
236 progress.done("least-privilege policy");
237 }
238 policy_put?;
239
240 if crate::secrets::Secrets::exists() {
243 progress.step("access key (reusing)");
244 progress.done("access key (reusing)");
245 } else {
246 progress.step("minting access key");
247 let minted = (|| -> Result<()> {
248 let out = aws(
249 profile,
250 &[
251 "iam",
252 "create-access-key",
253 "--user-name",
254 &iam_user,
255 "--output",
256 "json",
257 ],
258 )?;
259 if !out.status.success() {
260 bail!(
261 "creating access key: {}",
262 String::from_utf8_lossy(&out.stderr).trim()
263 );
264 }
265 let (id, secret) = parse_access_key(&out.stdout)?;
266 crate::secrets::Secrets {
267 access_key_id: id,
268 secret_access_key: secret,
269 gate_secret: None,
270 }
271 .save()
272 })();
273 if minted.is_ok() {
274 progress.done("minting access key");
275 }
276 minted?;
277 }
278
279 Ok((account, bucket))
280}
281
282struct FullInfra {
284 table: String,
285 gate_url: String,
286 distribution_id: Option<String>,
287}
288
289fn build_full_infra(
293 profile: Option<&str>,
294 account: &str,
295 bucket: &str,
296 region: &str,
297 existing_distribution: Option<&str>,
298 progress: &dyn Progress,
299) -> Result<FullInfra> {
300 let table = bucket.to_string(); let name = format!("dove-gate-{account}"); progress.step("bucket CORS");
306 let cors = aws_ok(
307 profile,
308 &[
309 "s3api",
310 "put-bucket-cors",
311 "--bucket",
312 bucket,
313 "--cors-configuration",
314 BUCKET_CORS,
315 ],
316 &[],
317 );
318 if cors.is_ok() {
319 progress.done("bucket CORS");
320 }
321 cors?;
322
323 progress.step("dynamodb table");
325 let dynamo = (|| -> Result<()> {
326 aws_ok(
327 profile,
328 &[
329 "dynamodb",
330 "create-table",
331 "--table-name",
332 &table,
333 "--attribute-definitions",
334 "AttributeName=id,AttributeType=S",
335 "--key-schema",
336 "AttributeName=id,KeyType=HASH",
337 "--billing-mode",
338 "PAY_PER_REQUEST",
339 ],
340 &["ResourceInUseException"],
341 )?;
342 aws_ok(
343 profile,
344 &["dynamodb", "wait", "table-exists", "--table-name", &table],
345 &[],
346 )?;
347 aws_ok(
348 profile,
349 &[
350 "dynamodb",
351 "update-time-to-live",
352 "--table-name",
353 &table,
354 "--time-to-live-specification",
355 "Enabled=true,AttributeName=expires_at",
356 ],
357 &["TimeToLive is already enabled"],
358 )
359 })();
360 if dynamo.is_ok() {
361 progress.done("dynamodb table");
362 }
363 dynamo?;
364
365 let role_arn = format!("arn:aws:iam::{account}:role/{name}");
367 progress.step("gate IAM role");
368 let role = (|| -> Result<()> {
369 aws_ok(
370 profile,
371 &[
372 "iam",
373 "create-role",
374 "--role-name",
375 &name,
376 "--assume-role-policy-document",
377 LAMBDA_TRUST,
378 ],
379 &["EntityAlreadyExists"],
380 )?;
381 let policy = gate_role_policy(account, region, &table, bucket);
382 aws_ok(
383 profile,
384 &[
385 "iam",
386 "put-role-policy",
387 "--role-name",
388 &name,
389 "--policy-name",
390 "dove-gate",
391 "--policy-document",
392 &policy,
393 ],
394 &[],
395 )
396 })();
397 if role.is_ok() {
398 progress.done("gate IAM role");
399 }
400 role?;
401
402 let gate_secret = ensure_gate_secret()?;
407 let secret_param = format!("/dove/{bucket}/gate-secret");
408 progress.step("gate secret (SSM)");
409 let secret_put = aws_ok(
410 profile,
411 &[
412 "ssm",
413 "put-parameter",
414 "--name",
415 &secret_param,
416 "--value",
417 &gate_secret,
418 "--type",
419 "SecureString",
420 "--overwrite",
421 ],
422 &[],
423 );
424 if secret_put.is_ok() {
425 progress.done("gate secret (SSM)");
426 }
427 secret_put?;
428
429 let zip = temp_path("zip");
432 gate::write_deployment_zip(&zip)?;
433 let zip_arg = format!("fileb://{}", zip.display());
434 let env =
435 format!("Variables={{BUCKET={bucket},TABLE={table},GATE_SECRET_PARAM={secret_param}}}");
436 progress.step("gate Lambda");
437 let lambda_result = (|| -> Result<()> {
438 aws_retry(
439 profile,
440 &[
441 "lambda",
442 "create-function",
443 "--function-name",
444 &name,
445 "--runtime",
446 gate::RUNTIME,
447 "--handler",
448 gate::HANDLER,
449 "--role",
450 &role_arn,
451 "--zip-file",
452 &zip_arg,
453 "--environment",
454 &env,
455 "--timeout",
456 "30",
457 ],
458 &["ResourceConflictException"],
459 "cannot be assumed",
460 6,
461 )?;
462 let _ = aws(
469 profile,
470 &[
471 "lambda",
472 "wait",
473 "function-active-v2",
474 "--function-name",
475 &name,
476 ],
477 );
478 aws_retry(
479 profile,
480 &[
481 "lambda",
482 "update-function-code",
483 "--function-name",
484 &name,
485 "--zip-file",
486 &zip_arg,
487 ],
488 &[],
489 "cannot be performed at this time",
490 10,
491 )?;
492 let _ = aws(
494 profile,
495 &[
496 "lambda",
497 "wait",
498 "function-updated-v2",
499 "--function-name",
500 &name,
501 ],
502 );
503 aws_retry(
506 profile,
507 &[
508 "lambda",
509 "update-function-configuration",
510 "--function-name",
511 &name,
512 "--environment",
513 &env,
514 ],
515 &[],
516 "cannot be performed at this time",
517 10,
518 )?;
519 let _ = aws(
520 profile,
521 &[
522 "lambda",
523 "wait",
524 "function-updated-v2",
525 "--function-name",
526 &name,
527 ],
528 );
529 Ok(())
530 })();
531 let _ = std::fs::remove_file(&zip);
532 if lambda_result.is_ok() {
533 progress.done("gate Lambda");
534 }
535 lambda_result?;
536
537 let function_arn = format!("arn:aws:lambda:{region}:{account}:function:{name}");
541 let api = apigw::provision_api(profile, region, account, &name, &function_arn, progress)?;
542 let front =
545 cloudfront::front_gate(profile, account, &api.host, existing_distribution, progress)?;
546
547 breaker::provision_breaker(profile, region, account, &name, progress)?;
551
552 Ok(FullInfra {
553 table,
554 gate_url: format!("https://{}", front.domain),
555 distribution_id: Some(front.distribution_id),
556 })
557}
558
559fn ensure_gate_secret() -> Result<String> {
563 let mut s = crate::secrets::Secrets::load()?;
564 if let Some(g) = &s.gate_secret {
565 return Ok(g.clone());
566 }
567 let g = crate::crypto::gen_gate_secret();
568 s.gate_secret = Some(g.clone());
569 s.save()?;
570 Ok(g)
571}
572
573pub fn derive_bucket(account: &str, override_bucket: Option<&str>) -> String {
575 override_bucket
576 .map(str::to_string)
577 .unwrap_or_else(|| format!("dove-shares-{account}"))
578}
579
580pub fn have_aws() -> bool {
583 Command::new("aws")
584 .arg("--version")
585 .output()
586 .map(|o| o.status.success())
587 .unwrap_or(false)
588}
589
590pub fn caller_identity(profile: Option<&str>) -> Result<(String, String)> {
594 let out = aws(profile, &["sts", "get-caller-identity", "--output", "json"])?;
595 if !out.status.success() {
596 bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
597 }
598 let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
599 let account = v["Account"].as_str().unwrap_or("?").to_string();
600 let arn = v["Arn"].as_str().unwrap_or("?").to_string();
601 Ok((account, arn))
602}
603
604const LAMBDA_TRUST: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
606
607const BUCKET_CORS: &str = r#"{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","POST"],"AllowedHeaders":["*"],"MaxAgeSeconds":3000}]}"#;
613
614pub fn gate_role_policy(account: &str, region: &str, table: &str, bucket: &str) -> String {
617 format!(
618 r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"],"Resource":"arn:aws:logs:*:{account}:*"}},{{"Effect":"Allow","Action":["dynamodb:GetItem","dynamodb:UpdateItem"],"Resource":"arn:aws:dynamodb:{region}:{account}:table/{table}"}},{{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"arn:aws:s3:::{bucket}/*"}},{{"Effect":"Allow","Action":"ssm:GetParameter","Resource":"arn:aws:ssm:{region}:{account}:parameter/dove/{bucket}/gate-secret"}},{{"Effect":"Allow","Action":"kms:Decrypt","Resource":"*"}}]}}"#
619 )
620}
621
622fn temp_path(ext: &str) -> std::path::PathBuf {
624 let mut b = [0u8; 8];
625 getrandom::getrandom(&mut b).expect("OS RNG");
626 let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
627 std::env::temp_dir().join(format!("dove-{hex}.{ext}"))
628}
629
630fn aws_retry(
633 profile: Option<&str>,
634 args: &[&str],
635 tolerate: &[&str],
636 retry_on: &str,
637 attempts: u32,
638) -> Result<()> {
639 for i in 0..attempts {
640 let out = aws(profile, args)?;
641 if out.status.success() {
642 return Ok(());
643 }
644 let stderr = String::from_utf8_lossy(&out.stderr);
645 if tolerate.iter().any(|t| stderr.contains(t)) {
646 return Ok(());
647 }
648 if stderr.contains(retry_on) && i + 1 < attempts {
649 std::thread::sleep(std::time::Duration::from_secs(3));
650 continue;
651 }
652 bail!("aws {} failed: {}", args.join(" "), stderr.trim());
653 }
654 Ok(())
655}
656
657const PUBLIC_ACCESS_BLOCK: &str = "BlockPublicAcls=true,IgnorePublicAcls=true,\
659 BlockPublicPolicy=true,RestrictPublicBuckets=true";
660
661pub fn lifecycle_config(days: u32) -> String {
663 format!(
664 r#"{{"Rules":[{{"ID":"dove-expire","Status":"Enabled","Filter":{{}},"Expiration":{{"Days":{days}}}}}]}}"#
665 )
666}
667
668pub fn share_policy(bucket: &str) -> String {
671 format!(
672 r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::{bucket}/*"}},{{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::{bucket}"}}]}}"#
673 )
674}
675
676pub fn share_policy_full(bucket: &str, region: &str, account: &str, table: &str) -> String {
681 format!(
682 r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::{bucket}/*"}},{{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::{bucket}"}},{{"Effect":"Allow","Action":"dynamodb:PutItem","Resource":"arn:aws:dynamodb:{region}:{account}:table/{table}"}}]}}"#
683 )
684}
685
686pub fn parse_access_key(json_bytes: &[u8]) -> Result<(String, String)> {
688 let json: serde_json::Value =
689 serde_json::from_slice(json_bytes).context("parsing create-access-key output")?;
690 let key = json
691 .get("AccessKey")
692 .ok_or_else(|| anyhow!("create-access-key output missing AccessKey"))?;
693 let id = key
694 .get("AccessKeyId")
695 .and_then(|v| v.as_str())
696 .ok_or_else(|| anyhow!("create-access-key output missing AccessKeyId"))?;
697 let secret = key
698 .get("SecretAccessKey")
699 .and_then(|v| v.as_str())
700 .ok_or_else(|| anyhow!("create-access-key output missing SecretAccessKey"))?;
701 Ok((id.to_string(), secret.to_string()))
702}
703
704fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
706 let mut cmd = Command::new("aws");
707 if let Some(p) = profile {
708 cmd.args(["--profile", p]);
709 }
710 cmd.args(args)
711 .output()
712 .map_err(|e| anyhow!("running aws {}: {e}", args.join(" ")))
713}
714
715fn aws_ok(profile: Option<&str>, args: &[&str], tolerate: &[&str]) -> Result<()> {
718 let out = aws(profile, args)?;
719 if out.status.success() {
720 return Ok(());
721 }
722 let stderr = String::from_utf8_lossy(&out.stderr);
723 if tolerate.iter().any(|t| stderr.contains(t)) {
724 return Ok(());
725 }
726 bail!("aws {} failed: {}", args.join(" "), stderr.trim())
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn lifecycle_expires_after_the_given_days() {
735 let lc = lifecycle_config(7);
736 assert!(lc.contains("\"Days\":7"));
737 assert!(lc.contains("\"Status\":\"Enabled\""));
738 assert!(lc.contains("\"Expiration\""));
739 }
740
741 #[test]
742 fn share_policy_is_scoped_to_the_one_bucket() {
743 let p = share_policy("dove-shares-123");
744 assert!(p.contains("s3:PutObject"));
745 assert!(p.contains("s3:GetObject"));
746 assert!(p.contains("s3:DeleteObject"));
747 assert!(p.contains("s3:ListBucket"));
748 assert!(p.contains("arn:aws:s3:::dove-shares-123/*"));
749 assert!(p.contains("arn:aws:s3:::dove-shares-123\""));
750 assert!(!p.contains("\"Resource\":\"*\""));
752 }
753
754 #[test]
755 fn share_policy_full_adds_dynamodb_putitem_scoped_to_the_table() {
756 let p = share_policy_full("dove-shares-123", "us-east-1", "123", "dove-shares-123");
757 assert!(p.contains("s3:PutObject")); assert!(p.contains("s3:ListBucket"));
759 assert!(p.contains("dynamodb:PutItem")); assert!(p.contains("arn:aws:dynamodb:us-east-1:123:table/dove-shares-123"));
761 assert!(!p.contains("dynamodb:*"));
763 assert!(!p.contains("\"Resource\":\"*\""));
764 }
765
766 #[test]
767 fn gate_role_policy_scopes_to_the_one_table_and_bucket() {
768 let p = gate_role_policy("123", "us-east-1", "dove-shares-123", "dove-shares-123");
769 assert!(p.contains("dynamodb:GetItem")); assert!(p.contains("dynamodb:UpdateItem"));
771 assert!(p.contains("arn:aws:dynamodb:us-east-1:123:table/dove-shares-123"));
772 assert!(p.contains("s3:GetObject"));
773 assert!(p.contains("s3:PutObject")); assert!(p.contains("arn:aws:s3:::dove-shares-123/*"));
775 assert!(p.contains("logs:PutLogEvents"));
776 assert!(p.contains("ssm:GetParameter")); assert!(p.contains("parameter/dove/dove-shares-123/gate-secret"));
778 assert!(p.contains("kms:Decrypt"));
779 }
780
781 #[test]
782 fn parse_access_key_extracts_id_and_secret() {
783 let json =
784 br#"{"AccessKey":{"AccessKeyId":"AKIA1","SecretAccessKey":"shh","Status":"Active"}}"#;
785 let (id, secret) = parse_access_key(json).unwrap();
786 assert_eq!(id, "AKIA1");
787 assert_eq!(secret, "shh");
788 }
789
790 #[test]
791 fn public_access_block_turns_everything_on() {
792 for k in [
793 "BlockPublicAcls=true",
794 "IgnorePublicAcls=true",
795 "BlockPublicPolicy=true",
796 "RestrictPublicBuckets=true",
797 ] {
798 assert!(PUBLIC_ACCESS_BLOCK.contains(k), "missing {k}");
799 }
800 }
801
802 #[test]
803 fn bucket_cors_allows_get_and_post() {
804 assert!(BUCKET_CORS.contains("\"GET\""));
805 assert!(BUCKET_CORS.contains("\"POST\""));
806 assert!(BUCKET_CORS.contains("\"AllowedOrigins\":[\"*\"]"));
807 }
808
809 #[test]
810 fn derive_bucket_uses_override_or_falls_back_to_account() {
811 assert_eq!(
812 derive_bucket("123456789012", None),
813 "dove-shares-123456789012"
814 );
815 assert_eq!(
816 derive_bucket("123456789012", Some("my-bucket")),
817 "my-bucket"
818 );
819 }
820}