OXYGENHUB

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("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
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&amp;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