1use anyhow::{anyhow, bail, Context, Result};
16use std::io::{IsTerminal, Write};
17use std::process::Command;
18
19pub struct ProvisionArgs {
21 pub bucket: String,
22 pub region: String,
23 pub prefix: String,
24 pub history_days: u32,
25 pub iam_user: String,
26 pub profile: Option<String>,
28 pub yes: bool,
30}
31
32pub struct Provisioned {
34 pub bucket: String,
35 pub region: String,
36 pub prefix: String,
37 pub key_id: String,
38 pub secret: String,
39}
40
41const BANNER: &str = "\
42git-ark vault provision — AWS S3 only.
43 Creates an S3 bucket and a write-only (PutObject-only) IAM key. The write-only
44 guarantee relies on AWS IAM, so this does NOT apply to MinIO, Cloudflare R2, or
45 other S3-compatible stores — for those, make a bucket yourself and pass
46 --bucket/--endpoint to `git-ark host add`.";
47
48pub fn run(args: &ProvisionArgs) -> Result<()> {
51 eprintln!("{BANNER}\n");
52
53 if !have_aws() {
54 bail!("the AWS CLI (`aws`) is required for provisioning — https://aws.amazon.com/cli/");
55 }
56
57 let profile = match &args.profile {
60 Some(p) => Some(p.clone()),
61 None => choose_profile()?,
62 };
63
64 let (account, arn) = caller_identity(profile.as_deref()).with_context(|| {
66 format!(
67 "resolving the AWS identity for {} — is it logged in (e.g. `aws sso login`)?",
68 profile.as_deref().unwrap_or("environment credentials")
69 )
70 })?;
71 eprintln!(
72 "About to provision, using {}:\n bucket: s3://{}/{}/\n region: {}\n account: {account} ({arn})\n iam user: {} (PutObject-only)\n",
73 profile.as_deref().unwrap_or("environment credentials"),
74 args.bucket,
75 args.prefix,
76 args.region,
77 args.iam_user,
78 );
79 if !args.yes && !confirm("Create these AWS resources?")? {
80 bail!("aborted — nothing was created");
81 }
82
83 let p = provision(args, profile.as_deref())?;
85
86 println!("\n✓ vault provisioned: s3://{}/{}/", p.bucket, p.prefix);
89 println!("\nWrite-only key (paste into the host via `host add`, then clear your scrollback):");
90 println!(" export GIT_ARK_HOST_S3_KEY_ID={}", p.key_id);
91 println!(" export GIT_ARK_HOST_S3_SECRET={}", p.secret);
92 println!(
93 "\nThen wire a host to it:\n git-ark host add <name> <user@host> \\\n --bucket {} --region {} --recipient age1…",
94 p.bucket, p.region
95 );
96 Ok(())
97}
98
99fn have_aws() -> bool {
100 Command::new("aws")
101 .arg("--version")
102 .output()
103 .map(|o| o.status.success())
104 .unwrap_or(false)
105}
106
107fn choose_profile() -> Result<Option<String>> {
111 let profiles = list_profiles()?;
112 if !std::io::stdin().is_terminal() {
113 bail!("no TTY for interactive profile selection — pass --profile <name>");
114 }
115 eprintln!("Which AWS credentials?");
116 for (i, p) in profiles.iter().enumerate() {
117 eprintln!(" {}) {p}", i + 1);
118 }
119 let env_choice = profiles.len() + 1;
120 eprintln!(" {env_choice}) environment / default credentials (no profile)");
121
122 loop {
123 eprint!("Choose [1-{env_choice}]: ");
124 std::io::stderr().flush().ok();
125 let line = read_line()?;
126 match line.trim().parse::<usize>() {
127 Ok(n) if (1..=profiles.len()).contains(&n) => return Ok(Some(profiles[n - 1].clone())),
128 Ok(n) if n == env_choice => return Ok(None),
129 _ => eprintln!(" please enter a number between 1 and {env_choice}"),
130 }
131 }
132}
133
134fn list_profiles() -> Result<Vec<String>> {
136 let out = Command::new("aws")
137 .args(["configure", "list-profiles"])
138 .output()
139 .map_err(|e| anyhow!("running aws configure list-profiles: {e}"))?;
140 if !out.status.success() {
141 return Ok(Vec::new());
143 }
144 Ok(String::from_utf8_lossy(&out.stdout)
145 .lines()
146 .map(str::trim)
147 .filter(|l| !l.is_empty())
148 .map(str::to_string)
149 .collect())
150}
151
152fn caller_identity(profile: Option<&str>) -> Result<(String, String)> {
155 let out = aws(profile, &["sts", "get-caller-identity", "--output", "json"])?;
156 if !out.status.success() {
157 bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
158 }
159 let json: serde_json::Value =
160 serde_json::from_slice(&out.stdout).context("parsing sts get-caller-identity output")?;
161 let account = json
162 .get("Account")
163 .and_then(|v| v.as_str())
164 .unwrap_or("<unknown>")
165 .to_string();
166 let arn = json
167 .get("Arn")
168 .and_then(|v| v.as_str())
169 .unwrap_or("<unknown>")
170 .to_string();
171 Ok((account, arn))
172}
173
174pub fn put_only_policy(bucket: &str, prefix: &str) -> String {
177 format!(
178 r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::{bucket}/{prefix}/*"}}]}}"#
179 )
180}
181
182pub fn lifecycle_config(prefix: &str, days: u32) -> String {
185 format!(
186 r#"{{"Rules":[{{"ID":"expire-history","Status":"Enabled","Filter":{{"Prefix":"{prefix}/"}},"Expiration":{{"Days":{days}}},"NoncurrentVersionExpiration":{{"NoncurrentDays":{days}}}}}]}}"#
187 )
188}
189
190pub fn parse_access_key(json_bytes: &[u8]) -> Result<(String, String)> {
192 let json: serde_json::Value =
193 serde_json::from_slice(json_bytes).context("parsing create-access-key output")?;
194 let key = json
195 .get("AccessKey")
196 .ok_or_else(|| anyhow!("create-access-key output missing AccessKey"))?;
197 let id = key
198 .get("AccessKeyId")
199 .and_then(|v| v.as_str())
200 .ok_or_else(|| anyhow!("create-access-key output missing AccessKeyId"))?;
201 let secret = key
202 .get("SecretAccessKey")
203 .and_then(|v| v.as_str())
204 .ok_or_else(|| anyhow!("create-access-key output missing SecretAccessKey"))?;
205 Ok((id.to_string(), secret.to_string()))
206}
207
208fn provision(args: &ProvisionArgs, profile: Option<&str>) -> Result<Provisioned> {
212 eprintln!("• creating bucket {} (Object Lock enabled)…", args.bucket);
215 let lc = format!("LocationConstraint={}", args.region);
216 let mut create = vec![
217 "s3api",
218 "create-bucket",
219 "--bucket",
220 &args.bucket,
221 "--object-lock-enabled-for-bucket",
222 "--region",
223 &args.region,
224 ];
225 if args.region != "us-east-1" {
226 create.push("--create-bucket-configuration");
227 create.push(&lc);
228 }
229 aws_tolerate(
230 profile,
231 &create,
232 &["BucketAlreadyOwnedByYou", "BucketAlreadyExists"],
233 )?;
234
235 eprintln!("• enabling versioning…");
242 aws_tolerate(
243 profile,
244 &[
245 "s3api",
246 "put-bucket-versioning",
247 "--bucket",
248 &args.bucket,
249 "--versioning-configuration",
250 "Status=Enabled",
251 ],
252 &["Object Lock configuration is present"],
253 )?;
254
255 eprintln!("• blocking all public access…");
257 aws_ok(
258 profile,
259 &[
260 "s3api",
261 "put-public-access-block",
262 "--bucket",
263 &args.bucket,
264 "--public-access-block-configuration",
265 "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true",
266 ],
267 )?;
268
269 eprintln!("• default server-side encryption (AES256)…");
271 aws_ok(
272 profile,
273 &[
274 "s3api",
275 "put-bucket-encryption",
276 "--bucket",
277 &args.bucket,
278 "--server-side-encryption-configuration",
279 r#"{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}"#,
280 ],
281 )?;
282
283 eprintln!(
285 "• lifecycle: expire {}/ after {} days…",
286 args.prefix, args.history_days
287 );
288 let lifecycle = lifecycle_config(&args.prefix, args.history_days);
289 aws_ok(
290 profile,
291 &[
292 "s3api",
293 "put-bucket-lifecycle-configuration",
294 "--bucket",
295 &args.bucket,
296 "--lifecycle-configuration",
297 &lifecycle,
298 ],
299 )?;
300
301 eprintln!("• write-only IAM user {}…", args.iam_user);
303 aws_tolerate(
304 profile,
305 &["iam", "create-user", "--user-name", &args.iam_user],
306 &["EntityAlreadyExists"],
307 )?;
308
309 let policy = put_only_policy(&args.bucket, &args.prefix);
311 aws_ok(
312 profile,
313 &[
314 "iam",
315 "put-user-policy",
316 "--user-name",
317 &args.iam_user,
318 "--policy-name",
319 "git-ark-put-only",
320 "--policy-document",
321 &policy,
322 ],
323 )?;
324
325 eprintln!("• minting access key…");
327 let out = aws(
328 profile,
329 &[
330 "iam",
331 "create-access-key",
332 "--user-name",
333 &args.iam_user,
334 "--output",
335 "json",
336 ],
337 )?;
338 if !out.status.success() {
339 bail!(
340 "creating access key: {}",
341 String::from_utf8_lossy(&out.stderr).trim()
342 );
343 }
344 let (key_id, secret) = parse_access_key(&out.stdout)?;
345
346 Ok(Provisioned {
347 bucket: args.bucket.clone(),
348 region: args.region.clone(),
349 prefix: args.prefix.clone(),
350 key_id,
351 secret,
352 })
353}
354
355fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
357 let mut cmd = Command::new("aws");
358 if let Some(p) = profile {
359 cmd.arg("--profile").arg(p);
360 }
361 cmd.args(args)
362 .output()
363 .map_err(|e| anyhow!("running aws {}: {e}", args.join(" ")))
364}
365
366fn aws_ok(profile: Option<&str>, args: &[&str]) -> Result<()> {
368 let out = aws(profile, args)?;
369 if !out.status.success() {
370 bail!(
371 "aws {} failed: {}",
372 args.join(" "),
373 String::from_utf8_lossy(&out.stderr).trim()
374 );
375 }
376 Ok(())
377}
378
379fn aws_tolerate(profile: Option<&str>, args: &[&str], tolerate: &[&str]) -> Result<()> {
382 let out = aws(profile, args)?;
383 if out.status.success() {
384 return Ok(());
385 }
386 let stderr = String::from_utf8_lossy(&out.stderr);
387 if tolerate.iter().any(|m| stderr.contains(m)) {
388 return Ok(());
389 }
390 bail!("aws {} failed: {}", args.join(" "), stderr.trim());
391}
392
393fn confirm(prompt: &str) -> Result<bool> {
394 eprint!("{prompt} [y/N]: ");
395 std::io::stderr().flush().ok();
396 let line = read_line()?;
397 let a = line.trim().to_ascii_lowercase();
398 Ok(a == "y" || a == "yes")
399}
400
401fn read_line() -> Result<String> {
402 let mut s = String::new();
403 std::io::stdin()
404 .read_line(&mut s)
405 .context("reading from stdin")?;
406 Ok(s)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn put_only_policy_is_putobject_scoped_to_prefix() {
415 let p = put_only_policy("git-ark-vault-123", "git-ark");
416 assert!(p.contains(r#""Action":"s3:PutObject""#));
417 assert!(p.contains(r#""Resource":"arn:aws:s3:::git-ark-vault-123/git-ark/*""#));
418 assert!(!p.contains("GetObject"));
420 assert!(!p.contains("ListBucket"));
421 assert!(!p.contains("DeleteObject"));
422 serde_json::from_str::<serde_json::Value>(&p).unwrap();
424 }
425
426 #[test]
427 fn lifecycle_config_expires_prefix_and_noncurrent() {
428 let l = lifecycle_config("git-ark", 90);
429 let v: serde_json::Value = serde_json::from_str(&l).unwrap();
430 let rule = &v["Rules"][0];
431 assert_eq!(rule["Filter"]["Prefix"], "git-ark/");
432 assert_eq!(rule["Expiration"]["Days"], 90);
433 assert_eq!(rule["NoncurrentVersionExpiration"]["NoncurrentDays"], 90);
434 }
435
436 #[test]
437 fn parse_access_key_extracts_id_and_secret() {
438 let json = br#"{"AccessKey":{"UserName":"git-ark-nas","AccessKeyId":"AKIAEXAMPLE","SecretAccessKey":"s3cr3t/value","Status":"Active"}}"#;
439 let (id, secret) = parse_access_key(json).unwrap();
440 assert_eq!(id, "AKIAEXAMPLE");
441 assert_eq!(secret, "s3cr3t/value");
442 }
443
444 #[test]
445 fn parse_access_key_errors_without_accesskey() {
446 assert!(parse_access_key(br#"{"nope":true}"#).is_err());
447 }
448}