2026-04-19 00:42:56 -04:00
|
|
|
|
//! User-triggered FIPS upgrade from the upstream default branch.
|
2026-04-18 22:57:51 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! Flow (no auto-update, no background polling — user clicks a button):
|
2026-04-19 00:42:56 -04:00
|
|
|
|
//! 1. Query GitHub for the upstream repo's default branch, then the
|
|
|
|
|
|
//! latest commit on it. (jmcorgan/fips default is `master`, not
|
|
|
|
|
|
//! `main` — we resolve it dynamically so a future rename Just Works.)
|
2026-04-18 22:57:51 -04:00
|
|
|
|
//! 2. Compare with the installed daemon version reported by
|
|
|
|
|
|
//! `fipsctl --version`. If identical, report "up to date".
|
|
|
|
|
|
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
|
|
|
|
|
|
//! 4. SHA256-verify the download.
|
|
|
|
|
|
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
|
2026-04-19 00:42:56 -04:00
|
|
|
|
//! publish stable release assets for per-commit builds. This module
|
|
|
|
|
|
//! currently implements steps 1–2 (the "is there anything newer?" query)
|
|
|
|
|
|
//! and stubs out 3–5 so the RPC/UI can wire through. The apply path
|
|
|
|
|
|
//! returns a clear "not yet available" error until the artefact source
|
|
|
|
|
|
//! is decided.
|
2026-04-18 22:57:51 -04:00
|
|
|
|
|
|
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
|
|
use super::{service, UPSTREAM_REPO};
|
|
|
|
|
|
|
|
|
|
|
|
const GITHUB_API: &str = "https://api.github.com";
|
|
|
|
|
|
const USER_AGENT: &str = "archipelago-fips-updater";
|
|
|
|
|
|
|
|
|
|
|
|
/// Result of `check_update()` — what the dashboard renders.
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct UpdateCheck {
|
|
|
|
|
|
/// Currently installed daemon version (from `fipsctl --version`).
|
|
|
|
|
|
pub current: Option<String>,
|
|
|
|
|
|
/// Short SHA of the latest commit on upstream `main`.
|
|
|
|
|
|
pub latest_commit: String,
|
|
|
|
|
|
/// True when the installed version string does not mention the latest SHA.
|
|
|
|
|
|
pub update_available: bool,
|
|
|
|
|
|
/// Human-readable note for the UI.
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 00:42:56 -04:00
|
|
|
|
/// Query GitHub for the latest commit on the upstream default branch and
|
|
|
|
|
|
/// compare to the installed version. Never errors on "no package installed"
|
|
|
|
|
|
/// — that is itself a valid state where an update is available.
|
2026-04-18 22:57:51 -04:00
|
|
|
|
pub async fn check() -> Result<UpdateCheck> {
|
|
|
|
|
|
let current = service::daemon_version().await.ok();
|
2026-04-19 00:42:56 -04:00
|
|
|
|
let client = reqwest::Client::builder()
|
|
|
|
|
|
.user_agent(USER_AGENT)
|
|
|
|
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
|
|
|
|
.build()
|
|
|
|
|
|
.context("Build HTTP client")?;
|
|
|
|
|
|
let branch = fetch_default_branch(&client).await?;
|
|
|
|
|
|
let latest = fetch_head_sha(&client, &branch).await?;
|
2026-04-18 22:57:51 -04:00
|
|
|
|
let short = latest.chars().take(7).collect::<String>();
|
|
|
|
|
|
|
|
|
|
|
|
let update_available = match ¤t {
|
|
|
|
|
|
Some(v) => !v.contains(&short),
|
|
|
|
|
|
None => true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let notes = if update_available {
|
|
|
|
|
|
format!(
|
2026-04-19 00:42:56 -04:00
|
|
|
|
"Upstream {} is at {}; installed: {}",
|
|
|
|
|
|
branch,
|
2026-04-18 22:57:51 -04:00
|
|
|
|
short,
|
|
|
|
|
|
current.as_deref().unwrap_or("not installed")
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
2026-04-19 00:42:56 -04:00
|
|
|
|
format!("Up to date ({} @ {})", branch, short)
|
2026-04-18 22:57:51 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Ok(UpdateCheck {
|
|
|
|
|
|
current,
|
|
|
|
|
|
latest_commit: short,
|
|
|
|
|
|
update_available,
|
|
|
|
|
|
notes,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply the update. Stubbed pending a stable artefact source for
|
|
|
|
|
|
/// per-commit builds of the `fips` debian package. When this is wired
|
|
|
|
|
|
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
|
|
|
|
|
|
pub async fn apply() -> Result<()> {
|
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
|
"FIPS auto-apply not yet wired — upstream does not publish stable \
|
|
|
|
|
|
per-commit .deb artefacts for main. Upgrade manually for now: \
|
|
|
|
|
|
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 00:42:56 -04:00
|
|
|
|
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
|
|
|
|
|
|
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
|
|
|
|
|
|
let resp = client
|
|
|
|
|
|
.get(&url)
|
|
|
|
|
|
.header("Accept", "application/vnd.github+json")
|
|
|
|
|
|
.send()
|
|
|
|
|
|
.await
|
|
|
|
|
|
.context("GitHub repo API")?;
|
|
|
|
|
|
if !resp.status().is_success() {
|
|
|
|
|
|
anyhow::bail!("GitHub repo API returned {}", resp.status());
|
|
|
|
|
|
}
|
|
|
|
|
|
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
|
|
|
|
|
|
body.get("default_branch")
|
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
|
|
|
|
|
|
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
|
2026-04-18 22:57:51 -04:00
|
|
|
|
let resp = client
|
|
|
|
|
|
.get(&url)
|
|
|
|
|
|
.header("Accept", "application/vnd.github+json")
|
|
|
|
|
|
.send()
|
|
|
|
|
|
.await
|
|
|
|
|
|
.context("GitHub commits API")?;
|
|
|
|
|
|
if !resp.status().is_success() {
|
2026-04-19 00:42:56 -04:00
|
|
|
|
anyhow::bail!(
|
|
|
|
|
|
"GitHub commits API returned {} for branch {}",
|
|
|
|
|
|
resp.status(),
|
|
|
|
|
|
branch
|
|
|
|
|
|
);
|
2026-04-18 22:57:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
|
2026-04-19 00:42:56 -04:00
|
|
|
|
body.get("sha")
|
2026-04-18 22:57:51 -04:00
|
|
|
|
.and_then(|v| v.as_str())
|
2026-04-19 00:42:56 -04:00
|
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
|
2026-04-18 22:57:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn test_apply_returns_clear_stub_error() {
|
|
|
|
|
|
let err = apply().await.unwrap_err().to_string();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
err.contains("not yet wired"),
|
|
|
|
|
|
"apply() should return an explicit not-yet-wired error, got: {}",
|
|
|
|
|
|
err
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_update_check_serialises() {
|
|
|
|
|
|
let uc = UpdateCheck {
|
|
|
|
|
|
current: Some("0.2.0-abc1234".to_string()),
|
|
|
|
|
|
latest_commit: "def5678".to_string(),
|
|
|
|
|
|
update_available: true,
|
|
|
|
|
|
notes: "test".to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let json = serde_json::to_string(&uc).unwrap();
|
|
|
|
|
|
assert!(json.contains("latest_commit"));
|
|
|
|
|
|
assert!(json.contains("update_available"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|