Skip to main content

manabrew_server/
self_update.rs

1//! Keeps a self-hosted relay current without anyone logging into the box: a
2//! relay left behind eventually refuses its own clients on `PROTOCOL_VERSION`.
3//!
4//! Off unless asked, since the same binary runs production. Trust is the
5//! minisign signature CI produces; a download that does not verify is
6//! discarded.
7
8use std::time::Duration;
9
10use tracing::{error, info, warn};
11
12const DEFAULT_MANIFEST_URL: &str = "https://play.manabrew.app/manifest.json";
13const DEFAULT_POLL_SECS: u64 = 3600;
14const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
15
16/// The key CI signs release assets with, from `tauri.conf.json`. Regenerating
17/// the pair orphans every installed copy, desktop and server alike.
18const PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ1OUFFMThDMDA1NzdBNkIKUldScmVsY0FqT0dhMWI2QThlNlNNV1dFamNaOU90MXBZak5nVTd1QlRoUHAwdFBOdnVzN2NFcUQK";
19
20const ASSET: &str = "manabrew-server-linux-x86_64";
21
22pub struct UpdateConfig {
23    pub enabled: bool,
24    pub manifest_url: String,
25    pub poll: Duration,
26}
27
28impl UpdateConfig {
29    pub fn from_env() -> Self {
30        Self {
31            enabled: std::env::var("MANABREW_SELF_UPDATE")
32                .map(|value| {
33                    matches!(
34                        value.to_ascii_lowercase().as_str(),
35                        "1" | "true" | "yes" | "on"
36                    )
37                })
38                .unwrap_or(false),
39            manifest_url: std::env::var("MANABREW_MANIFEST_URL")
40                .unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string()),
41            poll: std::env::var("MANABREW_SELF_UPDATE_POLL_SECS")
42                .ok()
43                .and_then(|value| value.parse().ok())
44                .map(Duration::from_secs)
45                .unwrap_or(Duration::from_secs(DEFAULT_POLL_SECS)),
46        }
47    }
48}
49
50/// Replaces this binary when a newer one is published, then exits so the
51/// service manager starts it again.
52///
53/// `is_idle` is what stops it happening mid-game. A relay that restarts under a
54/// live table is worse than one that never updates, so a stale relay waits.
55pub async fn run<F>(config: UpdateConfig, is_idle: F)
56where
57    F: Fn() -> bool + Send + 'static,
58{
59    if !config.enabled {
60        return;
61    }
62    let mut tick = tokio::time::interval(config.poll);
63    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
64    loop {
65        tick.tick().await;
66        let Some(manifest) = fetch_manifest(&config.manifest_url).await else {
67            continue;
68        };
69        let Some(latest) = manifest.package("manabrew-server") else {
70            continue;
71        };
72        if !is_behind(SERVER_VERSION, &latest) {
73            continue;
74        }
75        let Some(tag) = manifest.package("manabrew") else {
76            warn!("[update] manifest names no app version, so no release to download from");
77            continue;
78        };
79        if !is_idle() {
80            info!(
81                current = SERVER_VERSION,
82                latest = %latest,
83                "[update] newer relay published, waiting for the last game to finish"
84            );
85            continue;
86        }
87        match replace_self(&tag).await {
88            Ok(()) => {
89                warn!(
90                    from = SERVER_VERSION,
91                    to = %latest,
92                    "[update] replaced this binary, exiting so the service manager restarts it"
93                );
94                std::process::exit(0);
95            }
96            Err(error) => error!("[update] {error}"),
97        }
98    }
99}
100
101struct Manifest(serde_json::Value);
102
103impl Manifest {
104    fn package(&self, name: &str) -> Option<String> {
105        self.0
106            .get("packages")?
107            .get(name)?
108            .as_str()
109            .map(str::to_string)
110    }
111}
112
113async fn fetch_manifest(url: &str) -> Option<Manifest> {
114    let client = reqwest::Client::builder()
115        .timeout(Duration::from_secs(10))
116        .build()
117        .ok()?;
118    let body: serde_json::Value = client.get(url).send().await.ok()?.json().await.ok()?;
119    Some(Manifest(body))
120}
121
122/// Semver-ish, on the same shape `self-hosted-node` compares: a published
123/// version that sorts above this one means this one is behind.
124pub fn is_behind(current: &str, latest: &str) -> bool {
125    fn parts(version: &str) -> Vec<u64> {
126        version
127            .split(['.', '-', '+'])
128            .map_while(|part| part.parse().ok())
129            .collect()
130    }
131    let (current, latest) = (parts(current), parts(latest));
132    if current.is_empty() || latest.is_empty() {
133        return false;
134    }
135    latest > current
136}
137
138/// Downloads the published binary, checks its signature, and moves it over this
139/// one. The rename is what makes it safe to do while running: the old inode
140/// stays alive until this process exits, which it does immediately after.
141async fn replace_self(tag: &str) -> Result<(), String> {
142    let base =
143        format!("https://github.com/witchesofthehill/manabrew/releases/download/v{tag}/{ASSET}");
144    let client = reqwest::Client::builder()
145        .timeout(Duration::from_secs(600))
146        .build()
147        .map_err(|e| e.to_string())?;
148
149    let binary = get(&client, &base).await?;
150    let signature = get(&client, &format!("{base}.sig")).await?;
151    verify(&binary, &signature)?;
152
153    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
154    let staged = exe.with_extension("new");
155    std::fs::write(&staged, &binary).map_err(|e| format!("writing {staged:?}: {e}"))?;
156    #[cfg(unix)]
157    {
158        use std::os::unix::fs::PermissionsExt;
159        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
160            .map_err(|e| e.to_string())?;
161    }
162    std::fs::rename(&staged, &exe).map_err(|e| format!("replacing {exe:?}: {e}"))?;
163    Ok(())
164}
165
166async fn get(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
167    let response = client
168        .get(url)
169        .send()
170        .await
171        .map_err(|e| format!("{url}: {e}"))?;
172    if !response.status().is_success() {
173        return Err(format!("{url}: HTTP {}", response.status()));
174    }
175    response
176        .bytes()
177        .await
178        .map(|bytes| bytes.to_vec())
179        .map_err(|e| format!("{url}: {e}"))
180}
181
182/// An unsigned or wrongly signed download is discarded rather than run. This is
183/// the only thing standing between a manifest and code running as this service.
184fn verify(binary: &[u8], signature: &[u8]) -> Result<(), String> {
185    let raw =
186        String::from_utf8(base64_decode(PUBKEY).ok_or_else(|| "pubkey is not base64".to_string())?)
187            .map_err(|_| "pubkey is not utf8".to_string())?;
188    // `decode` wants the whole two-line block, comment included, which is what
189    // `tauri.conf.json` stores base64'd.
190    let key = minisign_verify::PublicKey::decode(&raw).map_err(|e| format!("bad pubkey: {e}"))?;
191    let signature =
192        String::from_utf8(signature.to_vec()).map_err(|_| "signature is not utf8".to_string())?;
193    let signature = minisign_verify::Signature::decode(&signature)
194        .map_err(|e| format!("bad signature: {e}"))?;
195    key.verify(binary, &signature, false)
196        .map_err(|e| format!("the published binary did not verify: {e}"))
197}
198
199fn base64_decode(value: &str) -> Option<Vec<u8>> {
200    use base64::Engine;
201    base64::engine::general_purpose::STANDARD.decode(value).ok()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn a_newer_published_version_is_what_counts_as_behind() {
210        assert!(is_behind("0.26.1", "0.26.2"));
211        assert!(is_behind("0.26.1", "0.27.0"));
212        assert!(!is_behind("0.26.1", "0.26.1"));
213        assert!(!is_behind("0.27.0", "0.26.1"));
214        // A manifest that says something unparseable must not trigger a
215        // download, because the download replaces this binary.
216        assert!(!is_behind("0.26.1", "not-a-version"));
217        assert!(!is_behind("", "0.26.1"));
218    }
219
220    /// The embedded key has to be the one CI signs with, and it has to decode.
221    /// A key that does not parse would fail every update silently.
222    #[test]
223    fn the_signing_key_decodes() {
224        let raw = String::from_utf8(base64_decode(PUBKEY).expect("base64")).expect("utf8");
225        minisign_verify::PublicKey::decode(&raw).expect("a usable minisign key");
226    }
227
228    /// Nothing unsigned reaches the disk. This is the whole trust boundary.
229    #[test]
230    fn a_binary_with_a_bogus_signature_is_refused() {
231        let error = verify(b"pretend binary", b"not a signature").expect_err("must refuse");
232        assert!(error.contains("bad signature"), "{error}");
233    }
234}