g1t/crates/runner/src/selfhosted/update.rs
| 1 | //! Releases: where `g1t-runner` is published, how it updates itself, and |
| 2 | //! the Linux build it runs inside containers. |
| 3 | //! |
| 4 | //! Every release is at `<site>/downloads/runner/<version>/`: one binary per |
| 5 | //! platform (`g1t-runner-linux-x64`, `-linux-arm64`, `-macos-arm64`, |
| 6 | //! `-macos-x64`, `-windows-x64.exe`), `SHA256SUMS`, and `manifest.json`. |
| 7 | //! `<site>/downloads/runner/latest.json` is the newest release's manifest, |
| 8 | //! and `latest.json.sig` its Ed25519 signature, made with g1t's release key |
| 9 | //! (`scripts/runner-release.mjs`). A runner only trusts a manifest whose |
| 10 | //! signature checks out against the key built into it, and only runs a |
| 11 | //! binary whose SHA-256 is the manifest's. |
| 12 | |
| 13 | use std::path::{Path, PathBuf}; |
| 14 | |
| 15 | use anyhow::{Context, Result, anyhow, bail}; |
| 16 | use base64::Engine; |
| 17 | use base64::engine::general_purpose::STANDARD; |
| 18 | use serde::Deserialize; |
| 19 | use sha2::{Digest, Sha256}; |
| 20 | |
| 21 | use super::api::download; |
| 22 | use super::config::Config; |
| 23 | use super::{VERSION, log}; |
| 24 | |
| 25 | /// g1t's release key, as base64 of its 32 bytes: set when release builds |
| 26 | /// are made. Without it a runner never updates itself and only takes the |
| 27 | /// Linux harness from the release when its SHA-256 matches the unsigned |
| 28 | /// manifest it fetched over HTTPS. |
| 29 | const RELEASE_KEY: Option<&str> = option_env!("G1T_RUNNER_RELEASE_KEY"); |
| 30 | |
| 31 | #[derive(Clone, Debug, Deserialize)] |
| 32 | pub struct File { |
| 33 | pub name: String, |
| 34 | pub sha256: String, |
| 35 | } |
| 36 | |
| 37 | #[derive(Clone, Debug, Deserialize)] |
| 38 | pub struct Manifest { |
| 39 | pub version: String, |
| 40 | /// The image agent work runs in. |
| 41 | #[serde(default)] |
| 42 | pub agent_image: Option<String>, |
| 43 | /// By platform: `linux-x64`, `macos-arm64`, `windows-x64`… |
| 44 | pub files: std::collections::BTreeMap<String, File>, |
| 45 | } |
| 46 | |
| 47 | /// This machine's platform, as releases name it. |
| 48 | pub fn platform() -> String { |
| 49 | let os = match std::env::consts::OS { |
| 50 | "macos" => "macos", |
| 51 | "windows" => "windows", |
| 52 | _ => "linux", |
| 53 | }; |
| 54 | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; |
| 55 | format!("{os}-{arch}") |
| 56 | } |
| 57 | |
| 58 | fn downloads(config: &Config) -> String { |
| 59 | format!("{}/downloads/runner", config.url.trim_end_matches('/')) |
| 60 | } |
| 61 | |
| 62 | /// `a.b.c` newer than `x.y.z`. |
| 63 | pub fn newer(candidate: &str, current: &str) -> bool { |
| 64 | let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() }; |
| 65 | parts(candidate) > parts(current) |
| 66 | } |
| 67 | |
| 68 | pub fn sha256(bytes: &[u8]) -> String { |
| 69 | hex::encode(Sha256::digest(bytes)) |
| 70 | } |
| 71 | |
| 72 | /// Whether `signature` (base64) is g1t's release key's over `message`. |
| 73 | pub fn verify(message: &[u8], signature: &str, key: &str) -> Result<()> { |
| 74 | let key: [u8; 32] = STANDARD |
| 75 | .decode(key.trim()) |
| 76 | .ok() |
| 77 | .and_then(|bytes| bytes.try_into().ok()) |
| 78 | .ok_or_else(|| anyhow!("the release key built into this runner does not read"))?; |
| 79 | let signature: [u8; 64] = STANDARD |
| 80 | .decode(signature.trim()) |
| 81 | .ok() |
| 82 | .and_then(|bytes| bytes.try_into().ok()) |
| 83 | .ok_or_else(|| anyhow!("the release's signature does not read"))?; |
| 84 | let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| anyhow!("the release key is not a key"))?; |
| 85 | key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature)) |
| 86 | .map_err(|_| anyhow!("the release's signature is not g1t's")) |
| 87 | } |
| 88 | |
| 89 | /// The newest release, signed. `None` when this build has no release key. |
| 90 | fn latest_signed(config: &Config) -> Result<Option<Manifest>> { |
| 91 | let Some(key) = RELEASE_KEY else { return Ok(None) }; |
| 92 | let base = downloads(config); |
| 93 | let manifest = download(&format!("{base}/latest.json"))?; |
| 94 | let signature = String::from_utf8(download(&format!("{base}/latest.json.sig"))?)?; |
| 95 | verify(&manifest, &signature, key)?; |
| 96 | Ok(Some(serde_json::from_slice(&manifest)?)) |
| 97 | } |
| 98 | |
| 99 | /// The manifest of a version: signed when this build can check, otherwise |
| 100 | /// as fetched over HTTPS. |
| 101 | fn manifest_of(config: &Config, version: &str) -> Result<Manifest> { |
| 102 | if let Some(latest) = latest_signed(config)?.filter(|m| m.version == version) { |
| 103 | return Ok(latest); |
| 104 | } |
| 105 | let bytes = download(&format!("{}/{version}/manifest.json", downloads(config)))?; |
| 106 | Ok(serde_json::from_slice(&bytes)?) |
| 107 | } |
| 108 | |
| 109 | /// The image agent work runs in, from the release. |
| 110 | pub fn agent_image(config: &Config) -> Option<String> { |
| 111 | manifest_of(config, VERSION).ok().and_then(|m| m.agent_image) |
| 112 | } |
| 113 | |
| 114 | /// A Linux build of the harness to mount into containers: `--harness`; |
| 115 | /// this program, on Linux; else the release's for this version, fetched |
| 116 | /// once and checked. |
| 117 | pub fn linux_harness(config: &Config, folder: &Path) -> Result<PathBuf> { |
| 118 | if let Some(path) = &config.harness { |
| 119 | return Ok(path.clone()); |
| 120 | } |
| 121 | if cfg!(target_os = "linux") { |
| 122 | return std::env::current_exe().context("could not find this program"); |
| 123 | } |
| 124 | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; |
| 125 | let path = folder.join("harness").join(VERSION).join("g1t-runner"); |
| 126 | if path.exists() { |
| 127 | return Ok(path); |
| 128 | } |
| 129 | let manifest = manifest_of(config, VERSION)?; |
| 130 | let file = manifest |
| 131 | .files |
| 132 | .get(&format!("linux-{arch}")) |
| 133 | .ok_or_else(|| anyhow!("release {VERSION} has no Linux build for {arch}"))?; |
| 134 | log(&format!("Fetching the Linux harness for containers ({})", file.name)); |
| 135 | let bytes = download(&format!("{}/{VERSION}/{}", downloads(config), file.name))?; |
| 136 | if sha256(&bytes) != file.sha256 { |
| 137 | bail!("the Linux harness's SHA-256 is not the release's; not using it"); |
| 138 | } |
| 139 | std::fs::create_dir_all(path.parent().unwrap_or(folder))?; |
| 140 | std::fs::write(&path, bytes)?; |
| 141 | Ok(path) |
| 142 | } |
| 143 | |
| 144 | /// Updates this program to the newest release, if there is one. Returns |
| 145 | /// the new version when it did. |
| 146 | pub fn update(config: &Config) -> Result<Option<String>> { |
| 147 | let Some(latest) = latest_signed(config)? else { |
| 148 | bail!("this build of g1t-runner has no release key, so it cannot check releases; download a release from {}", downloads(config)); |
| 149 | }; |
| 150 | if !newer(&latest.version, VERSION) { |
| 151 | return Ok(None); |
| 152 | } |
| 153 | let file = latest |
| 154 | .files |
| 155 | .get(&platform()) |
| 156 | .ok_or_else(|| anyhow!("release {} has no build for {}", latest.version, platform()))?; |
| 157 | let bytes = download(&format!("{}/{}/{}", downloads(config), latest.version, file.name))?; |
| 158 | if sha256(&bytes) != file.sha256 { |
| 159 | bail!("the download's SHA-256 is not the release's; not updating"); |
| 160 | } |
| 161 | replace_self(&bytes)?; |
| 162 | Ok(Some(latest.version)) |
| 163 | } |
| 164 | |
| 165 | /// Puts `bytes` where this program is. The running file is moved aside |
| 166 | /// first: Windows will not overwrite a running program, but lets it be |
| 167 | /// renamed. |
| 168 | fn replace_self(bytes: &[u8]) -> Result<()> { |
| 169 | let me = std::env::current_exe()?; |
| 170 | let new = me.with_extension("new"); |
| 171 | let old = me.with_extension("old"); |
| 172 | std::fs::write(&new, bytes)?; |
| 173 | #[cfg(unix)] |
| 174 | { |
| 175 | use std::os::unix::fs::PermissionsExt; |
| 176 | std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755))?; |
| 177 | } |
| 178 | let _ = std::fs::remove_file(&old); |
| 179 | std::fs::rename(&me, &old).context("could not move the old version aside")?; |
| 180 | if let Err(error) = std::fs::rename(&new, &me) { |
| 181 | let _ = std::fs::rename(&old, &me); |
| 182 | return Err(error).context("could not put the new version in place"); |
| 183 | } |
| 184 | Ok(()) |
| 185 | } |
| 186 | |
| 187 | /// Whether this build can update itself. |
| 188 | pub fn can_update() -> bool { |
| 189 | RELEASE_KEY.is_some() |
| 190 | } |
| 191 | |
| 192 | #[cfg(test)] |
| 193 | mod tests { |
| 194 | use super::*; |
| 195 | use ed25519_dalek::Signer; |
| 196 | |
| 197 | #[test] |
| 198 | fn versions_compare_as_numbers() { |
| 199 | assert!(newer("0.10.0", "0.9.9")); |
| 200 | assert!(newer("v1.0.0", "0.9.0")); |
| 201 | assert!(!newer("0.2.0", "0.2.0")); |
| 202 | assert!(!newer("0.1.9", "0.2.0")); |
| 203 | } |
| 204 | |
| 205 | #[test] |
| 206 | fn only_the_release_keys_signature_is_trusted() { |
| 207 | let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); |
| 208 | let key = STANDARD.encode(signing.verifying_key().to_bytes()); |
| 209 | let manifest = br#"{"version":"0.2.0","files":{}}"#; |
| 210 | let signature = STANDARD.encode(signing.sign(manifest).to_bytes()); |
| 211 | assert!(verify(manifest, &signature, &key).is_ok()); |
| 212 | assert!(verify(br#"{"version":"9.9.9","files":{}}"#, &signature, &key).is_err()); |
| 213 | let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes()); |
| 214 | assert!(verify(manifest, &signature, &other).is_err()); |
| 215 | assert!(verify(manifest, "not base64", &key).is_err()); |
| 216 | } |
| 217 | |
| 218 | /// Made by scripts/runner-release.mjs: what it signs, the runner checks. |
| 219 | #[test] |
| 220 | fn the_release_scripts_signatures_check_out() { |
| 221 | let key = "4wuwkRbieiO9sWq1UBAcGDjAjin4cwJRG2F8LJVyr6U="; |
| 222 | let signature = "aXjDOfxYhm+iHacZwsxMadNxoHX07p62evYNqsXX4UZoDQ7pwWtFnF4jKfiqvAk+AmGVkcE+/uioMR2v+FixCw=="; |
| 223 | assert!(verify(br#"{"version":"0.2.0","files":{}}"#, signature, key).is_ok()); |
| 224 | assert!(verify(br#"{"version":"0.2.1","files":{}}"#, signature, key).is_err()); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn platforms_are_named_as_releases_name_them() { |
| 229 | let name = platform(); |
| 230 | assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&name.as_str()), "{name}"); |
| 231 | assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); |
| 232 | } |
| 233 | } |