feat: probe autoupdate (#88)

* feat: autoupdate

* chore: bump version

* feat: publish installer

* fix: standalone builds

* ci: release docs

* fix: windows icmp socket

* docs: windows notice

* docs: remove line breaks
This commit is contained in:
LowderPlay
2026-08-29 00:22:08 +05:00
committed by GitHub
parent 0c43f470cd
commit 63dff075c0
29 changed files with 1734 additions and 203 deletions
+8
View File
@@ -77,6 +77,14 @@ pub fn detect_dpi_hop_blocking(config: DpiHopProbeConfig) -> io::Result<DpiHopPr
));
}
// Winsock requires a raw socket to be bound before `recvfrom`; otherwise
// the first drain/read fails with WSAEINVAL (10022). Binding to the address
// selected for the TCP connection also limits replies to the right local
// interface. Raw sockets do not use a transport port, so bind with port 0.
let mut icmp_addr = local_addr;
icmp_addr.set_port(0);
icmp.bind(&icmp_addr.into())?;
tcp.write_all(&client_hello)?;
let mut hops = Vec::with_capacity(config.max_ttl as usize);
let mut max_icmp_time_exceeded_ttl = None;
+154 -16
View File
@@ -2,20 +2,24 @@ mod dns;
mod dpi_hop;
mod sni;
mod traceroute;
mod update;
use anyhow::{Context, Result, bail};
use clap::Parser;
use clap::{Parser, Subcommand};
use log::{debug, error, info, warn};
use reports::probe::{DpiProbeConfig, ProbeConfig, ProbeResult, ProbeStatus, ProbeTask};
use rumqttc::{
AsyncClient, Event, Incoming, LastWill, MqttOptions, NetworkOptions, QoS, Transport,
};
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
const CONFIG_TOPIC: &str = "probe/config/v1";
const UPDATE_TOPIC: &str = "probe/update/v1";
const UPDATE_REQUEST_COMMAND: &str = "/usr/libexec/cheburprobe-request-update";
const MQTT_MAX_PACKET_SIZE: usize = 1024 * 1024;
#[derive(Clone)]
@@ -32,8 +36,17 @@ struct DpiHops {
}
#[derive(Parser, Debug, Clone)]
#[command(author, version, about = "Dynamic probing daemon")]
struct Args {
#[command(
author,
version,
about = "Dynamic probing daemon",
subcommand_negates_reqs = true,
args_conflicts_with_subcommands = true
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, env = "MQTT_HOST", default_value = "wss://cheburcheck.ru/mqtt")]
mqtt_host: String,
@@ -44,10 +57,10 @@ struct Args {
mqtt_connection_timeout_secs: u64,
#[arg(long, env = "PROBE_ID")]
probe_id: String,
probe_id: Option<String>,
#[arg(long, env = "PROBE_TOKEN")]
probe_token: String,
probe_token: Option<String>,
#[arg(long, env = "MAX_CONCURRENT_TASKS", default_value_t = 8)]
max_concurrent_tasks: usize,
@@ -56,10 +69,52 @@ struct Args {
traceroute_retries: u8,
}
#[derive(Debug, Clone)]
struct Args {
mqtt_host: String,
mqtt_port: u16,
mqtt_connection_timeout_secs: u64,
probe_id: String,
probe_token: String,
max_concurrent_tasks: usize,
traceroute_retries: u8,
}
impl Cli {
fn into_daemon_args(self) -> Result<Args> {
Ok(Args {
mqtt_host: self.mqtt_host,
mqtt_port: self.mqtt_port,
mqtt_connection_timeout_secs: self.mqtt_connection_timeout_secs,
probe_id: self
.probe_id
.context("--probe-id or PROBE_ID is required when running the probe")?,
probe_token: self
.probe_token
.context("--probe-token or PROBE_TOKEN is required when running the probe")?,
max_concurrent_tasks: self.max_concurrent_tasks,
traceroute_retries: self.traceroute_retries,
})
}
}
#[derive(Subcommand, Debug, Clone)]
enum Command {
/// Update Cheburprobe from the latest GitHub release.
Update,
}
#[tokio::main]
async fn main() -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("failed to install the rustls ring crypto provider"))?;
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
let cli = Cli::parse();
if matches!(cli.command, Some(Command::Update)) {
return update::run().await;
}
let args = cli.into_daemon_args()?;
if args.max_concurrent_tasks == 0 {
bail!("max_concurrent_tasks must be greater than zero");
}
@@ -89,6 +144,7 @@ async fn main() -> Result<()> {
));
let (client, mut eventloop) = AsyncClient::new(options, 100);
let mqtt_updates_enabled = Path::new(UPDATE_REQUEST_COMMAND).is_file();
let config = Arc::new(RwLock::new(None));
let task_semaphore = Arc::new(tokio::sync::Semaphore::new(args.max_concurrent_tasks));
let mut network_options = NetworkOptions::new();
@@ -98,6 +154,11 @@ async fn main() -> Result<()> {
wait_for_connection(&mut eventloop).await;
publish_status(&client, &status_topic, &args, true, DpiHops::default()).await?;
client.subscribe(CONFIG_TOPIC, QoS::AtLeastOnce).await?;
if mqtt_updates_enabled {
subscribe_to_update_requests(&client, &args.probe_id).await?;
} else {
debug!("MQTT-triggered updates are disabled for this standalone installation");
}
client
.subscribe("probe/tasks/v1/+", QoS::AtLeastOnce)
.await?;
@@ -116,17 +177,20 @@ async fn main() -> Result<()> {
loop {
match eventloop.poll().await {
Ok(Event::Incoming(Incoming::Publish(publish))) => {
if publish.topic == CONFIG_TOPIC {
match update_config(&config, &publish.payload).await {
Ok(dpi_hops) => {
if let Err(error) =
publish_status(&client, &status_topic, &args, true, dpi_hops).await
{
warn!("failed to publish probe status with DPI hop: {error}");
}
}
Err(error) => warn!("failed to update probe config: {error}"),
if mqtt_updates_enabled && is_update_topic(&publish.topic, &args.probe_id) {
if publish.retain {
warn!("ignoring retained update request on {}", publish.topic);
} else {
request_update_check();
}
} else if publish.topic == CONFIG_TOPIC {
spawn_config_update(
client.clone(),
status_topic.clone(),
args.clone(),
config.clone(),
publish.payload.to_vec(),
);
} else {
let client = client.clone();
let args = args.clone();
@@ -179,6 +243,9 @@ async fn main() -> Result<()> {
});
publish_status(&client, &status_topic, &args, true, dpi_hops).await?;
client.subscribe(CONFIG_TOPIC, QoS::AtLeastOnce).await?;
if mqtt_updates_enabled {
subscribe_to_update_requests(&client, &args.probe_id).await?;
}
client
.subscribe("probe/tasks/v1/+", QoS::AtLeastOnce)
.await?;
@@ -194,6 +261,52 @@ async fn main() -> Result<()> {
}
}
fn spawn_config_update(
client: AsyncClient,
status_topic: String,
args: Args,
config: Arc<RwLock<Option<LoadedProbeConfig>>>,
payload: Vec<u8>,
) {
tokio::spawn(async move {
match update_config(&config, &payload).await {
Ok(dpi_hops) => {
if let Err(error) =
publish_status(&client, &status_topic, &args, true, dpi_hops).await
{
warn!("failed to publish probe status with DPI hop: {error}");
}
}
Err(error) => warn!("failed to update probe config: {error}"),
}
});
}
async fn subscribe_to_update_requests(client: &AsyncClient, probe_id: &str) -> Result<()> {
client.subscribe(UPDATE_TOPIC, QoS::AtLeastOnce).await?;
client
.subscribe(format!("{UPDATE_TOPIC}/{probe_id}"), QoS::AtLeastOnce)
.await?;
Ok(())
}
fn is_update_topic(topic: &str, probe_id: &str) -> bool {
topic == UPDATE_TOPIC || topic == format!("{UPDATE_TOPIC}/{probe_id}")
}
fn request_update_check() {
tokio::spawn(async {
match tokio::process::Command::new(UPDATE_REQUEST_COMMAND)
.status()
.await
{
Ok(status) if status.success() => info!("requested an update check over MQTT"),
Ok(status) => warn!("update request command exited with {status}"),
Err(error) => warn!("failed to request an update check: {error}"),
}
});
}
fn mqtt_transport(mqtt_host: &str) -> Result<Transport> {
if mqtt_host.starts_with("wss://") {
Ok(Transport::wss_with_default_config())
@@ -449,6 +562,23 @@ fn probe_task_job_id(topic: &str) -> Option<&str> {
mod tests {
use super::*;
#[test]
fn parses_update_subcommand_without_daemon_arguments() {
let args = Cli::try_parse_from(["cheburprobe", "update"]).unwrap();
assert!(matches!(args.command, Some(Command::Update)));
}
#[test]
fn preserves_daemon_invocation_without_a_subcommand() {
let args =
Cli::try_parse_from(["cheburprobe", "--probe-id", "42", "--probe-token", "secret"])
.unwrap()
.into_daemon_args()
.unwrap();
assert_eq!(args.probe_id, "42");
assert_eq!(args.probe_token, "secret");
}
#[test]
fn extracts_job_id_from_legacy_global_and_individual_topics() {
assert_eq!(probe_task_job_id("probe/tasks/v1/job-1"), Some("job-1"));
@@ -457,6 +587,14 @@ mod tests {
assert_eq!(probe_task_job_id("probe/tasks/v1/42/job-2/extra"), None);
}
#[test]
fn recognizes_global_and_individual_update_topics() {
assert!(is_update_topic("probe/update/v1", "42"));
assert!(is_update_topic("probe/update/v1/42", "42"));
assert!(!is_update_topic("probe/update/v1/7", "42"));
assert!(!is_update_topic("probe/update/v1/42/extra", "42"));
}
#[test]
fn decodes_separate_dpi_targets() {
let config: ProbeConfig = serde_json::from_value(serde_json::json!({
+190
View File
@@ -0,0 +1,190 @@
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde::Serialize;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[command(about = "Serve local packages through a mock GitHub release API")]
struct Args {
#[arg(long, default_value = "127.0.0.1:8080")]
bind: String,
#[arg(long, default_value = "http://127.0.0.1:8080")]
public_url: String,
#[arg(long, default_value = "LowderPlay/cheburcheck")]
repository: String,
/// Directory containing .deb, .apk, and .ipk release assets.
#[arg(long)]
assets_dir: PathBuf,
}
#[derive(Serialize)]
struct Release {
assets: Vec<Asset>,
}
#[derive(Serialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn main() -> Result<()> {
let args = Args::parse();
validate_repository(&args.repository)?;
let assets_dir = args
.assets_dir
.canonicalize()
.with_context(|| format!("failed to open {}", args.assets_dir.display()))?;
let public_url = args.public_url.trim_end_matches('/').to_owned();
let listener = TcpListener::bind(&args.bind)
.with_context(|| format!("failed to listen on {}", args.bind))?;
println!(
"mock release API: {public_url}/repos/{}/releases/latest",
args.repository
);
println!("serving assets from {}", assets_dir.display());
for connection in listener.incoming() {
match connection {
Ok(stream) => {
if let Err(error) =
handle_request(stream, &assets_dir, &args.repository, &public_url)
{
eprintln!("request failed: {error:#}");
}
}
Err(error) => eprintln!("failed to accept connection: {error}"),
}
}
Ok(())
}
fn handle_request(
mut stream: TcpStream,
assets_dir: &Path,
repository: &str,
public_url: &str,
) -> Result<()> {
let mut reader = BufReader::new(stream.try_clone().context("failed to read request")?);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.context("failed to read request line")?;
let mut parts = request_line.split_whitespace();
let (Some(method), Some(path), Some(_version), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return respond(&mut stream, 400, "text/plain", b"bad request\n");
};
if method != "GET" {
return respond(&mut stream, 405, "text/plain", b"method not allowed\n");
}
println!("request: {method} {path}");
let release_path = format!("/repos/{repository}/releases/latest");
if path == release_path {
let body = serde_json::to_vec(&Release {
assets: list_assets(assets_dir, public_url)?,
})?;
return respond(&mut stream, 200, "application/json", &body);
}
if let Some(name) = path.strip_prefix("/assets/") {
if !valid_name(name) {
return respond(&mut stream, 400, "text/plain", b"invalid asset name\n");
}
let asset_path = assets_dir.join(name);
return match fs::read(&asset_path) {
Ok(body) => respond(&mut stream, 200, "application/octet-stream", &body),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
respond(&mut stream, 404, "text/plain", b"not found\n")
}
Err(error) => {
Err(error).with_context(|| format!("failed to read {}", asset_path.display()))
}
};
}
respond(&mut stream, 404, "text/plain", b"not found\n")
}
fn list_assets(directory: &Path, public_url: &str) -> Result<Vec<Asset>> {
let mut assets = Vec::new();
for entry in fs::read_dir(directory)
.with_context(|| format!("failed to list {}", directory.display()))?
{
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("asset filename is not valid UTF-8"))?;
let supported_extension = matches!(
entry.path().extension().and_then(|value| value.to_str()),
Some("deb" | "apk" | "ipk")
);
if !valid_name(&name) || !supported_extension {
continue;
}
assets.push(Asset {
browser_download_url: format!("{public_url}/assets/{name}"),
name,
});
}
assets.sort_by(|left, right| left.name.cmp(&right.name));
Ok(assets)
}
fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn validate_repository(repository: &str) -> Result<()> {
let mut parts = repository.split('/');
match (parts.next(), parts.next(), parts.next()) {
(Some(owner), Some(repo), None) if valid_name(owner) && valid_name(repo) => Ok(()),
_ => bail!("invalid repository {repository:?}; expected owner/name"),
}
}
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) -> Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
write!(
stream,
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)?;
stream.write_all(body)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_package_names_and_rejects_paths() {
assert!(valid_name("cheburprobe-0.6.0-r1_x86_64.apk"));
assert!(valid_name("luci-app-cheburprobe_0.6.0-1_all.ipk"));
assert!(!valid_name("../cheburprobe.apk"));
assert!(!valid_name("directory/cheburprobe.apk"));
}
}
+555
View File
@@ -0,0 +1,555 @@
use anyhow::{Context, Result, bail};
use reqwest::{Client, Url};
use semver::Version;
use serde::Deserialize;
use std::env;
#[cfg(unix)]
use std::fs::{File, OpenOptions};
use std::path::Path;
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use tempfile::TempDir;
const DEFAULT_REPOSITORY: &str = "LowderPlay/cheburcheck";
const DEFAULT_API_BASE_URL: &str = "https://api.github.com";
#[derive(Debug, Deserialize)]
struct Release {
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PackageKind {
Debian,
Apk,
Opkg,
Linux,
#[cfg_attr(not(windows), allow(dead_code))]
Windows,
}
#[cfg(unix)]
struct UpdateLock {
_file: File,
}
#[cfg(unix)]
impl UpdateLock {
fn acquire() -> Result<Option<Self>> {
let lock_path = env::temp_dir().join(format!(
"cheburprobe-update-{}.lock",
rustix::process::getuid().as_raw()
));
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("failed to open update lock {}", lock_path.display()))?;
match rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
Ok(()) => Ok(Some(Self { _file: file })),
Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
Err(error) => Err(error).context("failed to lock updater"),
}
}
}
#[cfg(windows)]
struct UpdateLock;
#[cfg(windows)]
impl UpdateLock {
fn acquire() -> Result<Option<Self>> {
Ok(Some(Self))
}
}
pub async fn run() -> Result<()> {
let Some(_lock) = UpdateLock::acquire()? else {
println!("another cheburprobe update check is already running");
return Ok(());
};
update().await
}
async fn update() -> Result<()> {
let repository =
env::var("CHEBURPROBE_UPDATE_REPOSITORY").unwrap_or_else(|_| DEFAULT_REPOSITORY.to_owned());
validate_repository(&repository)?;
let current = Version::parse(env!("CARGO_PKG_VERSION"))
.context("the installed cheburprobe version is invalid")?;
let client = Client::builder()
.user_agent(concat!("cheburprobe-update/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(60))
.build()
.context("failed to create HTTP client")?;
let api_base_url = env::var("CHEBURPROBE_UPDATE_API_BASE_URL")
.unwrap_or_else(|_| DEFAULT_API_BASE_URL.to_owned());
let api_url = format!(
"{}/repos/{repository}/releases/latest",
api_base_url.trim_end_matches('/')
);
let release = fetch_release(&client, &api_url).await?;
let (kind, architecture, luci_installed) = detect_platform()?;
let (asset, latest) = select_asset(&release.assets, kind, &architecture)?;
if latest <= current {
if latest == current {
println!("cheburprobe is current ({current})");
} else {
println!(
"installed cheburprobe {current} is newer than packaged version {latest}; not downgrading"
);
}
return Ok(());
}
let luci_asset = luci_installed
.then(|| select_luci_asset(&release.assets, kind, &latest))
.transpose()?;
let temp_dir = TempDir::with_prefix("cheburprobe-update.")
.context("failed to create a temporary update directory")?;
let package = download_asset(&client, asset, temp_dir.path()).await?;
let luci_package = match luci_asset {
Some(asset) => Some(download_asset(&client, asset, temp_dir.path()).await?),
None => None,
};
install(kind, &package, luci_package.as_deref())?;
println!("updated cheburprobe from {current} to {latest}");
Ok(())
}
async fn fetch_release(client: &Client, api_url: &str) -> Result<Release> {
client
.get(api_url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("failed to query the latest GitHub release")?
.error_for_status()
.context("GitHub rejected the latest-release request")?
.json::<Release>()
.await
.context("GitHub returned an invalid release document")
}
fn validate_repository(repository: &str) -> Result<()> {
let mut parts = repository.split('/');
let valid_part = |part: &str| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
};
match (parts.next(), parts.next(), parts.next()) {
(Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => Ok(()),
_ => bail!("invalid GitHub repository {repository:?}; expected owner/name"),
}
}
fn command_exists(command: &str) -> bool {
env::var_os("PATH").is_some_and(|path| {
env::split_paths(&path).any(|directory| directory.join(command).is_file())
})
}
fn command_output(command: &str, arguments: &[&str]) -> Result<Output> {
let output = Command::new(command)
.args(arguments)
.output()
.with_context(|| format!("failed to execute {command}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
bail!("{command} exited with {}: {stderr}", output.status);
}
Ok(output)
}
fn output_text(command: &str, arguments: &[&str]) -> Result<String> {
let output = command_output(command, arguments)?;
String::from_utf8(output.stdout).with_context(|| format!("{command} returned non-UTF-8 output"))
}
fn command_succeeds(command: &str, arguments: &[&str]) -> Result<bool> {
let status = Command::new(command)
.args(arguments)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.with_context(|| format!("failed to execute {command}"))?;
Ok(status.success())
}
fn detect_platform() -> Result<(PackageKind, String, bool)> {
#[cfg(windows)]
{
let architecture = match env::consts::ARCH {
"x86_64" => "x86_64",
architecture => bail!("unsupported Windows architecture: {architecture}"),
};
return Ok((PackageKind::Windows, architecture.to_owned(), false));
}
#[cfg(target_os = "linux")]
if command_exists("dpkg") && command_succeeds("dpkg-query", &["-W", "cheburprobe"])? {
let architecture = output_text("dpkg", &["--print-architecture"])?;
let architecture = architecture.trim();
if !matches!(architecture, "amd64" | "arm64") {
bail!("unsupported Debian architecture: {architecture}");
}
Ok((PackageKind::Debian, architecture.to_owned(), false))
} else if command_exists("apk")
&& command_succeeds("apk", &["info", "--exists", "cheburprobe"])?
{
let architecture = output_text("apk", &["--print-arch"])?;
let luci_installed =
command_succeeds("apk", &["info", "--exists", "luci-app-cheburprobe"])?;
Ok((
PackageKind::Apk,
architecture.trim().to_owned(),
luci_installed,
))
} else if command_exists("opkg")
&& output_text("opkg", &["list-installed", "cheburprobe"])?
.lines()
.any(|line| line.split_whitespace().next() == Some("cheburprobe"))
{
let architectures = output_text("opkg", &["print-architecture"])?;
let architecture = architectures
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.rfind(|architecture| *architecture != "all")
.context("opkg did not report a package architecture")?;
let luci_installed = output_text("opkg", &["list-installed", "luci-app-cheburprobe"])?
.lines()
.any(|line| line.split_whitespace().next() == Some("luci-app-cheburprobe"));
Ok((PackageKind::Opkg, architecture.to_owned(), luci_installed))
} else {
let architecture = match env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
architecture => bail!("unsupported standalone Linux architecture: {architecture}"),
};
Ok((PackageKind::Linux, architecture.to_owned(), false))
}
#[cfg(not(any(target_os = "linux", windows)))]
bail!("updates are not supported on this operating system")
}
fn select_luci_asset<'a>(
assets: &'a [Asset],
kind: PackageKind,
version: &Version,
) -> Result<&'a Asset> {
let (prefix, suffix) = match kind {
PackageKind::Apk => (format!("luci-app-cheburprobe-{version}-r"), ".apk"),
PackageKind::Opkg => (format!("luci-app-cheburprobe_{version}-"), "_all.ipk"),
PackageKind::Debian | PackageKind::Linux | PackageKind::Windows => {
bail!("LuCI packages are only supported on OpenWrt")
}
};
let matches: Vec<_> = assets
.iter()
.filter(|asset| asset.name.starts_with(&prefix) && asset.name.ends_with(suffix))
.collect();
match matches.as_slice() {
[asset] => Ok(asset),
[] => bail!("LuCI package for v{version} not found"),
_ => bail!("multiple LuCI packages for v{version} found"),
}
}
fn package_version(name: &str, kind: PackageKind, architecture: &str) -> Option<Version> {
let (prefix, suffix) = match kind {
PackageKind::Debian => ("cheburprobe_", format!("_{architecture}.deb")),
PackageKind::Apk => ("cheburprobe-", format!("_{architecture}.apk")),
PackageKind::Opkg => ("cheburprobe_", format!("_{architecture}.ipk")),
PackageKind::Linux => ("cheburprobe-", format!("-linux-{architecture}")),
PackageKind::Windows => ("cheburprobe-", format!("-windows-{architecture}.exe")),
};
let version_with_revision = name.strip_prefix(prefix)?.strip_suffix(&suffix)?;
let version = match kind {
PackageKind::Apk => version_with_revision.rsplit_once("-r")?.0,
PackageKind::Debian | PackageKind::Opkg => version_with_revision.rsplit_once('-')?.0,
PackageKind::Linux | PackageKind::Windows => version_with_revision,
};
Version::parse(version).ok()
}
fn select_asset<'a>(
assets: &'a [Asset],
kind: PackageKind,
architecture: &str,
) -> Result<(&'a Asset, Version)> {
let mut matches: Vec<_> = assets
.iter()
.filter_map(|asset| {
package_version(&asset.name, kind, architecture).map(|version| (asset, version))
})
.collect();
matches.sort_by(|(_, left), (_, right)| left.cmp(right));
let Some((asset, version)) = matches.pop() else {
bail!("Cheburprobe package for architecture {architecture} not found");
};
if matches.last().is_some_and(|(_, other)| other == &version) {
bail!("multiple Cheburprobe {version} packages for architecture {architecture} found");
}
Ok((asset, version))
}
async fn download(client: &Client, url: Url, destination: &Path) -> Result<()> {
let bytes = client
.get(url)
.send()
.await
.context("failed to download the update package")?
.error_for_status()
.context("GitHub rejected the package download")?
.bytes()
.await
.context("failed to read the update package")?;
std::fs::write(destination, bytes)
.with_context(|| format!("failed to write {}", destination.display()))
}
async fn download_asset(
client: &Client,
asset: &Asset,
directory: &Path,
) -> Result<std::path::PathBuf> {
if Path::new(&asset.name)
.file_name()
.and_then(|name| name.to_str())
!= Some(&asset.name)
{
bail!("invalid release asset name: {:?}", asset.name);
}
let url = Url::parse(&asset.browser_download_url)
.context("GitHub returned an invalid release asset URL")?;
let destination = directory.join(&asset.name);
download(client, url, &destination).await?;
Ok(destination)
}
fn run_paths(command: &str, arguments: &[&Path]) -> Result<()> {
let status = Command::new(command)
.args(arguments)
.status()
.with_context(|| format!("failed to execute {command}"))?;
if !status.success() {
bail!("{command} exited with {status}");
}
Ok(())
}
fn run_args(command: &str, arguments: &[&str]) -> Result<()> {
let status = Command::new(command)
.args(arguments)
.status()
.with_context(|| format!("failed to execute {command}"))?;
if !status.success() {
bail!("{command} exited with {status}");
}
Ok(())
}
fn install(kind: PackageKind, package: &Path, luci_package: Option<&Path>) -> Result<()> {
match kind {
PackageKind::Debian => {
run_paths("dpkg-deb", &[Path::new("--info"), package])?;
run_paths("dpkg", &[Path::new("-i"), package])?;
run_args("systemctl", &["try-restart", "cheburprobe.service"])
}
PackageKind::Apk => {
let mut arguments = vec![Path::new("add"), Path::new("--allow-untrusted"), package];
arguments.extend(luci_package);
run_paths("apk", &arguments)?;
run_args("/etc/init.d/cheburprobe", &["restart"])
}
PackageKind::Opkg => {
let mut arguments = vec![Path::new("install"), package];
arguments.extend(luci_package);
run_paths("opkg", &arguments)?;
run_args("/etc/init.d/cheburprobe", &["restart"])
}
PackageKind::Linux => replace_linux_executable(package),
PackageKind::Windows => replace_windows_executable(package),
}
}
#[cfg(unix)]
fn replace_linux_executable(package: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let executable = env::current_exe().context("failed to locate the running executable")?;
let replacement = executable.with_extension("new");
let mode = std::fs::metadata(&executable)
.context("failed to inspect the running executable")?
.permissions()
.mode();
std::fs::copy(package, &replacement)
.context("failed to copy the new Linux executable beside the current one")?;
std::fs::set_permissions(&replacement, std::fs::Permissions::from_mode(mode))
.context("failed to set permissions on the new Linux executable")?;
if let Err(error) = std::fs::rename(&replacement, &executable) {
let _ = std::fs::remove_file(&replacement);
return Err(error).context("failed to replace the Linux executable");
}
Ok(())
}
#[cfg(not(unix))]
fn replace_linux_executable(_package: &Path) -> Result<()> {
bail!("Linux executable replacement is unavailable on this platform")
}
#[cfg(windows)]
fn replace_windows_executable(package: &Path) -> Result<()> {
let executable = env::current_exe().context("failed to locate the running executable")?;
let backup = executable.with_extension("old.exe");
match std::fs::remove_file(&backup) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).context("failed to remove the previous executable backup"),
}
std::fs::rename(&executable, &backup)
.context("failed to move the running executable to its backup path")?;
if let Err(error) = std::fs::copy(package, &executable) {
let _ = std::fs::rename(&backup, &executable);
return Err(error).context("failed to install the new Windows executable");
}
// The renamed executable may stay locked until this process exits. A later
// update removes the backup if it cannot be deleted immediately.
let _ = std::fs::remove_file(backup);
Ok(())
}
#[cfg(not(windows))]
fn replace_windows_executable(_package: &Path) -> Result<()> {
bail!("Windows executable replacement is unavailable on this platform")
}
#[cfg(test)]
mod tests {
use super::*;
fn asset(name: &str) -> Asset {
Asset {
name: name.to_owned(),
browser_download_url: format!(
"https://github.com/LowderPlay/cheburcheck/releases/download/v0.5.0/{name}"
),
}
}
#[test]
fn parses_versions_from_package_names() {
assert_eq!(
package_version(
"cheburprobe_1.2.3-1_arm64.deb",
PackageKind::Debian,
"arm64"
),
Some(Version::new(1, 2, 3))
);
assert_eq!(
package_version(
"cheburprobe-1.2.3-r1_aarch64_generic.apk",
PackageKind::Apk,
"aarch64_generic"
),
Some(Version::new(1, 2, 3))
);
assert_eq!(
package_version(
"cheburprobe_1.2.3-1_aarch64_generic.ipk",
PackageKind::Opkg,
"aarch64_generic"
),
Some(Version::new(1, 2, 3))
);
}
#[test]
fn validates_repository_names() {
assert!(validate_repository("LowderPlay/cheburcheck").is_ok());
assert!(validate_repository("owner/repo/extra").is_err());
assert!(validate_repository("owner?x/repo").is_err());
}
#[test]
fn selects_each_package_format() {
let assets = vec![
asset("cheburprobe_0.5.0-1_arm64.deb"),
asset("cheburprobe-0.5.0-r1_aarch64_generic.apk"),
asset("cheburprobe_0.5.0-1_aarch64_generic.ipk"),
asset("luci-app-cheburprobe-0.5.0-r1.apk"),
asset("luci-app-cheburprobe_0.5.0-1_all.ipk"),
asset("cheburprobe-0.5.0-windows-x86_64.exe"),
asset("cheburprobe-0.5.0-linux-amd64"),
];
let version = Version::new(0, 5, 0);
assert_eq!(
select_asset(&assets, PackageKind::Debian, "arm64")
.unwrap()
.0
.name,
"cheburprobe_0.5.0-1_arm64.deb"
);
assert_eq!(
select_asset(&assets, PackageKind::Apk, "aarch64_generic")
.unwrap()
.0
.name,
"cheburprobe-0.5.0-r1_aarch64_generic.apk"
);
assert_eq!(
select_asset(&assets, PackageKind::Opkg, "aarch64_generic")
.unwrap()
.0
.name,
"cheburprobe_0.5.0-1_aarch64_generic.ipk"
);
assert_eq!(
select_luci_asset(&assets, PackageKind::Apk, &version)
.unwrap()
.name,
"luci-app-cheburprobe-0.5.0-r1.apk"
);
assert_eq!(
select_luci_asset(&assets, PackageKind::Opkg, &version)
.unwrap()
.name,
"luci-app-cheburprobe_0.5.0-1_all.ipk"
);
assert_eq!(
select_asset(&assets, PackageKind::Windows, "x86_64")
.unwrap()
.0
.name,
"cheburprobe-0.5.0-windows-x86_64.exe"
);
assert_eq!(
select_asset(&assets, PackageKind::Linux, "amd64")
.unwrap()
.0
.name,
"cheburprobe-0.5.0-linux-amd64"
);
}
}