Backups to S3, keylessly 7b9f4570daf5
docs/backups.md §3, §6.1. An S3 destination needs no access key: the hub mints a 10-minute token as backup:OWNER with audience sts.amazonaws.com, AWS STS (AssumeRoleWithWebIdentity) gives an hour's credentials of the IAM role the owner names, exchanged again five minutes before they run out, and each S3 request is signed with Signature Version 4. The signer reproduces AWS's published GET Object example exactly. Heads, puts, gets, paginated listings and recursive removal, under an optional prefix. The setting holds the bucket, its region (migration 0063) and the role's ARN, on Settings › Backups beside GCS; Admin › Backups shows s3://bucket (region). A refusal says what the role needs, s3:ListBucket included. The AWS setup commands are in §6.1 for Andy; nothing was created in any cloud. Tested end to end against a fake STS and S3 that check every request's signature: a backup under a prefix with a space, file names with spaces and +, a second run sending nothing, the probe cleaning up, a fresh hub restoring byte for byte, and another owner's session refused. closes #178
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 03:48 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7b9f4570daf517d0c5fbc10965f9001dfb5cc68b2a5635bb53f37ecffeccbe8f- Depends on
- 18e381ea88c2 1e8a6e8bb91b 35fea4237cc9 6dbd999ad794 d23464c0ac60 d3b048b27277 d7d182467382
- Depended on by
- cb7b3cd45dc0 c8684d44d549 5d90e0e045e2 0cd118748131 2e9188ca4f98
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 28 atoms, 1 tree operation, 8 files
Browse the tree at this change
added crates/hub/migrations/0063_backup_region.sql +3 −0
| 1 | + | -- S3 destinations (docs/backups.md §3, oxygenhub#178): the bucket's AWS |
|
| 2 | + | -- region, which signs every request. |
|
| 3 | + | ALTER TABLE backup_settings ADD COLUMN region TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/backups.rs +374 −3
| … | 37 unchanged lines |
||
| 38 | 38 | pub provider: String, |
|
| 39 | 39 | pub keep_days: i64, |
|
| 40 | 40 | pub updated_ms: i64, |
|
| 41 | + | /// An S3 bucket's region (migration 0063). |
|
| 42 | + | #[sqlx(default)] |
|
| 43 | + | pub region: String, |
|
| 41 | 44 | } |
|
| 42 | 45 | ||
| 43 | 46 | impl Setting { |
|
| … | 8 unchanged lines |
||
| 52 | 55 | provider: String::new(), |
|
| 53 | 56 | keep_days: 30, |
|
| 54 | 57 | updated_ms: 0, |
|
| 58 | + | region: String::new(), |
|
| 55 | 59 | } |
|
| 56 | 60 | } |
|
| 57 | 61 | ||
| … | 30 unchanged lines |
||
| 88 | 92 | ||
| 89 | 93 | pub async fn save(pool: &Pool, s: &Setting) -> sqlx::Result<()> { |
|
| 90 | 94 | sqlx::query( |
|
| 91 | - | "INSERT INTO backup_settings (owner, kind, every, destination, target, prefix, provider, keep_days, updated_ms) \ |
|
| 92 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(owner) DO UPDATE SET kind = excluded.kind, every = excluded.every, \ |
|
| 95 | + | "INSERT INTO backup_settings (owner, kind, every, destination, target, prefix, provider, keep_days, updated_ms, region) \ |
|
| 96 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(owner) DO UPDATE SET kind = excluded.kind, every = excluded.every, \ |
|
| 93 | 97 | destination = excluded.destination, target = excluded.target, prefix = excluded.prefix, provider = excluded.provider, \ |
|
| 94 | - | keep_days = excluded.keep_days, updated_ms = excluded.updated_ms", |
|
| 98 | + | keep_days = excluded.keep_days, updated_ms = excluded.updated_ms, region = excluded.region", |
|
| 95 | 99 | ) |
|
| 96 | 100 | .bind(&s.owner) |
|
| 97 | 101 | .bind(&s.kind) |
|
| … | 4 unchanged lines |
||
| 102 | 106 | .bind(&s.provider) |
|
| 103 | 107 | .bind(s.keep_days) |
|
| 104 | 108 | .bind(db::now_ms()) |
|
| 109 | + | .bind(&s.region) |
|
| 105 | 110 | .execute(pool) |
|
| 106 | 111 | .await?; |
|
| 107 | 112 | Ok(()) |
|
| … | 49 unchanged lines |
||
| 157 | 162 | match s.destination.as_str() { |
|
| 158 | 163 | "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target), |
|
| 159 | 164 | "gcs" => format!("gs://{}/{}", s.target, s.prefix), |
|
| 165 | + | "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region), |
|
| 166 | + | "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region), |
|
| 160 | 167 | _ => format!("{}/{}", s.target, s.owner), |
|
| 161 | 168 | } |
|
| 162 | 169 | } |
|
| … | 218 unchanged lines |
||
| 381 | 388 | } |
|
| 382 | 389 | } |
|
| 383 | 390 | ||
| 391 | + | /// An S3 bucket, written keylessly (§3): the hub's own short-lived token |
|
| 392 | + | /// (`sub = backup:OWNER`, audience `sts.amazonaws.com`) is exchanged at AWS |
|
| 393 | + | /// STS (AssumeRoleWithWebIdentity) for an hour's credentials of the role |
|
| 394 | + | /// the owner names, and they sign each request (Signature Version 4). |
|
| 395 | + | pub struct S3 { |
|
| 396 | + | bucket: String, |
|
| 397 | + | prefix: String, |
|
| 398 | + | region: String, |
|
| 399 | + | /// The bucket's endpoint: scheme, host and any path before keys. |
|
| 400 | + | base: reqwest::Url, |
|
| 401 | + | sts: String, |
|
| 402 | + | /// The IAM role's ARN. |
|
| 403 | + | role: String, |
|
| 404 | + | subject: String, |
|
| 405 | + | app: Shared, |
|
| 406 | + | client: reqwest::Client, |
|
| 407 | + | rt: tokio::runtime::Handle, |
|
| 408 | + | creds: std::sync::Mutex<Option<Creds>>, |
|
| 409 | + | } |
|
| 410 | + | ||
| 411 | + | #[derive(Clone)] |
|
| 412 | + | struct Creds { |
|
| 413 | + | id: String, |
|
| 414 | + | secret: String, |
|
| 415 | + | token: String, |
|
| 416 | + | until: i64, |
|
| 417 | + | } |
|
| 418 | + | ||
| 419 | + | /// The audience AWS expects of a web identity token. |
|
| 420 | + | pub const AWS_AUDIENCE: &str = "sts.amazonaws.com"; |
|
| 421 | + | ||
| 422 | + | /// Percent-encoding as AWS signs it: everything but unreserved characters, |
|
| 423 | + | /// and `/` too unless `keep_slash`. |
|
| 424 | + | pub fn aws_encode(s: &str, keep_slash: bool) -> String { |
|
| 425 | + | let mut out = String::new(); |
|
| 426 | + | for b in s.bytes() { |
|
| 427 | + | match b { |
|
| 428 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), |
|
| 429 | + | b'/' if keep_slash => out.push('/'), |
|
| 430 | + | _ => out.push_str(&format!("%{b:02X}")), |
|
| 431 | + | } |
|
| 432 | + | } |
|
| 433 | + | out |
|
| 434 | + | } |
|
| 435 | + | ||
| 436 | + | fn hmac(key: &[u8], data: &str) -> Vec<u8> { |
|
| 437 | + | use hmac::Mac; |
|
| 438 | + | let mut m = hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("any key length"); |
|
| 439 | + | m.update(data.as_bytes()); |
|
| 440 | + | m.finalize().into_bytes().to_vec() |
|
| 441 | + | } |
|
| 442 | + | ||
| 443 | + | fn sha256_hex(data: &[u8]) -> String { |
|
| 444 | + | use sha2::Digest; |
|
| 445 | + | hex::encode(sha2::Sha256::digest(data)) |
|
| 446 | + | } |
|
| 447 | + | ||
| 448 | + | /// AWS Signature Version 4: the `Authorization` header for one request. |
|
| 449 | + | /// `uri` is the path as encoded on the wire, `query` the decoded pairs, |
|
| 450 | + | /// `headers` the signed ones (any case), `payload` the body's SHA-256 in |
|
| 451 | + | /// hex, `amz_date` as `20130524T000000Z`. |
|
| 452 | + | #[allow(clippy::too_many_arguments)] |
|
| 453 | + | pub fn sigv4( |
|
| 454 | + | method: &str, |
|
| 455 | + | uri: &str, |
|
| 456 | + | query: &[(String, String)], |
|
| 457 | + | headers: &[(String, String)], |
|
| 458 | + | payload: &str, |
|
| 459 | + | (id, secret): (&str, &str), |
|
| 460 | + | region: &str, |
|
| 461 | + | service: &str, |
|
| 462 | + | amz_date: &str, |
|
| 463 | + | ) -> String { |
|
| 464 | + | let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect(); |
|
| 465 | + | q.sort(); |
|
| 466 | + | let query = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&"); |
|
| 467 | + | let mut h: Vec<(String, String)> = headers.iter().map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string())).collect(); |
|
| 468 | + | h.sort(); |
|
| 469 | + | let canonical_headers: String = h.iter().map(|(k, v)| format!("{k}:{v}\n")).collect(); |
|
| 470 | + | let signed = h.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";"); |
|
| 471 | + | let request = format!("{method}\n{uri}\n{query}\n{canonical_headers}\n{signed}\n{payload}"); |
|
| 472 | + | let date = &amz_date[..8]; |
|
| 473 | + | let scope = format!("{date}/{region}/{service}/aws4_request"); |
|
| 474 | + | let to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(request.as_bytes())); |
|
| 475 | + | let key = hmac(&hmac(&hmac(&hmac(format!("AWS4{secret}").as_bytes(), date), region), service), "aws4_request"); |
|
| 476 | + | let signature = hex::encode(hmac(&key, &to_sign)); |
|
| 477 | + | format!("AWS4-HMAC-SHA256 Credential={id}/{scope}, SignedHeaders={signed}, Signature={signature}") |
|
| 478 | + | } |
|
| 479 | + | ||
| 480 | + | /// The text of the first `<tag>` in `xml`, unescaped. |
|
| 481 | + | fn xml_text(xml: &str, tag: &str) -> Option<String> { |
|
| 482 | + | xml_blocks(xml, tag).into_iter().next().map(xml_unescape) |
|
| 483 | + | } |
|
| 484 | + | ||
| 485 | + | /// The inside of every `<tag>…</tag>` in `xml`. |
|
| 486 | + | fn xml_blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { |
|
| 487 | + | let (open, close) = (format!("<{tag}>"), format!("</{tag}>")); |
|
| 488 | + | let mut out = Vec::new(); |
|
| 489 | + | let mut rest = xml; |
|
| 490 | + | while let Some(i) = rest.find(&open) { |
|
| 491 | + | let after = &rest[i + open.len()..]; |
|
| 492 | + | let Some(j) = after.find(&close) else { break }; |
|
| 493 | + | out.push(&after[..j]); |
|
| 494 | + | rest = &after[j + close.len()..]; |
|
| 495 | + | } |
|
| 496 | + | out |
|
| 497 | + | } |
|
| 498 | + | ||
| 499 | + | fn xml_unescape(s: &str) -> String { |
|
| 500 | + | s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") |
|
| 501 | + | } |
|
| 502 | + | ||
| 503 | + | fn amz_date(ms: i64) -> String { |
|
| 504 | + | let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
|
| 505 | + | format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second()) |
|
| 506 | + | } |
|
| 507 | + | ||
| 508 | + | impl S3 { |
|
| 509 | + | pub fn new(app: &Shared, s: &Setting) -> S3 { |
|
| 510 | + | let url = app.config.backups.s3_url.replace("{bucket}", &s.target).replace("{region}", &s.region); |
|
| 511 | + | S3 { |
|
| 512 | + | bucket: s.target.clone(), |
|
| 513 | + | prefix: s.prefix.trim_matches('/').to_string(), |
|
| 514 | + | region: s.region.clone(), |
|
| 515 | + | base: reqwest::Url::parse(url.trim_end_matches('/')).unwrap_or_else(|_| reqwest::Url::parse("https://invalid.").unwrap()), |
|
| 516 | + | sts: app.config.backups.aws_sts_url.trim_end_matches('/').to_string(), |
|
| 517 | + | role: s.provider.clone(), |
|
| 518 | + | subject: format!("backup:{}", s.owner), |
|
| 519 | + | app: app.clone(), |
|
| 520 | + | client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(), |
|
| 521 | + | rt: tokio::runtime::Handle::current(), |
|
| 522 | + | creds: std::sync::Mutex::new(None), |
|
| 523 | + | } |
|
| 524 | + | } |
|
| 525 | + | ||
| 526 | + | fn name(&self, key: &str) -> String { |
|
| 527 | + | if self.prefix.is_empty() { |
|
| 528 | + | key.to_string() |
|
| 529 | + | } else { |
|
| 530 | + | format!("{}/{key}", self.prefix) |
|
| 531 | + | } |
|
| 532 | + | } |
|
| 533 | + | ||
| 534 | + | /// Credentials of the owner's role, exchanged again when the last are |
|
| 535 | + | /// within five minutes of running out. |
|
| 536 | + | fn creds(&self) -> Result<Creds> { |
|
| 537 | + | let now = db::now_ms(); |
|
| 538 | + | if let Some(c) = self.creds.lock().unwrap().as_ref() { |
|
| 539 | + | if c.until - now > 300_000 { |
|
| 540 | + | return Ok(c.clone()); |
|
| 541 | + | } |
|
| 542 | + | } |
|
| 543 | + | let session: String = format!("oxygenhub-{}", self.subject.trim_start_matches("backup:")) |
|
| 544 | + | .chars() |
|
| 545 | + | .filter(|c| c.is_ascii_alphanumeric() || "=,.@-_".contains(*c)) |
|
| 546 | + | .take(64) |
|
| 547 | + | .collect(); |
|
| 548 | + | let c = self.rt.block_on(async { |
|
| 549 | + | let issuer = self.app.oidc().await?; |
|
| 550 | + | let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, AWS_AUDIENCE, db::now_ms(), 600); |
|
| 551 | + | let res = self |
|
| 552 | + | .client |
|
| 553 | + | .post(format!("{}/", self.sts)) |
|
| 554 | + | .form(&[ |
|
| 555 | + | ("Action", "AssumeRoleWithWebIdentity"), |
|
| 556 | + | ("Version", "2011-06-15"), |
|
| 557 | + | ("RoleArn", self.role.as_str()), |
|
| 558 | + | ("RoleSessionName", session.as_str()), |
|
| 559 | + | ("WebIdentityToken", jwt.as_str()), |
|
| 560 | + | ("DurationSeconds", "3600"), |
|
| 561 | + | ]) |
|
| 562 | + | .send() |
|
| 563 | + | .await?; |
|
| 564 | + | let status = res.status(); |
|
| 565 | + | let text = res.text().await.unwrap_or_default(); |
|
| 566 | + | if !status.is_success() { |
|
| 567 | + | bail!( |
|
| 568 | + | "AWS STS refused {} for {}: {} (check the role's trust policy: this hub's address as its OIDC provider, audience {AWS_AUDIENCE}, and sub {})", |
|
| 569 | + | self.subject, |
|
| 570 | + | self.role, |
|
| 571 | + | xml_text(&text, "Message").unwrap_or_else(|| status.to_string()), |
|
| 572 | + | self.subject |
|
| 573 | + | ); |
|
| 574 | + | } |
|
| 575 | + | let get = |t: &str| xml_text(&text, t).with_context(|| format!("AWS STS gave no {t}")); |
|
| 576 | + | anyhow::Ok(Creds { id: get("AccessKeyId")?, secret: get("SecretAccessKey")?, token: get("SessionToken")?, until: now + 3_600_000 }) |
|
| 577 | + | })?; |
|
| 578 | + | *self.creds.lock().unwrap() = Some(c.clone()); |
|
| 579 | + | Ok(c) |
|
| 580 | + | } |
|
| 581 | + | ||
| 582 | + | /// One signed request: `key` is an object's (none for the bucket). |
|
| 583 | + | fn send( |
|
| 584 | + | &self, |
|
| 585 | + | method: reqwest::Method, |
|
| 586 | + | key: Option<&str>, |
|
| 587 | + | query: &[(String, String)], |
|
| 588 | + | body: Vec<u8>, |
|
| 589 | + | ) -> Result<(reqwest::StatusCode, Vec<u8>)> { |
|
| 590 | + | let c = self.creds()?; |
|
| 591 | + | let base_path = self.base.path().trim_end_matches('/'); |
|
| 592 | + | let uri = match key { |
|
| 593 | + | Some(k) => format!("{base_path}/{}", aws_encode(&self.name(k), true)), |
|
| 594 | + | None => format!("{base_path}/"), |
|
| 595 | + | }; |
|
| 596 | + | let host = match self.base.port() { |
|
| 597 | + | Some(p) => format!("{}:{p}", self.base.host_str().unwrap_or_default()), |
|
| 598 | + | None => self.base.host_str().unwrap_or_default().to_string(), |
|
| 599 | + | }; |
|
| 600 | + | let payload = sha256_hex(&body); |
|
| 601 | + | let date = amz_date(db::now_ms()); |
|
| 602 | + | let headers = vec![ |
|
| 603 | + | ("host".to_string(), host.clone()), |
|
| 604 | + | ("x-amz-content-sha256".to_string(), payload.clone()), |
|
| 605 | + | ("x-amz-date".to_string(), date.clone()), |
|
| 606 | + | ("x-amz-security-token".to_string(), c.token.clone()), |
|
| 607 | + | ]; |
|
| 608 | + | let auth = sigv4(method.as_str(), &uri, query, &headers, &payload, (&c.id, &c.secret), &self.region, "s3", &date); |
|
| 609 | + | let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect(); |
|
| 610 | + | q.sort(); |
|
| 611 | + | let qs = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&"); |
|
| 612 | + | let url = format!("{}://{host}{uri}{}{qs}", self.base.scheme(), if qs.is_empty() { "" } else { "?" }); |
|
| 613 | + | self.rt.block_on(async { |
|
| 614 | + | let mut req = self |
|
| 615 | + | .client |
|
| 616 | + | .request(method, &url) |
|
| 617 | + | .header("authorization", auth) |
|
| 618 | + | .header("x-amz-content-sha256", &payload) |
|
| 619 | + | .header("x-amz-date", &date) |
|
| 620 | + | .header("x-amz-security-token", &c.token); |
|
| 621 | + | if !body.is_empty() { |
|
| 622 | + | req = req.header("content-type", "application/octet-stream").body(body); |
|
| 623 | + | } |
|
| 624 | + | let res = req.send().await?; |
|
| 625 | + | let status = res.status(); |
|
| 626 | + | anyhow::Ok((status, res.bytes().await?.to_vec())) |
|
| 627 | + | }) |
|
| 628 | + | } |
|
| 629 | + | ||
| 630 | + | fn refused(&self, what: &str, status: reqwest::StatusCode, body: &[u8]) -> anyhow::Error { |
|
| 631 | + | let text = String::from_utf8_lossy(body); |
|
| 632 | + | let hint = if status == reqwest::StatusCode::FORBIDDEN { |
|
| 633 | + | format!(" (the role {} needs s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket on {})", self.role, self.bucket) |
|
| 634 | + | } else { |
|
| 635 | + | String::new() |
|
| 636 | + | }; |
|
| 637 | + | anyhow::anyhow!("S3 {what}: {status}{hint}: {}", xml_text(&text, "Message").unwrap_or_else(|| text.chars().take(200).collect())) |
|
| 638 | + | } |
|
| 639 | + | ||
| 640 | + | /// The keys under `full` (`delimited`: and the next level's prefixes). |
|
| 641 | + | fn listing(&self, full: &str, delimited: bool) -> Result<(Vec<String>, Vec<String>)> { |
|
| 642 | + | let (mut keys, mut prefixes) = (Vec::new(), Vec::new()); |
|
| 643 | + | let mut token: Option<String> = None; |
|
| 644 | + | loop { |
|
| 645 | + | let mut q = vec![("list-type".to_string(), "2".to_string()), ("prefix".to_string(), full.to_string())]; |
|
| 646 | + | if delimited { |
|
| 647 | + | q.push(("delimiter".to_string(), "/".to_string())); |
|
| 648 | + | } |
|
| 649 | + | if let Some(t) = &token { |
|
| 650 | + | q.push(("continuation-token".to_string(), t.clone())); |
|
| 651 | + | } |
|
| 652 | + | let (status, body) = self.send(reqwest::Method::GET, None, &q, Vec::new())?; |
|
| 653 | + | if !status.is_success() { |
|
| 654 | + | return Err(self.refused("list", status, &body)); |
|
| 655 | + | } |
|
| 656 | + | let text = String::from_utf8_lossy(&body).into_owned(); |
|
| 657 | + | for c in xml_blocks(&text, "Contents") { |
|
| 658 | + | keys.extend(xml_text(c, "Key")); |
|
| 659 | + | } |
|
| 660 | + | for c in xml_blocks(&text, "CommonPrefixes") { |
|
| 661 | + | prefixes.extend(xml_text(c, "Prefix")); |
|
| 662 | + | } |
|
| 663 | + | match (xml_text(&text, "IsTruncated").as_deref(), xml_text(&text, "NextContinuationToken")) { |
|
| 664 | + | (Some("true"), Some(t)) => token = Some(t), |
|
| 665 | + | _ => return Ok((keys, prefixes)), |
|
| 666 | + | } |
|
| 667 | + | } |
|
| 668 | + | } |
|
| 669 | + | } |
|
| 670 | + | ||
| 671 | + | impl Destination for S3 { |
|
| 672 | + | fn exists(&self, key: &str) -> Result<bool> { |
|
| 673 | + | let (status, body) = self.send(reqwest::Method::HEAD, Some(key), &[], Vec::new())?; |
|
| 674 | + | match status { |
|
| 675 | + | s if s.is_success() => Ok(true), |
|
| 676 | + | reqwest::StatusCode::NOT_FOUND => Ok(false), |
|
| 677 | + | s => Err(self.refused("read", s, &body)), |
|
| 678 | + | } |
|
| 679 | + | } |
|
| 680 | + | fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
|
| 681 | + | let (status, body) = self.send(reqwest::Method::PUT, Some(key), &[], bytes.to_vec())?; |
|
| 682 | + | if !status.is_success() { |
|
| 683 | + | return Err(self.refused("write", status, &body)); |
|
| 684 | + | } |
|
| 685 | + | Ok(()) |
|
| 686 | + | } |
|
| 687 | + | fn get(&self, key: &str) -> Result<Vec<u8>> { |
|
| 688 | + | let (status, body) = self.send(reqwest::Method::GET, Some(key), &[], Vec::new())?; |
|
| 689 | + | if !status.is_success() { |
|
| 690 | + | return Err(self.refused(&format!("read {key}"), status, &body)); |
|
| 691 | + | } |
|
| 692 | + | Ok(body) |
|
| 693 | + | } |
|
| 694 | + | fn list(&self, prefix: &str) -> Result<Vec<String>> { |
|
| 695 | + | let full = format!("{}/", self.name(prefix.trim_end_matches('/'))); |
|
| 696 | + | let (keys, prefixes) = self.listing(&full, true)?; |
|
| 697 | + | let mut out: std::collections::BTreeSet<String> = keys.iter().map(|k| k.trim_start_matches(&full).to_string()).collect(); |
|
| 698 | + | out.extend(prefixes.iter().map(|p| p.trim_start_matches(&full).trim_end_matches('/').to_string())); |
|
| 699 | + | Ok(out.into_iter().filter(|n| !n.is_empty()).collect()) |
|
| 700 | + | } |
|
| 701 | + | fn remove(&self, key: &str) -> Result<()> { |
|
| 702 | + | let full = self.name(key); |
|
| 703 | + | let (mut keys, _) = self.listing(&format!("{full}/"), false)?; |
|
| 704 | + | keys.push(full); |
|
| 705 | + | for k in keys { |
|
| 706 | + | let rel = if self.prefix.is_empty() { k.clone() } else { k.trim_start_matches(&format!("{}/", self.prefix)).to_string() }; |
|
| 707 | + | let (status, body) = self.send(reqwest::Method::DELETE, Some(&rel), &[], Vec::new())?; |
|
| 708 | + | if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND { |
|
| 709 | + | return Err(self.refused("remove", status, &body)); |
|
| 710 | + | } |
|
| 711 | + | } |
|
| 712 | + | Ok(()) |
|
| 713 | + | } |
|
| 714 | + | } |
|
| 715 | + | ||
| 384 | 716 | impl Destination for Gcs { |
|
| 385 | 717 | fn exists(&self, key: &str) -> Result<bool> { |
|
| 386 | 718 | let token = self.access()?; |
|
| … | 119 unchanged lines |
||
| 506 | 838 | bail!("a GCS destination needs its bucket and its workload identity provider"); |
|
| 507 | 839 | } |
|
| 508 | 840 | Ok(Box::new(Gcs::new(app, s))) |
|
| 841 | + | } |
|
| 842 | + | "s3" => { |
|
| 843 | + | if s.target.is_empty() || s.provider.is_empty() || s.region.is_empty() { |
|
| 844 | + | bail!("an S3 destination needs its bucket, its region and the IAM role the hub assumes"); |
|
| 845 | + | } |
|
| 846 | + | Ok(Box::new(S3::new(app, s))) |
|
| 509 | 847 | } |
|
| 510 | 848 | other => bail!("unknown destination {other:?}"), |
|
| 511 | 849 | } |
|
| … | 859 unchanged lines |
||
| 1371 | 1709 | let mut s = Sent::default(); |
|
| 1372 | 1710 | let first = copy_dir(&dest, src.path(), &HashMap::new(), true, &mut s).unwrap(); |
|
| 1373 | 1711 | assert_eq!(first.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec![".oxy/objects/loose/aa"]); |
|
| 1712 | + | } |
|
| 1713 | + | ||
| 1714 | + | #[test] |
|
| 1715 | + | fn requests_are_signed_as_aws_documents() { |
|
| 1716 | + | // AWS's own example (S3 API reference, "Signature calculations": |
|
| 1717 | + | // GET Object), with its published signature. |
|
| 1718 | + | let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; |
|
| 1719 | + | let headers = vec![ |
|
| 1720 | + | ("Host".to_string(), "examplebucket.s3.amazonaws.com".to_string()), |
|
| 1721 | + | ("Range".to_string(), "bytes=0-9".to_string()), |
|
| 1722 | + | ("x-amz-content-sha256".to_string(), empty.to_string()), |
|
| 1723 | + | ("x-amz-date".to_string(), "20130524T000000Z".to_string()), |
|
| 1724 | + | ]; |
|
| 1725 | + | let auth = sigv4( |
|
| 1726 | + | "GET", |
|
| 1727 | + | "/test.txt", |
|
| 1728 | + | &[], |
|
| 1729 | + | &headers, |
|
| 1730 | + | empty, |
|
| 1731 | + | ("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), |
|
| 1732 | + | "us-east-1", |
|
| 1733 | + | "s3", |
|
| 1734 | + | "20130524T000000Z", |
|
| 1735 | + | ); |
|
| 1736 | + | assert_eq!( |
|
| 1737 | + | auth, |
|
| 1738 | + | "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, \ |
|
| 1739 | + | SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \ |
|
| 1740 | + | Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" |
|
| 1741 | + | ); |
|
| 1742 | + | assert_eq!(aws_encode("runs/2026 x+y", true), "runs/2026%20x%2By"); |
|
| 1743 | + | assert_eq!(aws_encode("a/b", false), "a%2Fb"); |
|
| 1744 | + | assert_eq!(xml_text("<A><Key>runs/x&y</Key></A>", "Key").as_deref(), Some("runs/x&y")); |
|
| 1374 | 1745 | } |
|
| 1375 | 1746 | ||
| 1376 | 1747 | #[test] |
|
| … | 5 unchanged lines |
modified crates/hub/src/config.rs +6 −0
| … | 34 unchanged lines |
||
| 35 | 35 | /// set only to point them elsewhere (tests, a private endpoint). |
|
| 36 | 36 | pub sts_url: String, |
|
| 37 | 37 | pub storage_url: String, |
|
| 38 | + | /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in |
|
| 39 | + | /// `s3_url` are filled in. Set only to point them elsewhere. |
|
| 40 | + | pub aws_sts_url: String, |
|
| 41 | + | pub s3_url: String, |
|
| 38 | 42 | } |
|
| 39 | 43 | ||
| 40 | 44 | impl Default for Backups { |
|
| … | 2 unchanged lines |
||
| 43 | 47 | directories: Vec::new(), |
|
| 44 | 48 | sts_url: "https://sts.googleapis.com".into(), |
|
| 45 | 49 | storage_url: "https://storage.googleapis.com".into(), |
|
| 50 | + | aws_sts_url: "https://sts.amazonaws.com".into(), |
|
| 51 | + | s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(), |
|
| 46 | 52 | } |
|
| 47 | 53 | } |
|
| 48 | 54 | } |
|
| … | 366 unchanged lines |
modified crates/hub/src/web/backups.rs +24 −0
| … | 71 unchanged lines |
||
| 72 | 72 | prefix: String, |
|
| 73 | 73 | #[serde(default)] |
|
| 74 | 74 | keep_days: String, |
|
| 75 | + | /// An S3 destination's bucket, region and the IAM role the hub assumes. |
|
| 76 | + | #[serde(default)] |
|
| 77 | + | s3_bucket: String, |
|
| 78 | + | #[serde(default)] |
|
| 79 | + | s3_region: String, |
|
| 80 | + | #[serde(default)] |
|
| 81 | + | s3_role: String, |
|
| 75 | 82 | } |
|
| 76 | 83 | ||
| 77 | 84 | /// The form as a setting, or why it cannot be one. |
|
| … | 9 unchanged lines |
||
| 87 | 94 | if prefix.split('/').any(|p| p == ".." || p == ".") { |
|
| 88 | 95 | return Err("The prefix is a plain path, without . or .. parts.".into()); |
|
| 89 | 96 | } |
|
| 97 | + | let mut region = String::new(); |
|
| 90 | 98 | let (target, provider) = match f.destination.as_str() { |
|
| 91 | 99 | "dir" => { |
|
| 92 | 100 | if !app.config.backups.directories.iter().any(|d| d.display().to_string() == f.dir) { |
|
| 93 | 101 | return Err("Pick one of the directories this hub allows.".into()); |
|
| 94 | 102 | } |
|
| 95 | 103 | (f.dir.clone(), String::new()) |
|
| 104 | + | } |
|
| 105 | + | "s3" => { |
|
| 106 | + | let bucket = f.s3_bucket.trim().trim_start_matches("s3://").trim_end_matches('/').to_string(); |
|
| 107 | + | if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-.".contains(c)) { |
|
| 108 | + | return Err("Give the S3 bucket's name, such as andymccall-oxygen-backup.".into()); |
|
| 109 | + | } |
|
| 110 | + | region = f.s3_region.trim().to_string(); |
|
| 111 | + | if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { |
|
| 112 | + | return Err("Give the bucket's region, such as eu-west-2.".into()); |
|
| 113 | + | } |
|
| 114 | + | let role = f.s3_role.trim().to_string(); |
|
| 115 | + | if !role.starts_with("arn:aws:iam::") || !role.contains(":role/") { |
|
| 116 | + | return Err("Give the IAM role's ARN, arn:aws:iam::ACCOUNT:role/NAME (docs/backups.md §6).".into()); |
|
| 117 | + | } |
|
| 118 | + | (bucket, role) |
|
| 96 | 119 | } |
|
| 97 | 120 | "gcs" => { |
|
| 98 | 121 | let bucket = f.bucket.trim().trim_start_matches("gs://").trim_end_matches('/').to_string(); |
|
| … | 21 unchanged lines |
||
| 120 | 143 | provider, |
|
| 121 | 144 | keep_days, |
|
| 122 | 145 | updated_ms: 0, |
|
| 146 | + | region, |
|
| 123 | 147 | }) |
|
| 124 | 148 | } |
|
| 125 | 149 | ||
| … | 138 unchanged lines |
modified crates/hub/tests/backups.rs +204 −0
| … | 559 unchanged lines |
||
| 560 | 560 | let (owners, uncovered) = backups::overview(&h.app.db).await.unwrap(); |
|
| 561 | 561 | assert_eq!((owners[0].repos, uncovered.len()), (1, 0)); |
|
| 562 | 562 | } |
|
| 563 | + | ||
| 564 | + | /// AWS STS and S3, as much as backups use (path style, under `/s3`): an |
|
| 565 | + | /// AssumeRoleWithWebIdentity that admits `backup:` subjects with audience |
|
| 566 | + | /// `sts.amazonaws.com`, and buckets (`OWNER-backup`) that check every |
|
| 567 | + | /// request's Signature Version 4 and admit only their owner's session. |
|
| 568 | + | async fn fake_aws() -> (String, Shared2) { |
|
| 569 | + | use axum::extract::{Request, State}; |
|
| 570 | + | use axum::http::StatusCode; |
|
| 571 | + | use axum::response::IntoResponse; |
|
| 572 | + | let state: Shared2 = Default::default(); |
|
| 573 | + | async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response { |
|
| 574 | + | let (parts, body) = req.into_parts(); |
|
| 575 | + | let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes(); |
|
| 576 | + | let path = parts.uri.path().to_string(); |
|
| 577 | + | let header = |h: &str| parts.headers.get(h).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
|
| 578 | + | if path == "/" && parts.method == "POST" { |
|
| 579 | + | let form: Vec<(String, String)> = String::from_utf8_lossy(&bytes) |
|
| 580 | + | .split('&') |
|
| 581 | + | .filter_map(|kv| kv.split_once('=').map(|(k, v)| (k.to_string(), decode(&v.replace('+', " "))))) |
|
| 582 | + | .collect(); |
|
| 583 | + | let field = |k: &str| form.iter().find(|(f, _)| f == k).map(|(_, v)| v.clone()).unwrap_or_default(); |
|
| 584 | + | let jwt = field("WebIdentityToken"); |
|
| 585 | + | let claims: serde_json::Value = serde_json::from_slice(&base64url(jwt.split('.').nth(1).unwrap())).unwrap(); |
|
| 586 | + | let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string()); |
|
| 587 | + | g.lock().unwrap().exchanged.push((sub.clone(), aud.clone())); |
|
| 588 | + | if field("Action") != "AssumeRoleWithWebIdentity" || !sub.starts_with("backup:") || aud != "sts.amazonaws.com" { |
|
| 589 | + | return (StatusCode::FORBIDDEN, "<ErrorResponse><Error><Code>AccessDenied</Code><Message>Not authorized to perform sts:AssumeRoleWithWebIdentity</Message></Error></ErrorResponse>").into_response(); |
|
| 590 | + | } |
|
| 591 | + | let owner = sub.trim_start_matches("backup:"); |
|
| 592 | + | return format!( |
|
| 593 | + | "<AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials>\ |
|
| 594 | + | <AccessKeyId>ASIA{owner}</AccessKeyId><SecretAccessKey>secret/{owner}</SecretAccessKey>\ |
|
| 595 | + | <SessionToken>session-{owner}</SessionToken><Expiration>2030-01-01T00:00:00Z</Expiration>\ |
|
| 596 | + | </Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>" |
|
| 597 | + | ) |
|
| 598 | + | .into_response(); |
|
| 599 | + | } |
|
| 600 | + | // /s3/{bucket}/{key…} |
|
| 601 | + | let rest = path.strip_prefix("/s3/").unwrap_or(""); |
|
| 602 | + | let (bucket, key) = rest.split_once('/').unwrap_or((rest, "")); |
|
| 603 | + | let (bucket, key) = (bucket.to_string(), decode(key)); |
|
| 604 | + | let owner = bucket.trim_end_matches("-backup").to_string(); |
|
| 605 | + | // Check the signature as AWS would, with the session's secret. |
|
| 606 | + | let session = header("x-amz-security-token"); |
|
| 607 | + | let who = session.trim_start_matches("session-").to_string(); |
|
| 608 | + | let query: Vec<(String, String)> = parts |
|
| 609 | + | .uri |
|
| 610 | + | .query() |
|
| 611 | + | .unwrap_or("") |
|
| 612 | + | .split('&') |
|
| 613 | + | .filter(|kv| !kv.is_empty()) |
|
| 614 | + | .map(|kv| kv.split_once('=').map(|(k, v)| (decode(k), decode(v))).unwrap_or((decode(kv), String::new()))) |
|
| 615 | + | .collect(); |
|
| 616 | + | let signed = vec![ |
|
| 617 | + | ("host".to_string(), header("host")), |
|
| 618 | + | ("x-amz-content-sha256".to_string(), header("x-amz-content-sha256")), |
|
| 619 | + | ("x-amz-date".to_string(), header("x-amz-date")), |
|
| 620 | + | ("x-amz-security-token".to_string(), session.clone()), |
|
| 621 | + | ]; |
|
| 622 | + | let id = format!("ASIA{who}"); |
|
| 623 | + | let secret = format!("secret/{who}"); |
|
| 624 | + | let want = oxygenhub::backups::sigv4( |
|
| 625 | + | parts.method.as_str(), |
|
| 626 | + | &path, |
|
| 627 | + | &query, |
|
| 628 | + | &signed, |
|
| 629 | + | &header("x-amz-content-sha256"), |
|
| 630 | + | (&id, &secret), |
|
| 631 | + | "eu-west-2", |
|
| 632 | + | "s3", |
|
| 633 | + | &header("x-amz-date"), |
|
| 634 | + | ); |
|
| 635 | + | use sha2::Digest; |
|
| 636 | + | let body_hash = hex::encode(sha2::Sha256::digest(&bytes)); |
|
| 637 | + | if header("authorization") != want || header("x-amz-content-sha256") != body_hash { |
|
| 638 | + | return ( |
|
| 639 | + | StatusCode::FORBIDDEN, |
|
| 640 | + | "<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match</Message></Error>", |
|
| 641 | + | ) |
|
| 642 | + | .into_response(); |
|
| 643 | + | } |
|
| 644 | + | if who != owner { |
|
| 645 | + | return (StatusCode::FORBIDDEN, "<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>").into_response(); |
|
| 646 | + | } |
|
| 647 | + | let mut g = g.lock().unwrap(); |
|
| 648 | + | match (parts.method.as_str(), key.is_empty()) { |
|
| 649 | + | ("PUT", false) => { |
|
| 650 | + | g.objects.insert((bucket, key), bytes.to_vec()); |
|
| 651 | + | StatusCode::OK.into_response() |
|
| 652 | + | } |
|
| 653 | + | ("GET", false) | ("HEAD", false) => match g.objects.get(&(bucket, key)) { |
|
| 654 | + | Some(b) => b.clone().into_response(), |
|
| 655 | + | None => (StatusCode::NOT_FOUND, "<Error><Code>NoSuchKey</Code></Error>").into_response(), |
|
| 656 | + | }, |
|
| 657 | + | ("DELETE", false) => { |
|
| 658 | + | g.objects.remove(&(bucket, key)); |
|
| 659 | + | StatusCode::NO_CONTENT.into_response() |
|
| 660 | + | } |
|
| 661 | + | ("GET", true) => { |
|
| 662 | + | let prefix = query.iter().find(|(k, _)| k == "prefix").map(|(_, v)| v.clone()).unwrap_or_default(); |
|
| 663 | + | let delimited = query.iter().any(|(k, _)| k == "delimiter"); |
|
| 664 | + | let (mut keys, mut prefixes) = (String::new(), std::collections::BTreeSet::new()); |
|
| 665 | + | for (b, n) in g.objects.keys() { |
|
| 666 | + | if *b != bucket || !n.starts_with(&prefix) { |
|
| 667 | + | continue; |
|
| 668 | + | } |
|
| 669 | + | match n[prefix.len()..].find('/') { |
|
| 670 | + | Some(i) if delimited => { |
|
| 671 | + | prefixes.insert(format!("{prefix}{}", &n[prefix.len()..prefix.len() + i + 1])); |
|
| 672 | + | } |
|
| 673 | + | _ => keys.push_str(&format!("<Contents><Key>{n}</Key></Contents>")), |
|
| 674 | + | } |
|
| 675 | + | } |
|
| 676 | + | let common: String = prefixes.iter().map(|p| format!("<CommonPrefixes><Prefix>{p}</Prefix></CommonPrefixes>")).collect(); |
|
| 677 | + | format!("<ListBucketResult><Prefix>{prefix}</Prefix><IsTruncated>false</IsTruncated>{keys}{common}</ListBucketResult>") |
|
| 678 | + | .into_response() |
|
| 679 | + | } |
|
| 680 | + | _ => StatusCode::METHOD_NOT_ALLOWED.into_response(), |
|
| 681 | + | } |
|
| 682 | + | } |
|
| 683 | + | let app = axum::Router::new().fallback(handle).with_state(state.clone()); |
|
| 684 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 685 | + | let port = listener.local_addr().unwrap().port(); |
|
| 686 | + | tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); |
|
| 687 | + | (format!("http://127.0.0.1:{port}"), state) |
|
| 688 | + | } |
|
| 689 | + | ||
| 690 | + | async fn hub_aws(backups: &Path, aws: &str) -> Hub { |
|
| 691 | + | hermetic(); |
|
| 692 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 693 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 694 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 695 | + | config.backups.directories = vec![backups.to_path_buf()]; |
|
| 696 | + | config.backups.aws_sts_url = aws.to_string(); |
|
| 697 | + | config.backups.s3_url = format!("{aws}/s3/{{bucket}}"); |
|
| 698 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 699 | + | let port = listener.local_addr().unwrap().port(); |
|
| 700 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 701 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 702 | + | let router = oxygenhub::router(app.clone()); |
|
| 703 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 704 | + | Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
|
| 705 | + | } |
|
| 706 | + | ||
| 707 | + | const ROLE: &str = "arn:aws:iam::123456789012:role/oxygenhub-backup"; |
|
| 708 | + | ||
| 709 | + | fn s3(owner: &str, bucket: &str) -> Setting { |
|
| 710 | + | let mut s = Setting::off(owner, "user"); |
|
| 711 | + | s.every = "hourly".into(); |
|
| 712 | + | s.destination = "s3".into(); |
|
| 713 | + | s.target = bucket.into(); |
|
| 714 | + | s.provider = ROLE.into(); |
|
| 715 | + | s.region = "eu-west-2".into(); |
|
| 716 | + | s.prefix = "oxygen hub".into(); |
|
| 717 | + | s |
|
| 718 | + | } |
|
| 719 | + | ||
| 720 | + | /// docs/backups.md §3 (BP4): an S3 bucket, keylessly: the hub's own token |
|
| 721 | + | /// for AWS STS, then signed requests; a second run sends nothing; the |
|
| 722 | + | /// probe leaves the bucket as it was; a fresh hub restores the same |
|
| 723 | + | /// repository from it; another owner's session is refused, saying what the |
|
| 724 | + | /// role needs. |
|
| 725 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 726 | + | async fn an_s3_backup_needs_no_key_and_restores_the_same_repository() { |
|
| 727 | + | let (aws, seen) = fake_aws().await; |
|
| 728 | + | let store = tempfile::tempdir().unwrap(); |
|
| 729 | + | let h = hub_aws(store.path(), &aws).await; |
|
| 730 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 731 | + | db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 732 | + | create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
|
| 733 | + | let work = tempfile::tempdir().unwrap(); |
|
| 734 | + | push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/a b+c.rs", "fn main() {}\n")]); |
|
| 735 | + | ||
| 736 | + | backups::save(&h.app.db, &s3("andy", "andy-backup")).await.unwrap(); |
|
| 737 | + | let first = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 738 | + | assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail); |
|
| 739 | + | { |
|
| 740 | + | let g = seen.lock().unwrap(); |
|
| 741 | + | assert!( |
|
| 742 | + | g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("oxygen hub/runs/{}/DONE", first.at)), |
|
| 743 | + | "under the prefix" |
|
| 744 | + | ); |
|
| 745 | + | assert_eq!(g.exchanged.first().map(|(s, a)| (s.as_str(), a.as_str())), Some(("backup:andy", "sts.amazonaws.com"))); |
|
| 746 | + | } |
|
| 747 | + | tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
|
| 748 | + | let second = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 749 | + | assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail); |
|
| 750 | + | let before = seen.lock().unwrap().objects.len(); |
|
| 751 | + | let dest = backups::destination(&h.app, &s3("andy", "andy-backup")).unwrap(); |
|
| 752 | + | tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap(); |
|
| 753 | + | assert_eq!(seen.lock().unwrap().objects.len(), before, "the probe cleans up"); |
|
| 754 | + | ||
| 755 | + | let empty = tempfile::tempdir().unwrap(); |
|
| 756 | + | let fresh = hub_aws(empty.path(), &aws).await; |
|
| 757 | + | let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &s3("andy", "andy-backup")).unwrap().into(); |
|
| 758 | + | assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]); |
|
| 759 | + | let name = RepoName::parse("andy/demo").unwrap(); |
|
| 760 | + | assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte"); |
|
| 761 | + | ||
| 762 | + | backups::save(&h.app.db, &s3("bob", "andy-backup")).await.unwrap(); |
|
| 763 | + | let refused = backups::run(&h.app, "bob", "now").await.unwrap(); |
|
| 764 | + | assert_eq!(refused.status, "failed"); |
|
| 765 | + | assert!(refused.detail.contains("s3:PutObject") && refused.detail.contains(ROLE), "{}", refused.detail); |
|
| 766 | + | } |
modified crates/hub/tests/issues.rs +32 −0
| … | 998 unchanged lines |
||
| 999 | 999 | assert!(ok.body.contains("Saved."), "{}", ok.body); |
|
| 1000 | 1000 | let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1001 | 1001 | assert_eq!((s.target.as_str(), s.keep_days, s.every.as_str()), ("andy-backup", 7, "hourly")); |
|
| 1002 | + | // An S3 bucket (BP4): its region and the role the hub assumes. |
|
| 1003 | + | let bad = post( |
|
| 1004 | + | app, |
|
| 1005 | + | "/settings/backups", |
|
| 1006 | + | Some(&andy), |
|
| 1007 | + | &format!("_csrf={csrf}&every=hourly&destination=s3&s3_bucket=andy-backup&s3_region=eu-west-2&s3_role=admin&keep_days=30"), |
|
| 1008 | + | ) |
|
| 1009 | + | .await; |
|
| 1010 | + | assert!(bad.body.contains("the IAM role"), "{}", bad.body); |
|
| 1011 | + | let role = "arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Foxygenhub-backup"; |
|
| 1012 | + | let ok = post( |
|
| 1013 | + | app, |
|
| 1014 | + | "/settings/backups", |
|
| 1015 | + | Some(&andy), |
|
| 1016 | + | &format!( |
|
| 1017 | + | "_csrf={csrf}&every=daily&destination=s3&s3_bucket=s3%3A%2F%2Fandy-backup&s3_region=eu-west-2&s3_role={role}&keep_days=30" |
|
| 1018 | + | ), |
|
| 1019 | + | ) |
|
| 1020 | + | .await; |
|
| 1021 | + | assert!(ok.body.contains("Saved.") && ok.body.contains(r#"value="s3" checked"#), "{}", ok.body); |
|
| 1022 | + | let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1023 | + | assert_eq!((s.destination.as_str(), s.target.as_str(), s.region.as_str()), ("s3", "andy-backup", "eu-west-2")); |
|
| 1024 | + | assert_eq!(s.provider, "arn:aws:iam::123456789012:role/oxygenhub-backup"); |
|
| 1025 | + | // Back to GCS for what follows. |
|
| 1026 | + | let ok = post( |
|
| 1027 | + | app, |
|
| 1028 | + | "/settings/backups", |
|
| 1029 | + | Some(&andy), |
|
| 1030 | + | &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=gs%3A%2F%2Fandy-backup&provider={provider}&keep_days=7"), |
|
| 1031 | + | ) |
|
| 1032 | + | .await; |
|
| 1033 | + | assert!(ok.body.contains("Saved.")); |
|
| 1002 | 1034 | // An organisation's backups are its owners' business only. |
|
| 1003 | 1035 | let bob = register(app, "bob").await; |
|
| 1004 | 1036 | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| … | 28 unchanged lines |
modified docs/backups.md +53 −3
| … | 6 unchanged lines |
||
| 7 | 7 | notices, and restore (`oxygenhub admin backup run|restore`). Since |
|
| 8 | 8 | 2026-10-14 (oxygenhub#175) a restore brings back the records too (§7) and |
|
| 9 | 9 | release assets travel with the stores; Admin › Backups (#176); |
|
| 10 | - | per-repository settings (#177). Next: S3. |
|
| 10 | + | per-repository settings (#177); S3 destinations, keylessly (#178). |
|
| 11 | 11 | ||
| 12 | 12 | ## 1. Why |
|
| 13 | 13 | ||
| … | 53 unchanged lines |
||
| 67 | 67 | directories an owner may choose are listed in `hub.toml` |
|
| 68 | 68 | (`[backups] directories = ["/srv/backups"]`), so nobody can write |
|
| 69 | 69 | anywhere else on the host. Each owner gets `DIR/OWNER/`. |
|
| 70 | - | - **Later:** S3 (the same keyless exchange with AWS STS), and another hub. |
|
| 70 | + | - **An S3 bucket, keylessly** (built 2026-10-14, oxygenhub#178): the hub |
|
| 71 | + | mints a 10-minute token with `sub = backup:OWNER` and audience |
|
| 72 | + | `sts.amazonaws.com`, AWS STS's AssumeRoleWithWebIdentity gives an |
|
| 73 | + | hour's credentials of the IAM role the owner names (exchanged again |
|
| 74 | + | five minutes before they run out), and each request is signed with |
|
| 75 | + | Signature Version 4 (checked against AWS's published example). The |
|
| 76 | + | setting holds the bucket, its region and the role's ARN; setup is §6. |
|
| 77 | + | - **Later:** another hub. |
|
| 71 | 78 | ||
| 72 | 79 | Layout at a destination (`PREFIX` = the owner's chosen prefix): |
|
| 73 | 80 | ||
| … | 88 unchanged lines |
||
| 162 | 169 | An organisation does the same with `backup:ORG`. Only that principal can |
|
| 163 | 170 | write the bucket; the hub never holds a key. |
|
| 164 | 171 | ||
| 172 | + | ### 6.1 Setting up an S3 destination (for Andy, by hand) |
|
| 173 | + | ||
| 174 | + | Once per AWS account, the hub as an identity provider (AWS fetches its |
|
| 175 | + | keys from `https://oxygenhub.io/.well-known/openid-configuration`): |
|
| 176 | + | ||
| 177 | + | ``` |
|
| 178 | + | aws iam create-open-id-connect-provider \ |
|
| 179 | + | --url https://oxygenhub.io --client-id-list sts.amazonaws.com |
|
| 180 | + | ``` |
|
| 181 | + | ||
| 182 | + | Once per owner and bucket (here `andymccall`, in `eu-west-2`): |
|
| 183 | + | ||
| 184 | + | ``` |
|
| 185 | + | B=andymccall-oxygen-backup; ACCOUNT=$(aws sts get-caller-identity --query Account --output text) |
|
| 186 | + | aws s3api create-bucket --bucket $B --region eu-west-2 \ |
|
| 187 | + | --create-bucket-configuration LocationConstraint=eu-west-2 |
|
| 188 | + | aws s3api put-bucket-versioning --bucket $B --versioning-configuration Status=Enabled |
|
| 189 | + | aws s3api put-public-access-block --bucket $B --public-access-block-configuration \ |
|
| 190 | + | BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true |
|
| 191 | + | cat > trust.json <<JSON |
|
| 192 | + | {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", |
|
| 193 | + | "Principal": {"Federated": "arn:aws:iam::$ACCOUNT:oidc-provider/oxygenhub.io"}, |
|
| 194 | + | "Action": "sts:AssumeRoleWithWebIdentity", |
|
| 195 | + | "Condition": {"StringEquals": {"oxygenhub.io:aud": "sts.amazonaws.com", |
|
| 196 | + | "oxygenhub.io:sub": "backup:andymccall"}}}]} |
|
| 197 | + | JSON |
|
| 198 | + | cat > policy.json <<JSON |
|
| 199 | + | {"Version": "2012-10-17", "Statement": [ |
|
| 200 | + | {"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::$B"}, |
|
| 201 | + | {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], |
|
| 202 | + | "Resource": "arn:aws:s3:::$B/*"}]} |
|
| 203 | + | JSON |
|
| 204 | + | aws iam create-role --role-name oxygenhub-backup-andymccall --assume-role-policy-document file://trust.json |
|
| 205 | + | aws iam put-role-policy --role-name oxygenhub-backup-andymccall --policy-name backup --policy-document file://policy.json |
|
| 206 | + | ``` |
|
| 207 | + | ||
| 208 | + | Then, in Settings › Backups: destination S3, the bucket, region |
|
| 209 | + | `eu-west-2`, role `arn:aws:iam::ACCOUNT:role/oxygenhub-backup-andymccall`, |
|
| 210 | + | SAVE and TEST. `s3:ListBucket` matters: without it S3 answers a missing |
|
| 211 | + | object with 403 rather than 404, and every run fails. No access key exists |
|
| 212 | + | anywhere. |
|
| 213 | + | ||
| 165 | 214 | ## 7. Restore |
|
| 166 | 215 | ||
| 167 | 216 | **As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR |
|
| … | 37 unchanged lines |
||
| 205 | 254 | end-to-end test. |
|
| 206 | 255 | 2. **BK3:** the GCS destination with the keyless exchange (tested against |
|
| 207 | 256 | a fake STS and a fake GCS), and TEST. |
|
| 208 | - | 3. Admin › Backups, then per-repository settings, then S3. |
|
| 257 | + | 3. Admin › Backups, then per-repository settings, then S3: all built |
|
| 258 | + | 2026-10-14 (#176, #177, #178). |
modified templates/backups.html +10 −2
| … | 17 unchanged lines |
||
| 18 | 18 | <fieldset class="choices"> |
|
| 19 | 19 | <legend class="small mute">Where</legend> |
|
| 20 | 20 | {% if !dirs.is_empty() %}<label class="choice"><input type="radio" name="destination" value="dir"{% if setting.destination == "dir" %} checked{% endif %}> A directory on this hub's host</label>{% endif %} |
|
| 21 | - | <label class="choice"><input type="radio" name="destination" value="gcs"{% if setting.destination == "gcs" || dirs.is_empty() %} checked{% endif %}> A Google Cloud Storage bucket, with no key</label> |
|
| 21 | + | <label class="choice"><input type="radio" name="destination" value="gcs"{% if setting.destination == "gcs" || (dirs.is_empty() && setting.destination != "s3") %} checked{% endif %}> A Google Cloud Storage bucket, with no key</label> |
|
| 22 | + | <label class="choice"><input type="radio" name="destination" value="s3"{% if setting.destination == "s3" %} checked{% endif %}> An Amazon S3 bucket, with no key</label> |
|
| 22 | 23 | </fieldset> |
|
| 23 | 24 | {% if !dirs.is_empty() %} |
|
| 24 | 25 | <label for="dir">Directory</label> |
|
| … | 2 unchanged lines |
||
| 27 | 28 | <label for="bucket">GCS bucket</label> |
|
| 28 | 29 | <input type="text" id="bucket" name="bucket" placeholder="{{ owner }}-oxygen-backup" value="{% if setting.destination == "gcs" %}{{ setting.target }}{% endif %}"> |
|
| 29 | 30 | <label for="provider">Workload identity provider</label> |
|
| 30 | - | <input type="text" id="provider" name="provider" placeholder="//iam.googleapis.com/projects/NUMBER/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io" value="{{ setting.provider }}"> |
|
| 31 | + | <input type="text" id="provider" name="provider" placeholder="//iam.googleapis.com/projects/NUMBER/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io" value="{% if setting.destination == "gcs" %}{{ setting.provider }}{% endif %}"> |
|
| 31 | 32 | <p class="small mute">The hub signs in as <code>backup:{{ owner }}</code> with a token it issues itself; Google exchanges it for a few minutes' access to this bucket. No key file exists. One-time setup: <a href="/help#backups">Help › Backups</a> (docs/backups.md §6).</p> |
|
| 33 | + | <label for="s3_bucket">S3 bucket</label> |
|
| 34 | + | <input type="text" id="s3_bucket" name="s3_bucket" placeholder="{{ owner }}-oxygen-backup" value="{% if setting.destination == "s3" %}{{ setting.target }}{% endif %}"> |
|
| 35 | + | <label for="s3_region">S3 region</label> |
|
| 36 | + | <input type="text" id="s3_region" name="s3_region" placeholder="eu-west-2" value="{{ setting.region }}"> |
|
| 37 | + | <label for="s3_role">IAM role</label> |
|
| 38 | + | <input type="text" id="s3_role" name="s3_role" placeholder="arn:aws:iam::ACCOUNT:role/oxygenhub-backup-{{ owner }}" value="{% if setting.destination == "s3" %}{{ setting.provider }}{% endif %}"> |
|
| 39 | + | <p class="small mute">The hub signs in to AWS as <code>backup:{{ owner }}</code> with a token it issues itself (audience <code>sts.amazonaws.com</code>); AWS gives it an hour's credentials of this role. No access key exists. One-time setup: docs/backups.md §6.</p> |
|
| 32 | 40 | <label for="prefix">Prefix (optional)</label> |
|
| 33 | 41 | <input type="text" id="prefix" name="prefix" value="{{ setting.prefix }}" placeholder="oxygenhub"> |
|
| 34 | 42 | <label for="keep_days">Keep runs for</label> |
|
| … | 28 unchanged lines |