manabrew_server/
self_update.rs1use 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
16const 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
50pub 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
122pub 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
138async 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
182fn 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 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 assert!(!is_behind("0.26.1", "not-a-version"));
217 assert!(!is_behind("", "0.26.1"));
218 }
219
220 #[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 #[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}