| 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 | /// The sandbox's images (`g1t-runner sandbox`, launcher/), signed with |
| 46 | /// the rest. None in releases from before it. |
| 47 | #[serde(default)] |
| 48 | pub sandbox: Option<SandboxRelease>, |
| 49 | } |
| 50 | |
| 51 | /// What `g1t-runner sandbox` runs, in a release. |
| 52 | #[derive(Clone, Debug, Default, Deserialize)] |
| 53 | pub struct SandboxRelease { |
| 54 | /// The runner's image inside the sandbox, by digest |
| 55 | /// (`g1t.sh/flagon-io/g1t-runner@sha256:…`), so pulling it checks it. |
| 56 | #[serde(default)] |
| 57 | pub runner_image: Option<String>, |
| 58 | /// By kind and architecture: `wsl-x64` (a root filesystem, `.tar.gz`, |
| 59 | /// for a WSL distribution), `vm-arm64-kernel`, `vm-arm64-initrd`, |
| 60 | /// `vm-arm64-disk` (a VM's boot files and its system disk, `.gz`). |
| 61 | #[serde(default)] |
| 62 | pub files: std::collections::BTreeMap<String, SandboxFile>, |
| 63 | } |
| 64 | |
| 65 | #[derive(Clone, Debug, Deserialize)] |
| 66 | pub struct SandboxFile { |
| 67 | pub name: String, |
| 68 | pub sha256: String, |
| 69 | #[serde(default)] |
| 70 | pub size: u64, |
| 71 | } |
| 72 | |
| 73 | /// This machine's platform, as releases name it. |
| 74 | pub fn platform() -> String { |
| 75 | let os = match std::env::consts::OS { |
| 76 | "macos" => "macos", |
| 77 | "windows" => "windows", |
| 78 | _ => "linux", |
| 79 | }; |
| 80 | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; |
| 81 | format!("{os}-{arch}") |
| 82 | } |
| 83 | |
| 84 | fn downloads(config: &Config) -> String { |
| 85 | downloads_at(&config.url) |
| 86 | } |
| 87 | |
| 88 | /// Where a site keeps the runner's releases. |
| 89 | pub fn downloads_at(site: &str) -> String { |
| 90 | format!("{}/downloads/runner", site.trim_end_matches('/')) |
| 91 | } |
| 92 | |
| 93 | /// `a.b.c` newer than `x.y.z`. |
| 94 | pub fn newer(candidate: &str, current: &str) -> bool { |
| 95 | let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() }; |
| 96 | parts(candidate) > parts(current) |
| 97 | } |
| 98 | |
| 99 | pub fn sha256(bytes: &[u8]) -> String { |
| 100 | hex::encode(Sha256::digest(bytes)) |
| 101 | } |
| 102 | |
| 103 | /// Whether `signature` (base64) is g1t's release key's over `message`. |
| 104 | pub fn verify(message: &[u8], signature: &str, key: &str) -> Result<()> { |
| 105 | let key: [u8; 32] = STANDARD |
| 106 | .decode(key.trim()) |
| 107 | .ok() |
| 108 | .and_then(|bytes| bytes.try_into().ok()) |
| 109 | .ok_or_else(|| anyhow!("the release key built into this runner does not read"))?; |
| 110 | let signature: [u8; 64] = STANDARD |
| 111 | .decode(signature.trim()) |
| 112 | .ok() |
| 113 | .and_then(|bytes| bytes.try_into().ok()) |
| 114 | .ok_or_else(|| anyhow!("the release's signature does not read"))?; |
| 115 | let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| anyhow!("the release key is not a key"))?; |
| 116 | key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature)) |
| 117 | .map_err(|_| anyhow!("the release's signature is not g1t's")) |
| 118 | } |
| 119 | |
| 120 | /// The newest release, signed. `None` when this build has no release key. |
| 121 | fn latest_signed(config: &Config) -> Result<Option<Manifest>> { |
| 122 | latest_signed_at(&config.url) |
| 123 | } |
| 124 | |
| 125 | /// The newest release of `site`, signed. `None` when this build has no |
| 126 | /// release key. |
| 127 | pub fn latest_signed_at(site: &str) -> Result<Option<Manifest>> { |
| 128 | let Some(key) = RELEASE_KEY else { return Ok(None) }; |
| 129 | let base = downloads_at(site); |
| 130 | let manifest = download(&format!("{base}/latest.json"))?; |
| 131 | let signature = String::from_utf8(download(&format!("{base}/latest.json.sig"))?)?; |
| 132 | verify(&manifest, &signature, key)?; |
| 133 | Ok(Some(serde_json::from_slice(&manifest)?)) |
| 134 | } |
| 135 | |
| 136 | /// The manifest of a version: signed when this build can check, otherwise |
| 137 | /// as fetched over HTTPS. |
| 138 | fn manifest_of(config: &Config, version: &str) -> Result<Manifest> { |
| 139 | if let Some(latest) = latest_signed(config)?.filter(|m| m.version == version) { |
| 140 | return Ok(latest); |
| 141 | } |
| 142 | let bytes = download(&format!("{}/{version}/manifest.json", downloads(config)))?; |
| 143 | Ok(serde_json::from_slice(&bytes)?) |
| 144 | } |
| 145 | |
| 146 | /// The image agent work runs in, from the release. |
| 147 | pub fn agent_image(config: &Config) -> Option<String> { |
| 148 | manifest_of(config, VERSION).ok().and_then(|m| m.agent_image) |
| 149 | } |
| 150 | |
| 151 | /// A Linux build of the harness to mount into containers: `--harness`; |
| 152 | /// this program, on Linux; else the release's for this version, fetched |
| 153 | /// once and checked. |
| 154 | pub fn linux_harness(config: &Config, folder: &Path) -> Result<PathBuf> { |
| 155 | if let Some(path) = &config.harness { |
| 156 | return Ok(path.clone()); |
| 157 | } |
| 158 | if cfg!(target_os = "linux") { |
| 159 | return std::env::current_exe().context("could not find this program"); |
| 160 | } |
| 161 | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; |
| 162 | let path = folder.join("harness").join(VERSION).join("g1t-runner"); |
| 163 | if path.exists() { |
| 164 | return Ok(path); |
| 165 | } |
| 166 | let manifest = manifest_of(config, VERSION)?; |
| 167 | let file = manifest |
| 168 | .files |
| 169 | .get(&format!("linux-{arch}")) |
| 170 | .ok_or_else(|| anyhow!("release {VERSION} has no Linux build for {arch}"))?; |
| 171 | log(&format!("Fetching the Linux harness for containers ({})", file.name)); |
| 172 | let bytes = download(&format!("{}/{VERSION}/{}", downloads(config), file.name))?; |
| 173 | if sha256(&bytes) != file.sha256 { |
| 174 | bail!("the Linux harness's SHA-256 is not the release's; not using it"); |
| 175 | } |
| 176 | std::fs::create_dir_all(path.parent().unwrap_or(folder))?; |
| 177 | std::fs::write(&path, bytes)?; |
| 178 | Ok(path) |
| 179 | } |
| 180 | |
| 181 | /// Updates this program to the newest release, if there is one. Returns |
| 182 | /// the new version when it did. |
| 183 | pub fn update(config: &Config) -> Result<Option<String>> { |
| 184 | let Some(latest) = latest_signed(config)? else { |
| 185 | bail!("this build of g1t-runner has no release key, so it cannot check releases; download a release from {}", downloads(config)); |
| 186 | }; |
| 187 | if !newer(&latest.version, VERSION) { |
| 188 | return Ok(None); |
| 189 | } |
| 190 | let file = latest |
| 191 | .files |
| 192 | .get(&platform()) |
| 193 | .ok_or_else(|| anyhow!("release {} has no build for {}", latest.version, platform()))?; |
| 194 | let bytes = download(&format!("{}/{}/{}", downloads(config), latest.version, file.name))?; |
| 195 | if sha256(&bytes) != file.sha256 { |
| 196 | bail!("the download's SHA-256 is not the release's; not updating"); |
| 197 | } |
| 198 | replace_self(&bytes)?; |
| 199 | Ok(Some(latest.version)) |
| 200 | } |
| 201 | |
| 202 | /// Puts `bytes` where this program is. The running file is moved aside |
| 203 | /// first: Windows will not overwrite a running program, but lets it be |
| 204 | /// renamed. |
| 205 | fn replace_self(bytes: &[u8]) -> Result<()> { |
| 206 | let me = std::env::current_exe()?; |
| 207 | let new = me.with_extension("new"); |
| 208 | let old = me.with_extension("old"); |
| 209 | std::fs::write(&new, bytes)?; |
| 210 | #[cfg(unix)] |
| 211 | { |
| 212 | use std::os::unix::fs::PermissionsExt; |
| 213 | std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755))?; |
| 214 | } |
| 215 | let _ = std::fs::remove_file(&old); |
| 216 | std::fs::rename(&me, &old).context("could not move the old version aside")?; |
| 217 | if let Err(error) = std::fs::rename(&new, &me) { |
| 218 | let _ = std::fs::rename(&old, &me); |
| 219 | return Err(error).context("could not put the new version in place"); |
| 220 | } |
| 221 | Ok(()) |
| 222 | } |
| 223 | |
| 224 | /// Whether this build can update itself. |
| 225 | pub fn can_update() -> bool { |
| 226 | RELEASE_KEY.is_some() |
| 227 | } |
| 228 | |
| 229 | #[cfg(test)] |
| 230 | mod tests { |
| 231 | use super::*; |
| 232 | use ed25519_dalek::Signer; |
| 233 | |
| 234 | #[test] |
| 235 | fn versions_compare_as_numbers() { |
| 236 | assert!(newer("0.10.0", "0.9.9")); |
| 237 | assert!(newer("v1.0.0", "0.9.0")); |
| 238 | assert!(!newer("0.2.0", "0.2.0")); |
| 239 | assert!(!newer("0.1.9", "0.2.0")); |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn only_the_release_keys_signature_is_trusted() { |
| 244 | let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); |
| 245 | let key = STANDARD.encode(signing.verifying_key().to_bytes()); |
| 246 | let manifest = br#"{"version":"0.2.0","files":{}}"#; |
| 247 | let signature = STANDARD.encode(signing.sign(manifest).to_bytes()); |
| 248 | assert!(verify(manifest, &signature, &key).is_ok()); |
| 249 | assert!(verify(br#"{"version":"9.9.9","files":{}}"#, &signature, &key).is_err()); |
| 250 | let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes()); |
| 251 | assert!(verify(manifest, &signature, &other).is_err()); |
| 252 | assert!(verify(manifest, "not base64", &key).is_err()); |
| 253 | } |
| 254 | |
| 255 | /// Made by scripts/runner-release.mjs: what it signs, the runner checks. |
| 256 | #[test] |
| 257 | fn the_release_scripts_signatures_check_out() { |
| 258 | let key = "4wuwkRbieiO9sWq1UBAcGDjAjin4cwJRG2F8LJVyr6U="; |
| 259 | let signature = "aXjDOfxYhm+iHacZwsxMadNxoHX07p62evYNqsXX4UZoDQ7pwWtFnF4jKfiqvAk+AmGVkcE+/uioMR2v+FixCw=="; |
| 260 | assert!(verify(br#"{"version":"0.2.0","files":{}}"#, signature, key).is_ok()); |
| 261 | assert!(verify(br#"{"version":"0.2.1","files":{}}"#, signature, key).is_err()); |
| 262 | } |
| 263 | |
| 264 | #[test] |
| 265 | fn platforms_are_named_as_releases_name_them() { |
| 266 | let name = platform(); |
| 267 | assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&name.as_str()), "{name}"); |
| 268 | assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); |
| 269 | } |
| 270 | } |