mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
454 lines
17 KiB
Rust
454 lines
17 KiB
Rust
//! Module to check the Github repo for new releases
|
|
|
|
use anyhow::{Context, Result};
|
|
use rand::{thread_rng, Rng as _};
|
|
use semver::Version;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{io::Write, path::PathBuf, str::FromStr, time::Duration};
|
|
use tokio::sync::mpsc;
|
|
use url::Url;
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Notification {
|
|
pub release: Release,
|
|
/// If true, show a pop-up notification and set the dot. If false, only set the dot.
|
|
pub tell_user: bool,
|
|
}
|
|
|
|
/// GUI-friendly release struct
|
|
///
|
|
/// Serialize is derived for debugging
|
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
|
pub struct Release {
|
|
pub download_url: url::Url,
|
|
pub version: Version,
|
|
}
|
|
|
|
pub async fn checker_task(
|
|
ctlr_tx: mpsc::Sender<Option<Notification>>,
|
|
debug_mode: bool,
|
|
) -> Result<()> {
|
|
let (current_version, interval_in_seconds) = if debug_mode {
|
|
(Version::new(1, 0, 0), 30)
|
|
} else {
|
|
(current_version()?, 86_400)
|
|
};
|
|
|
|
// Always check the file first, then wait a random amount of time before entering the loop.
|
|
let latest_seen = read_latest_release_file().await;
|
|
let mut fsm = Checker::new(current_version, latest_seen);
|
|
let mut interval = tokio::time::interval(Duration::from_secs(interval_in_seconds));
|
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
|
|
loop {
|
|
match fsm.poll() {
|
|
Event::CheckNetwork => {
|
|
tracing::debug!("CheckNetwork");
|
|
match check().await {
|
|
Ok(release) => fsm.handle_check(release),
|
|
Err(e) => tracing::debug!("Couldn't check website for update: {e:#}"),
|
|
}
|
|
}
|
|
Event::WaitInterval => {
|
|
tracing::debug!("WaitInterval");
|
|
interval.tick().await;
|
|
}
|
|
Event::WaitRandom => {
|
|
tracing::debug!("WaitRandom");
|
|
let rand_time = thread_rng().gen_range(0..interval_in_seconds);
|
|
tokio::time::sleep(Duration::from_secs(rand_time)).await;
|
|
// Discard the first interval, which always elapses instantly
|
|
interval.reset();
|
|
}
|
|
Event::Notify(notification) => {
|
|
tracing::debug!("Notify");
|
|
write_latest_release_file(notification.as_ref().map(|n| &n.release)).await?;
|
|
ctlr_tx.send(notification).await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads the latest version and download URL we've seen, from disk
|
|
///
|
|
/// The URL is not used but the code was near merging so I didn't
|
|
/// want to remove it and break compat with my dev systems.
|
|
async fn read_latest_release_file() -> Option<Release> {
|
|
tokio::fs::read_to_string(version_file_path().ok()?)
|
|
.await
|
|
.ok()
|
|
.as_deref()
|
|
.map(serde_json::from_str)
|
|
.transpose()
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
async fn write_latest_release_file(release: Option<&Release>) -> Result<()> {
|
|
let path = version_file_path()?;
|
|
let Some(release) = release else {
|
|
let _ = tokio::fs::remove_file(&path).await;
|
|
return Ok(());
|
|
};
|
|
|
|
// `atomicwrites` is sync so use `spawn_blocking` so we don't block an
|
|
// executor thread
|
|
let s = serde_json::to_string(release)?;
|
|
tokio::task::spawn_blocking(move || {
|
|
std::fs::create_dir_all(
|
|
path.parent()
|
|
.context("release file path should always have a parent.")?,
|
|
)?;
|
|
let f =
|
|
atomicwrites::AtomicFile::new(&path, atomicwrites::OverwriteBehavior::AllowOverwrite);
|
|
f.write(|f| f.write_all(s.as_bytes()))?;
|
|
Ok::<_, anyhow::Error>(())
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
struct Checker {
|
|
ours: Version,
|
|
state: State,
|
|
/// The last notification we pushed to the GUI
|
|
notification: Option<Notification>,
|
|
/// Have we changed our desired notification since we last told the GUI about it?
|
|
notification_dirty: bool,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
enum Event {
|
|
/// Check the latest version from the Firezone website and write it to disk.
|
|
CheckNetwork,
|
|
/// Wait approximately a day using `tokio::time::interval`.
|
|
WaitInterval,
|
|
/// Wait a random amount of time up to the full interval, to avoid the thundering herd problem. This is only used at startup.
|
|
WaitRandom,
|
|
/// Set / clear a GUI notification.
|
|
Notify(Option<Notification>),
|
|
}
|
|
|
|
enum State {
|
|
/// Need to check the network.
|
|
CheckNetwork,
|
|
/// Need to wait before the next network check.
|
|
WaitInterval,
|
|
/// Need to wait a random time before the first network check
|
|
WaitRandom,
|
|
}
|
|
|
|
impl Checker {
|
|
fn new(ours: Version, latest_seen: Option<Release>) -> Self {
|
|
let notification = match &latest_seen {
|
|
Some(latest_seen) if latest_seen.version > ours => {
|
|
Some(Notification {
|
|
release: latest_seen.clone(),
|
|
// Never show a pop-up right at startup.
|
|
tell_user: false,
|
|
})
|
|
}
|
|
Some(_) => None,
|
|
None => None,
|
|
};
|
|
let notification_dirty = notification.is_some();
|
|
|
|
Self {
|
|
ours,
|
|
state: State::WaitRandom,
|
|
notification,
|
|
notification_dirty,
|
|
}
|
|
}
|
|
|
|
/// Call this when we just checked the network
|
|
fn handle_check(&mut self, release: Release) {
|
|
let different_than_latest_notified = match &self.notification {
|
|
None => release.version != self.ours,
|
|
Some(notification) => release.version != notification.release.version,
|
|
};
|
|
|
|
if different_than_latest_notified {
|
|
self.notification_dirty = true;
|
|
self.notification = if release.version == self.ours {
|
|
None
|
|
} else {
|
|
Some(Notification {
|
|
release,
|
|
tell_user: true,
|
|
})
|
|
};
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
fn poll(&mut self) -> Event {
|
|
if self.notification_dirty {
|
|
self.notification_dirty = false;
|
|
return Event::Notify(self.notification.clone());
|
|
}
|
|
match self.state {
|
|
State::CheckNetwork => {
|
|
self.state = State::WaitInterval;
|
|
Event::CheckNetwork
|
|
}
|
|
State::WaitInterval => {
|
|
self.state = State::CheckNetwork;
|
|
Event::WaitInterval
|
|
}
|
|
State::WaitRandom => {
|
|
self.state = State::CheckNetwork;
|
|
Event::WaitRandom
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn version_file_path() -> Result<PathBuf> {
|
|
Ok(firezone_headless_client::known_dirs::session()
|
|
.context("Couldn't find session dir")?
|
|
.join("latest_version_seen.txt"))
|
|
}
|
|
|
|
/// Returns the latest release, even if ours is already newer
|
|
pub(crate) async fn check() -> Result<Release> {
|
|
// Don't follow any redirects, just tell us what the Firezone site says the URL is
|
|
// If we follow multiple redirects, we'll end up with a messier URL like
|
|
// ```
|
|
// https://objects.githubusercontent.com/github-production-release-asset-2e65be/257787813/b3816cc1-87e4-42ae-b354-2dbb7f98721c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=releaseassetproduction%2F20240627%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240627T210330Z&X-Amz-Expires=300&X-Amz-Signature=fd367bcdc7e64ffac0b318ab710dd5f673dd5b5ac3a9ccdc621adf5d304df557&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=257787813&response-content-disposition=attachment%3B%20filename%3Dfirezone-client-gui-windows_1.1.0_x86_64.msi&response-content-type=application%2Foctet-stream
|
|
// ```
|
|
// The version number is still in there, but it's easier to just disable redirects
|
|
// and parse the number from the Firezone website, instead of making multiple HTTP requests
|
|
// and then hoping Github and Amazon's APIs don't change.
|
|
//
|
|
// When we need to do auto-updates later, we can leave redirects enabled for those.
|
|
let client = reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()?;
|
|
let arch = std::env::consts::ARCH;
|
|
let os = std::env::consts::OS;
|
|
|
|
// We used to send this to Github, couldn't hurt to send it to our own site, too
|
|
let user_agent = format!("Firezone Client/{:?} ({os}; {arch})", current_version());
|
|
|
|
let mut update_url = url::Url::parse("https://www.firezone.dev")
|
|
.context("Impossible: Hard-coded URL should always be parsable")?;
|
|
update_url.set_path(&format!("/dl/firezone-client-gui-{os}/latest/{arch}"));
|
|
|
|
let response = client
|
|
.head(update_url.clone())
|
|
.header("User-Agent", user_agent)
|
|
.send()
|
|
.await?;
|
|
let status = response.status();
|
|
if status != reqwest::StatusCode::TEMPORARY_REDIRECT {
|
|
anyhow::bail!("HTTP status: {status} from update URL `{update_url}`");
|
|
}
|
|
let download_url = response
|
|
.headers()
|
|
.get(reqwest::header::LOCATION)
|
|
.context("this URL should always have a redirect")?
|
|
.to_str()?;
|
|
tracing::debug!(?download_url);
|
|
let download_url = Url::parse(download_url)?;
|
|
let version = parse_version_from_url(&download_url)?;
|
|
Ok(Release {
|
|
download_url,
|
|
version,
|
|
})
|
|
}
|
|
|
|
fn parse_version_from_url(url: &Url) -> Result<Version> {
|
|
let filename = url
|
|
.path_segments()
|
|
.context("URL must have a path")?
|
|
.last()
|
|
.context("URL path must have a last segment")?;
|
|
let version_str = filename
|
|
.split('_')
|
|
.nth(1)
|
|
.context("Filename must have 3 parts separated by underscores")?;
|
|
Ok(Version::parse(version_str)?)
|
|
}
|
|
|
|
pub(crate) fn current_version() -> Result<Version> {
|
|
Version::from_str(env!("CARGO_PKG_VERSION")).context("Impossible, our version is invalid")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn checker_happy_path() {
|
|
// There's no file, this is a new system
|
|
let mut fsm = Checker::new(Version::new(1, 0, 0), None);
|
|
// After our initial random sleep we always check the network
|
|
assert_eq!(fsm.poll(), Event::WaitRandom);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// We check the network and the network's down, so do nothing
|
|
|
|
// After network checks we always sleep a full interval
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
|
|
// Back to step 1
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// We're on the latest version, so do nothing
|
|
fsm.handle_check(release(1, 0, 0));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// There's a new version, so tell the UI
|
|
fsm.handle_check(release(1, 0, 1));
|
|
assert_eq!(fsm.poll(), Event::Notify(Some(notification(1, 0, 1))));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// We already told the UI about this version, don't tell it again.
|
|
fsm.handle_check(release(1, 0, 1));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// There's an even newer version, so tell the UI
|
|
fsm.handle_check(release(1, 0, 2));
|
|
assert_eq!(fsm.poll(), Event::Notify(Some(notification(1, 0, 2))));
|
|
}
|
|
|
|
#[test]
|
|
fn checker_existing_system() {
|
|
// We check the file and we're already up to date, so do nothing
|
|
let mut fsm = Checker::new(Version::new(1, 0, 0), Some(release(1, 0, 0)));
|
|
assert_eq!(fsm.poll(), Event::WaitRandom);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// We're on the latest version, so do nothing
|
|
fsm.handle_check(release(1, 0, 0));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
}
|
|
|
|
#[test]
|
|
fn checker_ignored_update() {
|
|
// We check the file and Firezone has restarted when we already knew about an update, but we don't tell the user for that, we just show the dot
|
|
let mut fsm = Checker::new(Version::new(1, 0, 0), Some(release(1, 0, 1)));
|
|
assert_eq!(
|
|
fsm.poll(),
|
|
Event::Notify(Some(Notification {
|
|
release: release(1, 0, 1),
|
|
tell_user: false,
|
|
}))
|
|
);
|
|
assert_eq!(fsm.poll(), Event::WaitRandom);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// Don't notify since we already have the dot up.
|
|
fsm.handle_check(release(1, 0, 1));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
|
|
// There's an even newer version, so tell the user
|
|
fsm.handle_check(release(1, 0, 2));
|
|
assert_eq!(fsm.poll(), Event::Notify(Some(notification(1, 0, 2))));
|
|
}
|
|
|
|
#[test]
|
|
fn checker_rollback() {
|
|
let mut fsm = Checker::new(Version::new(1, 0, 0), Some(release(1, 0, 0)));
|
|
assert_eq!(fsm.poll(), Event::WaitRandom);
|
|
|
|
// We first hear about 1.0.2 and notify for that
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
fsm.handle_check(release(1, 0, 2));
|
|
assert_eq!(fsm.poll(), Event::Notify(Some(notification(1, 0, 2))));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
|
|
// Then we hear it's actually just 1.0.1, we still notify
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
fsm.handle_check(release(1, 0, 1));
|
|
assert_eq!(fsm.poll(), Event::Notify(Some(notification(1, 0, 1))));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
|
|
// When we hear about 1.0.2 again, we notify again.
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
fsm.handle_check(release(1, 0, 2));
|
|
assert_eq!(fsm.poll(), Event::Notify(Some(notification(1, 0, 2))));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
|
|
// But if we hear about 1.0.0, our own version, we remove the notification
|
|
assert_eq!(fsm.poll(), Event::CheckNetwork);
|
|
fsm.handle_check(release(1, 0, 0));
|
|
assert_eq!(fsm.poll(), Event::Notify(None));
|
|
assert_eq!(fsm.poll(), Event::WaitInterval);
|
|
}
|
|
|
|
fn notification(major: u64, minor: u64, patch: u64) -> Notification {
|
|
Notification {
|
|
release: release(major, minor, patch),
|
|
tell_user: true,
|
|
}
|
|
}
|
|
|
|
fn release(major: u64, minor: u64, patch: u64) -> Release {
|
|
let version = Version::new(major, minor, patch);
|
|
let download_url = format!("https://www.github.com/firezone/firezone/releases/download/{version}/firezone-client-gui-windows_{version}_x86_64.msi");
|
|
let download_url = Url::parse(&download_url).unwrap();
|
|
Release {
|
|
download_url,
|
|
version,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_version_from_url() {
|
|
for (input, expected) in [
|
|
("https://www.github.com/firezone/firezone/releases/download/1.0.0/firezone-client-gui-windows_1.0.0_x86_64.msi", Some((1, 0, 0))),
|
|
("https://www.github.com/firezone/firezone/releases/download/1.0.1/firezone-client-gui-linux_1.0.1_x86_64.deb", Some((1, 0, 1))),
|
|
("https://www.github.com/firezone/firezone/releases/download/1.0.1/firezone-client-gui-linux_x86_64.deb", None),
|
|
] {
|
|
let input = Url::parse(input).unwrap();
|
|
let expected = expected.map(|(a, b, c)| Version::new(a, b, c));
|
|
let actual = super::parse_version_from_url(&input).ok();
|
|
assert_eq!(actual, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pick_asset() {
|
|
let asset_names = [
|
|
"firezone-client-gui-linux_1.0.0-pre.14_aarch64.deb",
|
|
"firezone-client-gui-linux_1.0.0-pre.14_x86_64.deb",
|
|
"firezone-client-gui-windows_1.0.0-pre.14_aarch64.msi",
|
|
"firezone-client-gui-windows_1.0.0-pre.14_x86_64.msi",
|
|
"firezone-client-headless-linux_1.0.0-pre.14_aarch64.deb",
|
|
"firezone-client-headless-linux_1.0.0-pre.14_x86_64.deb",
|
|
"firezone-client-headless-windows_1.0.0-pre.14_aarch64.msi",
|
|
"firezone-client-headless-windows_1.0.0-pre.14_x86_64.msi",
|
|
"firezone-gateway-linux_1.0.0-pre.14_aarch64.deb",
|
|
"firezone-gateway-linux_1.0.0-pre.14_x86_64.deb",
|
|
"firezone-gateway-windows_1.0.0-pre.14_aarch64.msi",
|
|
"firezone-gateway-windows_1.0.0-pre.14_x86_64.msi",
|
|
];
|
|
|
|
let product = "client-gui";
|
|
let arch = "x86_64";
|
|
let os = "windows";
|
|
let package = "msi";
|
|
|
|
let prefix = format!("firezone-{product}-{os}_");
|
|
let suffix = format!("_{arch}.{package}");
|
|
|
|
let mut iter = asset_names
|
|
.into_iter()
|
|
.filter(|x| x.starts_with(&prefix) && x.ends_with(&suffix));
|
|
let asset_name = iter.next().unwrap();
|
|
assert!(iter.next().is_none());
|
|
|
|
assert_eq!(
|
|
asset_name,
|
|
"firezone-client-gui-windows_1.0.0-pre.14_x86_64.msi"
|
|
);
|
|
}
|
|
}
|