OXYGENHUB

feat: keyless GCS backups, and TEST (closes #169) 35fea4237cc9

A GCS destination signs in without a key: the hub mints a ten-minute token
as backup:OWNER for the owner's workload identity provider, Google's STS
exchanges it for an access token scoped devstorage.read_write, and the
Cloud Storage JSON API takes the run's blobs, manifests and records; the
access token is exchanged again before it runs out, and a refusal says
which role to grant to which principal. TEST writes, reads and removes a
probe at the saved destination. Tested against a fake STS and GCS that
check the subject, the audience and the bucket's owner: a backup, a second
run sending nothing, a probe, a restore into a fresh hub byte for byte, and
another owner refused.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 23:01 UTC
Signature
✔ signed by Andy McCall
Hash
35fea4237cc9db585d05ea889d741c2bbd6341c6e63bd47dde6aa90884f76d06
Depends on
19bb31ff1f5c 6dbd999ad794 93be9ce4653e a7e6e8e6094c b5668108487f c9aacc0e7dc1 d1727c573f30 d23464c0ac60 d7d182467382
Depended on by
9041db4e4d92 32d97df55ab7 0cd118748131 2e9188ca4f98 7b9f4570daf5 5ef6cf72cd19
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
30 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified CHANGELOG.md +1 −1
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 -
- **Backups (#169, docs/backups.md):** a user (Settings › Backups) or an organisation's owners copy every repository they own, with its wiki, issues, proposals, releases and settings (never a secret value), hourly or daily, to a directory the hub allows. Only what changed travels, runs are kept 7, 30 or 90 days, a failure lands in the owner's inbox, and `oxygenhub admin backup restore` brings repositories back byte for byte. GCS, with no key, is next.
7 +
- **Backups (#169, docs/backups.md):** a user (Settings › Backups) or an organisation's owners copy every repository they own, with its wiki, issues, proposals, releases and settings (never a secret value), hourly or daily, to a directory the hub allows or a Google Cloud Storage bucket with no key at all (the hub signs in as `backup:OWNER` through workload identity federation; TEST checks it). Only what changed travels, runs are kept 7, 30 or 90 days, a failure lands in the owner's inbox, and `oxygenhub admin backup restore` brings repositories back byte for byte.
8 8
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
9 9
- **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension.
10 10
- **The card layout everywhere (#159–#164):** the About card's counts in its heading and its buttons centred; wider repository cards with each Diffusion row on one line; the Code button and + New project in the accent blue; proposal and issue sidebars, profile and organisation pages, search results and the wiki's sidebar as cards; every panel's and form card's heading a shaded bar.
…
169 unchanged lines
modified crates/hub/src/backups.rs +256 −10
…
175 unchanged lines
176 176
    }
177 177
}
178 178
179 +
/// A Cloud Storage bucket, reached keylessly (docs/backups.md §3, §6):
180 +
/// the hub mints a short token as `backup:OWNER`, Google's STS exchanges
181 +
/// it for an access token through the owner's workload identity
182 +
/// provider, and that writes the bucket. No key exists anywhere; the
183 +
/// access token lasts an hour and is fetched again before it runs out.
184 +
pub struct Gcs {
185 +
    bucket: String,
186 +
    /// Keys go under this, when set.
187 +
    prefix: String,
188 +
    storage: String,
189 +
    sts: String,
190 +
    /// The provider's resource name, `//iam.googleapis.com/projects/…`.
191 +
    provider: String,
192 +
    subject: String,
193 +
    app: Shared,
194 +
    client: reqwest::Client,
195 +
    rt: tokio::runtime::Handle,
196 +
    token: std::sync::Mutex<Option<(String, i64)>>,
197 +
}
198 +
199 +
impl Gcs {
200 +
    pub fn new(app: &Shared, s: &Setting) -> Gcs {
201 +
        Gcs {
202 +
            bucket: s.target.clone(),
203 +
            prefix: s.prefix.trim_matches('/').to_string(),
204 +
            storage: app.config.backups.storage_url.trim_end_matches('/').to_string(),
205 +
            sts: app.config.backups.sts_url.trim_end_matches('/').to_string(),
206 +
            provider: s.provider.clone(),
207 +
            subject: format!("backup:{}", s.owner),
208 +
            app: app.clone(),
209 +
            client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(),
210 +
            rt: tokio::runtime::Handle::current(),
211 +
            token: std::sync::Mutex::new(None),
212 +
        }
213 +
    }
214 +
215 +
    fn name(&self, key: &str) -> String {
216 +
        if self.prefix.is_empty() {
217 +
            key.to_string()
218 +
        } else {
219 +
            format!("{}/{key}", self.prefix)
220 +
        }
221 +
    }
222 +
223 +
    /// An access token, exchanged for a fresh one of the hub's when the
224 +
    /// last is within five minutes of running out.
225 +
    fn access(&self) -> Result<String> {
226 +
        let now = db::now_ms();
227 +
        if let Some((t, until)) = self.token.lock().unwrap().as_ref() {
228 +
            if *until - now > 300_000 {
229 +
                return Ok(t.clone());
230 +
            }
231 +
        }
232 +
        let (token, expires_in) = self.rt.block_on(async {
233 +
            let issuer = self.app.oidc().await?;
234 +
            // Google's default audience for a provider: its name over https.
235 +
            let audience = format!("https:{}", self.provider);
236 +
            let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, &audience, db::now_ms(), 600);
237 +
            let res = self
238 +
                .client
239 +
                .post(format!("{}/v1/token", self.sts))
240 +
                .json(&serde_json::json!({
241 +
                    "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
242 +
                    "audience": self.provider,
243 +
                    "scope": "https://www.googleapis.com/auth/devstorage.read_write",
244 +
                    "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
245 +
                    "subjectToken": jwt,
246 +
                    "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
247 +
                }))
248 +
                .send()
249 +
                .await?;
250 +
            let status = res.status();
251 +
            let body: serde_json::Value = res.json().await.unwrap_or_default();
252 +
            if !status.is_success() {
253 +
                bail!(
254 +
                    "Google's token exchange refused {}: {} (check the provider's issuer is this hub's address and its attribute condition admits backup:)",
255 +
                    self.subject,
256 +
                    body["error_description"].as_str().or(body["error"].as_str()).unwrap_or("no reason given")
257 +
                );
258 +
            }
259 +
            let token = body["access_token"].as_str().context("the token exchange gave no access token")?.to_string();
260 +
            anyhow::Ok((token, body["expires_in"].as_i64().unwrap_or(3600)))
261 +
        })?;
262 +
        *self.token.lock().unwrap() = Some((token.clone(), now + expires_in * 1000));
263 +
        Ok(token)
264 +
    }
265 +
266 +
    fn object_url(&self, key: &str) -> Result<reqwest::Url> {
267 +
        let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
268 +
        u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&self.name(key));
269 +
        Ok(u)
270 +
    }
271 +
272 +
    fn refused(&self, what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
273 +
        let hint = if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::UNAUTHORIZED {
274 +
            format!(" (grant roles/storage.objectAdmin on gs://{} to the principal …/subject/{})", self.bucket, self.subject)
275 +
        } else {
276 +
            String::new()
277 +
        };
278 +
        anyhow::anyhow!("GCS {what}: {status}{hint}: {}", body.chars().take(200).collect::<String>())
279 +
    }
280 +
281 +
    /// Every object name under `full` (no delimiter), for removal.
282 +
    fn all_under(&self, full: &str) -> Result<Vec<String>> {
283 +
        let mut out = Vec::new();
284 +
        let mut page: Option<String> = None;
285 +
        loop {
286 +
            let token = self.access()?;
287 +
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
288 +
            u.query_pairs_mut().append_pair("prefix", full).append_pair("fields", "items(name),nextPageToken");
289 +
            if let Some(p) = &page {
290 +
                u.query_pairs_mut().append_pair("pageToken", p);
291 +
            }
292 +
            let body: serde_json::Value = self.rt.block_on(async {
293 +
                let res = self.client.get(u).bearer_auth(&token).send().await?;
294 +
                let status = res.status();
295 +
                let text = res.text().await?;
296 +
                if !status.is_success() {
297 +
                    return Err(self.refused("list", status, &text));
298 +
                }
299 +
                anyhow::Ok(serde_json::from_str(&text)?)
300 +
            })?;
301 +
            out.extend(body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str().map(String::from)));
302 +
            match body["nextPageToken"].as_str() {
303 +
                Some(p) => page = Some(p.to_string()),
304 +
                None => return Ok(out),
305 +
            }
306 +
        }
307 +
    }
308 +
}
309 +
310 +
impl Destination for Gcs {
311 +
    fn exists(&self, key: &str) -> Result<bool> {
312 +
        let token = self.access()?;
313 +
        let mut u = self.object_url(key)?;
314 +
        u.query_pairs_mut().append_pair("fields", "name");
315 +
        self.rt.block_on(async {
316 +
            let res = self.client.get(u).bearer_auth(&token).send().await?;
317 +
            match res.status() {
318 +
                s if s.is_success() => Ok(true),
319 +
                reqwest::StatusCode::NOT_FOUND => Ok(false),
320 +
                s => Err(self.refused("read", s, &res.text().await.unwrap_or_default())),
321 +
            }
322 +
        })
323 +
    }
324 +
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
325 +
        let token = self.access()?;
326 +
        let mut u = reqwest::Url::parse(&format!("{}/upload/storage/v1/b/{}/o", self.storage, self.bucket))?;
327 +
        u.query_pairs_mut().append_pair("uploadType", "media").append_pair("name", &self.name(key));
328 +
        let body = bytes.to_vec();
329 +
        self.rt.block_on(async {
330 +
            let res = self.client.post(u).bearer_auth(&token).header("content-type", "application/octet-stream").body(body).send().await?;
331 +
            let status = res.status();
332 +
            if !status.is_success() {
333 +
                return Err(self.refused("write", status, &res.text().await.unwrap_or_default()));
334 +
            }
335 +
            Ok(())
336 +
        })
337 +
    }
338 +
    fn get(&self, key: &str) -> Result<Vec<u8>> {
339 +
        let token = self.access()?;
340 +
        let mut u = self.object_url(key)?;
341 +
        u.query_pairs_mut().append_pair("alt", "media");
342 +
        self.rt.block_on(async {
343 +
            let res = self.client.get(u).bearer_auth(&token).send().await?;
344 +
            let status = res.status();
345 +
            if !status.is_success() {
346 +
                return Err(self.refused(&format!("read {key}"), status, &res.text().await.unwrap_or_default()));
347 +
            }
348 +
            Ok(res.bytes().await?.to_vec())
349 +
        })
350 +
    }
351 +
    fn list(&self, prefix: &str) -> Result<Vec<String>> {
352 +
        let full = format!("{}/", self.name(prefix.trim_end_matches('/')));
353 +
        let mut out = std::collections::BTreeSet::new();
354 +
        let mut page: Option<String> = None;
355 +
        loop {
356 +
            let token = self.access()?;
357 +
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
358 +
            u.query_pairs_mut()
359 +
                .append_pair("prefix", &full)
360 +
                .append_pair("delimiter", "/")
361 +
                .append_pair("fields", "items(name),prefixes,nextPageToken");
362 +
            if let Some(p) = &page {
363 +
                u.query_pairs_mut().append_pair("pageToken", p);
364 +
            }
365 +
            let body: serde_json::Value = self.rt.block_on(async {
366 +
                let res = self.client.get(u).bearer_auth(&token).send().await?;
367 +
                let status = res.status();
368 +
                let text = res.text().await?;
369 +
                if !status.is_success() {
370 +
                    return Err(self.refused("list", status, &text));
371 +
                }
372 +
                anyhow::Ok(serde_json::from_str(&text)?)
373 +
            })?;
374 +
            for n in body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str()) {
375 +
                out.insert(n.trim_start_matches(&full).to_string());
376 +
            }
377 +
            for p in body["prefixes"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) {
378 +
                out.insert(p.trim_start_matches(&full).trim_end_matches('/').to_string());
379 +
            }
380 +
            match body["nextPageToken"].as_str() {
381 +
                Some(p) => page = Some(p.to_string()),
382 +
                None => return Ok(out.into_iter().filter(|n| !n.is_empty()).collect()),
383 +
            }
384 +
        }
385 +
    }
386 +
    fn remove(&self, key: &str) -> Result<()> {
387 +
        let mut names = self.all_under(&format!("{}/", self.name(key)))?;
388 +
        names.push(self.name(key));
389 +
        for n in names {
390 +
            let token = self.access()?;
391 +
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
392 +
            u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&n);
393 +
            self.rt.block_on(async {
394 +
                let res = self.client.delete(u).bearer_auth(&token).send().await?;
395 +
                match res.status() {
396 +
                    s if s.is_success() || s == reqwest::StatusCode::NOT_FOUND => Ok(()),
397 +
                    s => Err(self.refused("delete", s, &res.text().await.unwrap_or_default())),
398 +
                }
399 +
            })?;
400 +
        }
401 +
        Ok(())
402 +
    }
403 +
}
404 +
405 +
/// Check a destination end to end: write a probe, read it back, remove it.
406 +
pub fn probe(dest: &dyn Destination) -> Result<()> {
407 +
    let marker = format!("probe {}", db::now_ms());
408 +
    dest.put(".oxyhub-probe", marker.as_bytes())?;
409 +
    if dest.get(".oxyhub-probe")? != marker.as_bytes() {
410 +
        bail!("the probe read back differently");
411 +
    }
412 +
    dest.remove(".oxyhub-probe")
413 +
}
414 +
179 415
/// The owner's destination, from its setting and the site's rules.
180 416
pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> {
181 417
    match s.destination.as_str() {
…
8 unchanged lines
190 426
                root = root.join(s.prefix.trim_matches('/'));
191 427
            }
192 428
            Ok(Box::new(Directory { root }))
429 +
        }
430 +
        "gcs" => {
431 +
            if s.target.is_empty() || s.provider.is_empty() {
432 +
                bail!("a GCS destination needs its bucket and its workload identity provider");
433 +
            }
434 +
            Ok(Box::new(Gcs::new(app, s)))
193 435
        }
194 -
        "gcs" => bail!("GCS destinations come with oxygenhub#169's next step"),
195 436
        other => bail!("unknown destination {other:?}"),
196 437
    }
197 438
}
…
274 unchanged lines
472 713
                    sent.bytes += s1.bytes + s2.bytes;
473 714
                    let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files };
474 715
                    let key = format!("runs/{at}/repos/{kind}-{}.json", row.name);
475 -
                    dest.put(&key, &serde_json::to_vec(&m)?)?;
716 +
                    tokio::task::block_in_place(|| dest.put(&key, &serde_json::to_vec(&m)?))?;
476 717
                    if kind == "repos" {
477 718
                        repos += 1;
478 719
                    }
…
11 unchanged lines
490 731
            anyhow::Ok(r)
491 732
        };
492 733
        match rec.await {
493 -
            Ok(r) => dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?)?,
734 +
            Ok(r) => tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?,
494 735
            Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)),
495 736
        }
496 737
    }
…
9 unchanged lines
506 747
            None => serde_json::json!({}),
507 748
        }
508 749
    };
509 -
    dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?;
510 -
    dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?;
511 -
    dest.put("LATEST", at.as_bytes())?;
512 -
    prune(dest.as_ref(), s.keep_days)?;
750 +
    // The blocking calls of a destination (GCS drives its client on this
751 +
    // runtime) run where blocking is allowed.
752 +
    tokio::task::block_in_place(|| -> Result<()> {
753 +
        dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?;
754 +
        dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?;
755 +
        dest.put("LATEST", at.as_bytes())?;
756 +
        prune(dest.as_ref(), s.keep_days)
757 +
    })?;
513 758
    Ok(((repos, sent), problems))
514 759
}
515 760
…
160 unchanged lines
676 921
    let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??;
677 922
    let at = match at {
678 923
        Some(a) => a,
679 -
        None => String::from_utf8_lossy(&dest.get("LATEST")?).trim().to_string(),
924 +
        None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(),
680 925
    };
681 -
    for name in dest.list(&format!("runs/{at}/records"))? {
682 -
        let rec: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{at}/records/{name}"))?)?;
926 +
    for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? {
927 +
        let rec: serde_json::Value =
928 +
            serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?;
683 929
        let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue };
684 930
        let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default());
685 931
        if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() {
…
58 unchanged lines
modified crates/hub/src/config.rs +15 −1
…
26 unchanged lines
27 27
28 28
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
29 29
/// an owner may back up to. Empty, only GCS destinations can be chosen.
30 -
#[derive(Clone, Debug, Default, Deserialize)]
30 +
#[derive(Clone, Debug, Deserialize)]
31 31
#[serde(default, deny_unknown_fields)]
32 32
pub struct Backups {
33 33
    pub directories: Vec<PathBuf>,
34 +
    /// Google's token exchange and Cloud Storage, for GCS destinations;
35 +
    /// set only to point them elsewhere (tests, a private endpoint).
36 +
    pub sts_url: String,
37 +
    pub storage_url: String,
38 +
}
39 +
40 +
impl Default for Backups {
41 +
    fn default() -> Self {
42 +
        Backups {
43 +
            directories: Vec::new(),
44 +
            sts_url: "https://sts.googleapis.com".into(),
45 +
            storage_url: "https://storage.googleapis.com".into(),
46 +
        }
47 +
    }
34 48
}
35 49
36 50
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
…
364 unchanged lines
modified crates/hub/src/oidc.rs +19 −0
…
134 unchanged lines
135 135
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
136 136
    }
137 137
138 +
    /// A signed token for the hub itself acting as `subject` (a backup's
139 +
    /// `backup:OWNER`, oxygenhub#169), for `audience`, living `ttl_s`.
140 +
    pub fn mint_for(&self, issuer: &str, subject: &str, audience: &str, now_ms: i64, ttl_s: u64) -> String {
141 +
        let iat = now_ms / 1000;
142 +
        let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid });
143 +
        let payload = serde_json::json!({
144 +
            "iss": issuer,
145 +
            "sub": subject,
146 +
            "aud": audience,
147 +
            "iat": iat,
148 +
            "nbf": iat - 30,
149 +
            "exp": iat + ttl_s.clamp(60, MAX_TTL_S) as i64,
150 +
            "jti": crate::auth::random_hex(16),
151 +
        });
152 +
        let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes()));
153 +
        let sig = self.signing.sign(signing_input.as_bytes());
154 +
        format!("{signing_input}.{}", b64url(&sig.to_bytes()))
155 +
    }
156 +
138 157
    /// The claims of a token this hub signed, when its signature holds,
139 158
    /// it is for `audience` and it has not expired at `now_ms`.
140 159
    pub fn verify(&self, token: &str, audience: &str, now_ms: i64) -> Option<serde_json::Value> {
…
147 unchanged lines
modified crates/hub/src/web/backups.rs +39 −0
…
146 unchanged lines
147 147
    page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await
148 148
}
149 149
150 +
/// TEST: reach the saved destination and write, read and remove a probe.
151 +
async fn test(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
152 +
    let Some(set) = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
153 +
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
154 +
    };
155 +
    let outcome = match backups::destination(app, &set) {
156 +
        Ok(dest) => {
157 +
            tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap_or_else(|e| Err(anyhow::anyhow!("{e}")))
158 +
        }
159 +
        Err(e) => Err(e),
160 +
    };
161 +
    match outcome {
162 +
        Ok(()) => {
163 +
            let place = if set.destination == "gcs" { format!("gs://{}", set.target) } else { format!("{}/{owner}", set.target) };
164 +
            page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await
165 +
        }
166 +
        Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await,
167 +
    }
168 +
}
169 +
150 170
#[derive(Deserialize)]
151 171
pub struct Csrf {
152 172
    #[serde(rename = "_csrf")]
…
48 unchanged lines
201 221
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
202 222
    csrf(&viewer, &f.csrf_token, &shell)?;
203 223
    run_now(&app, shell, "org", &org.name).await
224 +
}
225 +
226 +
pub async fn user_test(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
227 +
    let shell = Shell::new(&app, "/settings/backups", &viewer);
228 +
    let me = require(&viewer, &shell)?;
229 +
    csrf(&viewer, &f.csrf_token, &shell)?;
230 +
    test(&app, shell, "user", &me.account.username).await
231 +
}
232 +
233 +
pub async fn org_test(
234 +
    State(app): State<Shared>,
235 +
    viewer: Viewer,
236 +
    Path(name): Path<String>,
237 +
    Form(f): Form<Csrf>,
238 +
) -> Result<Page<BackupsPage>, Error> {
239 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
240 +
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
241 +
    csrf(&viewer, &f.csrf_token, &shell)?;
242 +
    test(&app, shell, "org", &org.name).await
204 243
}
modified crates/hub/src/web/mod.rs +2 −0
…
90 unchanged lines
91 91
        .route("/settings/tokens", get(settings::tokens).post(settings::tokens_create))
92 92
        .route("/settings/backups", get(backups::user).post(backups::user_save))
93 93
        .route("/settings/backups/run", post(backups::user_run))
94 +
        .route("/settings/backups/test", post(backups::user_test))
94 95
        .route("/orgs/{name}/settings/backups", get(backups::org).post(backups::org_save))
95 96
        .route("/orgs/{name}/settings/backups/run", post(backups::org_run))
97 +
        .route("/orgs/{name}/settings/backups/test", post(backups::org_test))
96 98
        .route("/settings/tokens/{id}/delete", post(settings::tokens_delete))
97 99
        .route("/new", get(repo_settings::new_form).post(repo_settings::create))
98 100
        .route("/orgs/new", get(orgs::new_form).post(orgs::create))
…
652 unchanged lines
modified crates/hub/tests/backups.rs +204 −0
…
27 unchanged lines
28 28
}
29 29
30 30
async fn hub(backups: &Path) -> Hub {
31 +
    hub_with(backups, None).await
32 +
}
33 +
34 +
async fn hub_with(backups: &Path, google: Option<&str>) -> Hub {
31 35
    hermetic();
32 36
    let dir = tempfile::tempdir().unwrap();
33 37
    let mut config = Config::load(dir.path()).unwrap();
34 38
    config.auth.push_tokens = vec!["site".into()];
35 39
    config.backups.directories = vec![backups.to_path_buf()];
40 +
    if let Some(g) = google {
41 +
        config.backups.sts_url = g.to_string();
42 +
        config.backups.storage_url = g.to_string();
43 +
    }
36 44
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
37 45
    let port = listener.local_addr().unwrap().port();
38 46
    config.server.base_url = format!("http://127.0.0.1:{port}");
…
123 unchanged lines
162 170
        .await
163 171
        .unwrap();
164 172
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now");
173 +
}
174 +
175 +
// ------------------------------------------------------------ a fake Google
176 +
177 +
/// What the fake has seen and holds.
178 +
#[derive(Default)]
179 +
struct Google {
180 +
    /// Objects by bucket and name.
181 +
    objects: std::collections::BTreeMap<(String, String), Vec<u8>>,
182 +
    /// The subjects and audiences of the tokens exchanged.
183 +
    exchanged: Vec<(String, String)>,
184 +
}
185 +
186 +
type Shared2 = Arc<std::sync::Mutex<Google>>;
187 +
188 +
fn decode(s: &str) -> String {
189 +
    let b = s.as_bytes();
190 +
    let mut out = Vec::new();
191 +
    let mut i = 0;
192 +
    while i < b.len() {
193 +
        if b[i] == b'%' && i + 2 < b.len() {
194 +
            out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap());
195 +
            i += 3;
196 +
        } else {
197 +
            out.push(b[i]);
198 +
            i += 1;
199 +
        }
200 +
    }
201 +
    String::from_utf8(out).unwrap()
202 +
}
203 +
204 +
fn query(uri: &axum::http::Uri, key: &str) -> Option<String> {
205 +
    uri.query()?.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| decode(v)))
206 +
}
207 +
208 +
/// Google's STS and Cloud Storage, as much as backups use: a token
209 +
/// exchange that admits `backup:` subjects for the provider's audience,
210 +
/// and buckets that admit only their owner's token (bucket `OWNER-backup`).
211 +
async fn fake_google() -> (String, Shared2) {
212 +
    use axum::extract::{Request, State};
213 +
    use axum::http::StatusCode;
214 +
    use axum::response::IntoResponse;
215 +
    let state: Shared2 = Default::default();
216 +
    async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response {
217 +
        let (parts, body) = req.into_parts();
218 +
        let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes();
219 +
        let path = parts.uri.path().to_string();
220 +
        if path == "/v1/token" {
221 +
            let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
222 +
            let jwt = v["subjectToken"].as_str().unwrap();
223 +
            let payload = jwt.split('.').nth(1).unwrap();
224 +
            let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)).unwrap();
225 +
            let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string());
226 +
            let provider = v["audience"].as_str().unwrap();
227 +
            g.lock().unwrap().exchanged.push((sub.clone(), aud.clone()));
228 +
            if !sub.starts_with("backup:") || aud != format!("https:{provider}") {
229 +
                return (
230 +
                    StatusCode::BAD_REQUEST,
231 +
                    axum::Json(serde_json::json!({"error": "invalid_grant", "error_description": "not admitted"})),
232 +
                )
233 +
                    .into_response();
234 +
            }
235 +
            return axum::Json(serde_json::json!({ "access_token": format!("tok-{sub}"), "expires_in": 3600 })).into_response();
236 +
        }
237 +
        let auth = parts.headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
238 +
        let segs: Vec<&str> = path.split('/').collect();
239 +
        // /storage/v1/b/{bucket}/o[/{name}]  or  /upload/storage/v1/b/{bucket}/o
240 +
        let upload = segs.get(1) == Some(&"upload");
241 +
        let at = if upload { 5 } else { 4 };
242 +
        let bucket = segs.get(at).unwrap_or(&"").to_string();
243 +
        let owner = bucket.trim_end_matches("-backup");
244 +
        if auth != format!("Bearer tok-backup:{owner}") {
245 +
            return (StatusCode::FORBIDDEN, "caller does not have storage.objects access").into_response();
246 +
        }
247 +
        let mut g = g.lock().unwrap();
248 +
        if upload {
249 +
            let name = query(&parts.uri, "name").unwrap();
250 +
            g.objects.insert((bucket, name), bytes.to_vec());
251 +
            return axum::Json(serde_json::json!({})).into_response();
252 +
        }
253 +
        let name = segs.get(at + 2).map(|n| decode(n));
254 +
        match (parts.method.as_str(), name) {
255 +
            ("GET", Some(n)) => match g.objects.get(&(bucket, n.clone())) {
256 +
                Some(b) if query(&parts.uri, "alt").as_deref() == Some("media") => b.clone().into_response(),
257 +
                Some(_) => axum::Json(serde_json::json!({ "name": n })).into_response(),
258 +
                None => StatusCode::NOT_FOUND.into_response(),
259 +
            },
260 +
            ("DELETE", Some(n)) => {
261 +
                g.objects.remove(&(bucket, n));
262 +
                StatusCode::NO_CONTENT.into_response()
263 +
            }
264 +
            ("GET", None) => {
265 +
                let prefix = query(&parts.uri, "prefix").unwrap_or_default();
266 +
                let delimited = query(&parts.uri, "delimiter").is_some();
267 +
                let (mut items, mut prefixes) = (Vec::new(), std::collections::BTreeSet::new());
268 +
                for (b, n) in g.objects.keys() {
269 +
                    if *b != bucket || !n.starts_with(&prefix) {
270 +
                        continue;
271 +
                    }
272 +
                    let rest = &n[prefix.len()..];
273 +
                    match rest.find('/') {
274 +
                        Some(i) if delimited => {
275 +
                            prefixes.insert(format!("{prefix}{}", &rest[..=i]));
276 +
                        }
277 +
                        _ => items.push(serde_json::json!({ "name": n })),
278 +
                    }
279 +
                }
280 +
                axum::Json(serde_json::json!({ "items": items, "prefixes": prefixes })).into_response()
281 +
            }
282 +
            _ => StatusCode::METHOD_NOT_ALLOWED.into_response(),
283 +
        }
284 +
    }
285 +
    let app = axum::Router::new().fallback(handle).with_state(state.clone());
286 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
287 +
    let port = listener.local_addr().unwrap().port();
288 +
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
289 +
    (format!("http://127.0.0.1:{port}"), state)
290 +
}
291 +
292 +
fn base64url(s: &str) -> Vec<u8> {
293 +
    let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
294 +
    let mut bits = 0u32;
295 +
    let mut n = 0;
296 +
    let mut out = Vec::new();
297 +
    for c in s.bytes() {
298 +
        let v = table.iter().position(|t| *t == c).unwrap() as u32;
299 +
        bits = (bits << 6) | v;
300 +
        n += 6;
301 +
        if n >= 8 {
302 +
            n -= 8;
303 +
            out.push((bits >> n) as u8);
304 +
            bits &= (1 << n) - 1;
305 +
        }
306 +
    }
307 +
    out
308 +
}
309 +
310 +
const PROVIDER: &str = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io";
311 +
312 +
fn gcs(owner: &str, bucket: &str) -> Setting {
313 +
    let mut s = Setting::off(owner, "user");
314 +
    s.every = "hourly".into();
315 +
    s.destination = "gcs".into();
316 +
    s.target = bucket.into();
317 +
    s.provider = PROVIDER.into();
318 +
    s
319 +
}
320 +
321 +
#[tokio::test(flavor = "multi_thread")]
322 +
async fn a_gcs_backup_needs_no_key_and_restores_the_same_repository() {
323 +
    let (google, seen) = fake_google().await;
324 +
    let store = tempfile::tempdir().unwrap();
325 +
    let h = hub_with(store.path(), Some(&google)).await;
326 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
327 +
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
328 +
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
329 +
    let work = tempfile::tempdir().unwrap();
330 +
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]);
331 +
332 +
    backups::save(&h.app.db, &gcs("andy", "andy-backup")).await.unwrap();
333 +
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
334 +
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
335 +
    {
336 +
        let g = seen.lock().unwrap();
337 +
        assert!(g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("runs/{}/DONE", first.at)));
338 +
        assert!(g.objects.keys().all(|(b, _)| b == "andy-backup"));
339 +
        let (sub, aud) = g.exchanged.first().unwrap();
340 +
        assert_eq!(
341 +
            (sub.as_str(), aud.as_str()),
342 +
            ("backup:andy", format!("https:{PROVIDER}").as_str()),
343 +
            "the hub signs in as the owner, for the provider"
344 +
        );
345 +
    }
346 +
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
347 +
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
348 +
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
349 +
    // The probe writes, reads and removes; the bucket is as it was.
350 +
    let before = seen.lock().unwrap().objects.len();
351 +
    let dest = backups::destination(&h.app, &gcs("andy", "andy-backup")).unwrap();
352 +
    tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap();
353 +
    assert_eq!(seen.lock().unwrap().objects.len(), before);
354 +
355 +
    // Restored from the bucket into a fresh hub: the same repository.
356 +
    let empty = tempfile::tempdir().unwrap();
357 +
    let fresh = hub_with(empty.path(), Some(&google)).await;
358 +
    let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &gcs("andy", "andy-backup")).unwrap().into();
359 +
    assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]);
360 +
    let name = RepoName::parse("andy/demo").unwrap();
361 +
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
362 +
363 +
    // Bob cannot write Andy's bucket: Google refuses his token, and the
364 +
    // run says what to grant.
365 +
    backups::save(&h.app.db, &gcs("bob", "andy-backup")).await.unwrap();
366 +
    let refused = backups::run(&h.app, "bob", "now").await.unwrap();
367 +
    assert_eq!(refused.status, "failed");
368 +
    assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail);
165 369
}
modified docs/backups.md +15 −11
1 1
# Backups: per site, organisation, user and repository
2 2
3 -
Status: BK2 built 2026-10-13 (oxygenhub#169): user and organisation
4 -
settings, the hourly and daily schedule with "back up now", directory
5 -
destinations, every repository's and wiki's store byte for byte, the
6 -
records as JSON, retention, failure notices and restore of the stores and
7 -
repository rows (`oxygenhub admin backup run|restore`). Next: the GCS
8 -
destination (BK3), then restoring issues and proposals from the records. Hourly backups configured by a
9 -
user and by an organisation come first; finer-grained ones (per
10 -
repository, more often than hourly) later. The site's own backup
11 -
(everything that runs a hub, #168) is an operations script today
12 -
(`~/backups/oxygenhub/backup.sh`); Admin › Backups shows it later.
3 +
Status: built 2026-10-13/14 (oxygenhub#169): user and organisation
4 +
settings, the hourly and daily schedule with "back up now" and TEST,
5 +
directory destinations and keyless GCS destinations, every repository's
6 +
and wiki's store byte for byte, the records as JSON, retention, failure
7 +
notices, and restore of the stores and repository rows
8 +
(`oxygenhub admin backup run|restore`). Next: restoring issues and
9 +
proposals from the records, Admin › Backups, per-repository settings.
13 10
14 11
## 1. Why
15 12
…
39 unchanged lines
55 52
A **destination** is one of:
56 53
57 54
- **A GCS bucket, keylessly.** The hub is already an OIDC issuer (spec
58 -
  §13.5). For a backup it mints a short-lived token with
55 +
  §13.5). Built: the token lives 10 minutes, its audience is the
56 +
  provider's name over https (Google's default), the exchange asks for
57 +
  `devstorage.read_write`, and the access token is exchanged again five
58 +
  minutes before it runs out. A refusal says what to grant. For a backup it mints a short-lived token with
59 59
  `sub = backup:OWNER` and audience the owner's workload identity
60 60
  provider. Google's STS exchanges it for a short-lived access token,
61 61
  which writes to the bucket. Nothing secret is stored: no service
…
77 unchanged lines
139 139
  --member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall"
140 140
```
141 141
142 +
**Done for doomsdayone-oxy on 2026-10-13:** the pool `oxygenhub`, the
143 +
provider `oxygenhub-io` and the buckets `andymccall-oxygen-backup` and
144 +
`doomsdayone-oxygen-backup`, each granted to its own `backup:` subject.
145 +
142 146
Then, in Settings › Backups: destination GCS, bucket
143 147
`andymccall-oxygen-backup`, provider
144 148
`//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`,
…
28 unchanged lines
modified docs/spec.md +2 −2
…
579 unchanged lines
580 580
- **Masking.** Log intake replaces any token this hub signed (recognised by its `kid`) with `***`, as it does secret values.
581 581
- **Trust, for example.** AWS: an IAM OIDC provider with the hub's URL and audience `sts.amazonaws.com`, a role whose trust policy matches `sub` `repo:andymccall/metalgear:bookmark:main`; the job then calls `aws sts assume-role-with-web-identity --web-identity-token file:///run/secrets/aws-token`. Google: a workload identity pool provider with the same issuer, mapping `assertion.sub`. HashiCorp Vault: the JWT auth method with `bound_audiences` and `bound_claims` on `repository`.
582 582
583 -
### 13.5.1 Backups (oxygenhub#169: directory destinations built 2026-10-13, GCS next)
583 +
### 13.5.1 Backups (oxygenhub#169, built 2026-10-13/14)
584 584
585 -
Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. Built: Settings › Backups and an organisation's Settings › Backups (how often: off, hourly or daily; a directory from `hub.toml` `[backups] directories` or a GCS bucket with its workload identity provider; a prefix; runs kept 7, 30 or 90 days; "back up now"; the last 50 runs with their outcome), migration 0060 (`backup_settings`, `backup_runs`), the hub's scheduler (one run at a time; a run left running by a restart is marked failed), each repository's and wiki's store copied file by file under the repository's read lock for the final pass, the records as JSON (every table keyed by the repository, and three levels of rows pointing at them; tables and columns holding secrets, tokens, sessions or passwords never), pruning of old runs and unreferenced blobs, an inbox notice to the owner (an organisation's owners) once per spell of failures, and `oxygenhub admin backup run OWNER` and `oxygenhub admin backup restore --from DIR [--run AT]`, which restores the stores and the repositories' rows. The design, the layout at a destination, setup and restore are in `docs/backups.md`.
585 +
Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. Built: Settings › Backups and an organisation's Settings › Backups (how often: off, hourly or daily; a directory from `hub.toml` `[backups] directories` or a GCS bucket with its workload identity provider; a prefix; runs kept 7, 30 or 90 days; "back up now"; the last 50 runs with their outcome), migration 0060 (`backup_settings`, `backup_runs`), the hub's scheduler (one run at a time; a run left running by a restart is marked failed), each repository's and wiki's store copied file by file under the repository's read lock for the final pass, the records as JSON (every table keyed by the repository, and three levels of rows pointing at them; tables and columns holding secrets, tokens, sessions or passwords never), pruning of old runs and unreferenced blobs, an inbox notice to the owner (an organisation's owners) once per spell of failures, and `oxygenhub admin backup run OWNER` and `oxygenhub admin backup restore --from DIR [--run AT]`, which restores the stores and the repositories' rows. A GCS destination is keyless: the hub mints a 10-minute token (`Issuer::mint_for`, `sub = backup:OWNER`, `aud` the provider's name over https), Google's STS exchanges it (`[backups] sts_url`) for an access token scoped `devstorage.read_write`, used with the Cloud Storage JSON API (`[backups] storage_url`) and exchanged again before it expires; TEST writes, reads and removes a probe. The design, the layout at a destination, setup and restore are in `docs/backups.md`.
586 586
587 587
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
588 588
…
168 unchanged lines
modified templates/backups.html +2 −1
…
37 unchanged lines
38 38
39 39
<section class="card-box" id="runs">
40 40
  <div class="card-head"><h2>Runs</h2><span class="count-chip">{{ runs.len() }}</span>
41 -
    <form class="inline more" method="post" action="{{ base }}/run"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small primary-btn"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %}>Back up now</button></form>
41 +
    <form class="inline more" method="post" action="{{ base }}/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %} title="Write, read and remove a probe at the destination">TEST</button></form>
42 +
    <form class="inline" method="post" action="{{ base }}/run"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small primary-btn"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %}>Back up now</button></form>
42 43
  </div>
43 44
  {% if runs.is_empty() %}<p class="empty small">No runs yet.{% if setting.every == "off" %} Pick how often and where, then save.{% endif %}</p>
44 45
  {% else %}
…
17 unchanged lines