296 lines
12 KiB
Rust
296 lines
12 KiB
Rust
//! Talking to the site's update service: reachability, token check, patch notes.
|
|
|
|
use std::time::Duration;
|
|
|
|
use reqwest::StatusCode;
|
|
|
|
/// How long a launch waits for the site before treating the PC as offline. Short on
|
|
/// purpose: a PC with no DHP at all must not sit through a stall at every start.
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
|
|
|
/// The second chance, for a link that answers slowly rather than not at all. Over a
|
|
/// phone's hotspot the first HTTPS round trip to a DHP - name, connection, handshake,
|
|
/// answer - runs past two seconds often enough that the DHP read as offline while a
|
|
/// browser on the same PC reached it fine. Only a TIMEOUT is retried: a refused
|
|
/// connection or a name that does not resolve is what an absent DHP looks like, and
|
|
/// waiting ten seconds on those at every start is exactly what the short budget avoids.
|
|
const SLOW_LINK_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
pub struct Probe {
|
|
pub online: bool,
|
|
/// Why the DHP is not online, in one line. Without it "offline" is all the operator
|
|
/// gets, and a URL that a browser on the same PC opens fine leaves nothing to go on.
|
|
pub problem: Option<String>,
|
|
/// The site's CURRENT version.
|
|
pub version: Option<String>,
|
|
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
|
|
/// online. Absent when the DHP offers none, and this PC then serves its own data.
|
|
pub engine_url: Option<String>,
|
|
/// Every address the DHP offers for it, best first: the site's own network, then the
|
|
/// one that answers from outside. A PC cannot be told which applies to it - a laptop
|
|
/// is at the office in the morning and at home in the evening - so it tries them in
|
|
/// turn. Older DHPs name one address and this holds just that one.
|
|
pub engine_urls: Vec<String>,
|
|
}
|
|
|
|
/// How long one Engine API gets to answer before the next address is tried.
|
|
const ENGINE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
|
|
|
/// Does this Engine API answer? `/health` is its open route: no token, no data.
|
|
pub async fn engine_answers(http: &reqwest::Client, engine_url: &str) -> bool {
|
|
let url = format!("{}/health", engine_url.trim_end_matches('/'));
|
|
matches!(http.get(url).timeout(ENGINE_PROBE_TIMEOUT).send().await, Ok(answer) if answer.status().is_success())
|
|
}
|
|
|
|
/// The first of `urls` that answers, or None when this PC can reach none of them - which
|
|
/// is not an error: the local API then serves this PC's own data, as it does offline.
|
|
pub async fn first_engine(http: &reqwest::Client, urls: &[String]) -> Option<String> {
|
|
for url in urls {
|
|
if engine_answers(http, url).await {
|
|
return Some(url.clone());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn unreachable(url: &str, what: impl std::fmt::Display) -> Probe {
|
|
Probe { online: false, problem: Some(format!("{url}: {what}")), version: None, engine_url: None, engine_urls: Vec::new() }
|
|
}
|
|
|
|
/// The reason, short enough for one log line: reqwest nests its causes, and the one
|
|
/// that names the fault (a refused connection, an expired certificate, a bad name) is
|
|
/// the innermost.
|
|
fn cause(error: &reqwest::Error) -> String {
|
|
let mut source: &dyn std::error::Error = error;
|
|
let mut text = error.to_string();
|
|
while let Some(inner) = source.source() {
|
|
text = inner.to_string();
|
|
source = inner;
|
|
}
|
|
text
|
|
}
|
|
|
|
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
|
|
if site_url.is_empty() {
|
|
return Probe {
|
|
online: false,
|
|
problem: Some("no DHP address is set".to_string()),
|
|
version: None,
|
|
engine_url: None,
|
|
engine_urls: Vec::new(),
|
|
};
|
|
}
|
|
let url = format!("{site_url}/v1/health");
|
|
let response = match http.get(&url).timeout(PROBE_TIMEOUT).send().await {
|
|
Ok(response) => response,
|
|
Err(quick) if quick.is_timeout() => match http.get(&url).timeout(SLOW_LINK_TIMEOUT).send().await {
|
|
Ok(response) => response,
|
|
Err(slow) if slow.is_timeout() => {
|
|
return unreachable(&url, format!("no answer within {}s", SLOW_LINK_TIMEOUT.as_secs()))
|
|
}
|
|
Err(slow) => return unreachable(&url, cause(&slow)),
|
|
},
|
|
Err(quick) => return unreachable(&url, cause(&quick)),
|
|
};
|
|
if !response.status().is_success() {
|
|
return unreachable(&url, format!("answered {}", response.status()));
|
|
}
|
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
|
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
|
|
let engine_url = text("engine_api_url");
|
|
let engine_urls = engine_urls_from(&body, engine_url.as_deref());
|
|
Probe { online: true, problem: None, version: text("version"), engine_url, engine_urls }
|
|
}
|
|
|
|
/// The addresses a health answer offers for the Engine API, in the order to try them.
|
|
/// A DHP from before the list names one, and it is the whole list then.
|
|
fn engine_urls_from(body: &serde_json::Value, single: Option<&str>) -> Vec<String> {
|
|
let listed: Vec<String> = body
|
|
.get("engine_api_urls")
|
|
.and_then(|v| v.as_array())
|
|
.map(|urls| urls.iter().filter_map(|u| u.as_str()).map(str::trim).filter(|u| !u.is_empty()).map(str::to_string).collect())
|
|
.unwrap_or_default();
|
|
if listed.is_empty() {
|
|
return single.filter(|u| !u.is_empty()).map(str::to_string).into_iter().collect();
|
|
}
|
|
listed
|
|
}
|
|
|
|
pub enum TokenCheck {
|
|
Valid,
|
|
Invalid,
|
|
/// The site could not say (unreachable, Engine down): keep the token.
|
|
Unknown,
|
|
}
|
|
|
|
pub async fn check_token(http: &reqwest::Client, site_url: &str, token: &str) -> TokenCheck {
|
|
let request = http
|
|
.get(format!("{site_url}/v1/versions/current"))
|
|
.bearer_auth(token)
|
|
.timeout(Duration::from_secs(5));
|
|
match request.send().await {
|
|
// 404 means "no current version yet", which is only answered after the token passed.
|
|
Ok(r) if r.status().is_success() || r.status() == StatusCode::NOT_FOUND => TokenCheck::Valid,
|
|
Ok(r) if r.status() == StatusCode::UNAUTHORIZED => TokenCheck::Invalid,
|
|
_ => TokenCheck::Unknown,
|
|
}
|
|
}
|
|
|
|
pub enum SessionRefresh {
|
|
Renewed { access_token: String, expires_at: i64, username: String },
|
|
/// The site no longer honours the launcher session: signed out elsewhere, revoked, or the user is gone.
|
|
Revoked(String),
|
|
/// The sign-in service could not answer (unreachable, Engine down, an MCP without launcher
|
|
/// sessions): keep the session and try again later.
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct RenewedBody {
|
|
#[serde(default)]
|
|
access_token: String,
|
|
#[serde(default)]
|
|
expires_at: i64,
|
|
#[serde(default)]
|
|
username: String,
|
|
}
|
|
|
|
/// A fresh Engine token from the site MCP's launcher session, without asking the operator.
|
|
pub async fn refresh_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) -> SessionRefresh {
|
|
if mcp_url.is_empty() {
|
|
return SessionRefresh::Unavailable("no sign-in address (MCP URL) is set".to_string());
|
|
}
|
|
let sent = http
|
|
.post(format!("{mcp_url}/auth/launcher/refresh"))
|
|
.json(&serde_json::json!({ "launcher_token": launcher_token }))
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
.await;
|
|
let response = match sent {
|
|
Ok(response) => response,
|
|
Err(e) => return SessionRefresh::Unavailable(format!("the sign-in service did not answer: {e}")),
|
|
};
|
|
match response.status() {
|
|
s if s.is_success() => match response.json::<RenewedBody>().await {
|
|
Ok(body) if !body.access_token.is_empty() => SessionRefresh::Renewed {
|
|
access_token: body.access_token,
|
|
expires_at: body.expires_at,
|
|
username: body.username,
|
|
},
|
|
_ => SessionRefresh::Unavailable("the sign-in service gave an unexpected answer".to_string()),
|
|
},
|
|
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
|
let detail = response
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.ok()
|
|
.and_then(|body| body.get("detail").and_then(|d| d.as_str()).map(str::to_string))
|
|
.unwrap_or_else(|| "the DHP ended this sign-in".to_string());
|
|
SessionRefresh::Revoked(detail)
|
|
}
|
|
s => SessionRefresh::Unavailable(format!("the sign-in service answered HTTP {s}")),
|
|
}
|
|
}
|
|
|
|
/// Best effort: tell the site MCP to end the launcher session. Signing out works offline too.
|
|
pub async fn end_session(http: &reqwest::Client, mcp_url: &str, launcher_token: &str) {
|
|
if mcp_url.is_empty() {
|
|
return;
|
|
}
|
|
let _ = http
|
|
.post(format!("{mcp_url}/auth/launcher/logout"))
|
|
.json(&serde_json::json!({ "launcher_token": launcher_token }))
|
|
.timeout(Duration::from_secs(5))
|
|
.send()
|
|
.await;
|
|
}
|
|
|
|
/// A DHP's answer to "may this PC download a whole version now?".
|
|
pub enum InstallSlot {
|
|
/// Go ahead; renew before `lease_sec` runs out.
|
|
Granted { lease_sec: u64 },
|
|
/// Wait: this many PCs are downloading or waiting ahead of this one.
|
|
Queued { ahead: u64 },
|
|
/// The DHP does not hand out slots (older service): just install.
|
|
NotOffered,
|
|
}
|
|
|
|
/// Ask for, or renew, this PC's install slot.
|
|
pub async fn claim_install(http: &reqwest::Client, site_url: &str, token: &str, install_id: &str) -> InstallSlot {
|
|
let sent = http
|
|
.post(format!("{site_url}/v1/install/claim"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({ "install_id": install_id }))
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
.await;
|
|
let Ok(response) = sent else { return InstallSlot::NotOffered };
|
|
if response.status() == StatusCode::NOT_FOUND {
|
|
return InstallSlot::NotOffered;
|
|
}
|
|
if !response.status().is_success() {
|
|
return InstallSlot::NotOffered;
|
|
}
|
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
|
if body.get("granted").and_then(|g| g.as_bool()).unwrap_or(true) {
|
|
return InstallSlot::Granted { lease_sec: body.get("lease_sec").and_then(|l| l.as_u64()).unwrap_or(120) };
|
|
}
|
|
InstallSlot::Queued { ahead: body.get("ahead").and_then(|a| a.as_u64()).unwrap_or(0) }
|
|
}
|
|
|
|
/// Give the slot back. Best effort: an unreleased slot simply expires.
|
|
pub async fn release_install(http: &reqwest::Client, site_url: &str, token: &str, install_id: &str) {
|
|
let _ = http
|
|
.post(format!("{site_url}/v1/install/release"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({ "install_id": install_id }))
|
|
.timeout(Duration::from_secs(5))
|
|
.send()
|
|
.await;
|
|
}
|
|
|
|
pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version: &str) -> Result<String, String> {
|
|
let response = http
|
|
.get(format!("{site_url}/v1/versions/{version}/notes"))
|
|
.bearer_auth(token)
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("the DHP did not answer: {e}"))?;
|
|
match response.status() {
|
|
s if s.is_success() => response.text().await.map_err(|e| e.to_string()),
|
|
StatusCode::UNAUTHORIZED => Err("Sign in to read the patch notes.".to_string()),
|
|
StatusCode::NOT_FOUND => Err(format!("Version {version} has no patch notes.")),
|
|
s => Err(format!("the DHP answered HTTP {s}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_addresses_are_taken_in_the_order_the_dhp_gives_them() {
|
|
let body = serde_json::json!({
|
|
"ok": true,
|
|
"engine_api_urls": ["http://192.168.1.41:30684", "https://engine-api.odian.fr"],
|
|
"engine_api_url": "https://engine-api.odian.fr"
|
|
});
|
|
assert_eq!(
|
|
engine_urls_from(&body, Some("https://engine-api.odian.fr")),
|
|
vec!["http://192.168.1.41:30684".to_string(), "https://engine-api.odian.fr".to_string()],
|
|
"the site's own network first, so an office PC does not go out and back"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dhp_that_names_one_address_still_works() {
|
|
// Every DHP in the field before the list: one address, and it is the whole list.
|
|
let body = serde_json::json!({"ok": true, "engine_api_url": "http://192.168.1.41:30684"});
|
|
assert_eq!(engine_urls_from(&body, Some("http://192.168.1.41:30684")), vec!["http://192.168.1.41:30684".to_string()]);
|
|
// And a DHP offering none leaves the PC on its own data.
|
|
assert!(engine_urls_from(&serde_json::json!({"ok": true}), None).is_empty());
|
|
assert!(engine_urls_from(&serde_json::json!({"engine_api_urls": ["", " "]}), None).is_empty());
|
|
}
|
|
}
|