//! Self-upgrade support for `aether-tunnel`. //! //! Downloads a release from GitHub, verifies the SHA256 checksum, replaces the //! running binary atomically, and restarts the active managed service when //! applicable. use std::io::{Read, Write}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use aether_http::{apply_http_client_config, read_response_bytes_with_limit, HttpClientConfig}; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use sha2::{Digest, Sha256}; const GITHUB_API_BASE: &str = "https://api.github.com"; const GITHUB_REPO: &str = "fawney19/Aether"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const MAX_GITHUB_RELEASE_METADATA_BYTES: usize = 8 * 1024 * 1024; const MAX_GITHUB_ERROR_RESPONSE_BYTES: usize = 256 * 1024; const MAX_RELEASE_ARCHIVE_DOWNLOAD_BYTES: usize = 128 * 1024 * 1024; const MAX_CHECKSUM_DOWNLOAD_BYTES: usize = 1024 * 1024; fn summarize_remote_error_body(body: &[u8]) -> String { let digest = Sha256::digest(body); format!( "response body redacted (bytes={}, sha256={})", body.len(), hex::encode(digest) ) } // ── GitHub API types ───────────────────────────────────────────────────────── #[derive(serde::Deserialize)] struct GithubRelease { tag_name: String, #[serde(default)] name: Option, #[serde(default)] draft: bool, #[serde(default)] prerelease: bool, } fn tunnel_release_semver(tag: &str) -> anyhow::Result { if tag.len() > 160 || !tag .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')) { anyhow::bail!("invalid tunnel release tag") } let version = tag .strip_prefix("tunnel-v") .or_else(|| tag.strip_prefix("proxy-v")) .ok_or_else(|| anyhow::anyhow!("tunnel release tag has an unsupported prefix"))?; semver::Version::parse(version) .map_err(|_| anyhow::anyhow!("tunnel release tag is not valid semantic versioning")) } fn normalize_requested_release_tag(version: &str) -> anyhow::Result { let version = version.trim(); if version.is_empty() { anyhow::bail!("upgrade version must not be empty"); } let tag = if version.starts_with("tunnel-v") || version.starts_with("proxy-v") { version.to_string() } else { format!("tunnel-v{version}") }; tunnel_release_semver(&tag)?; Ok(tag) } // ── Platform detection ─────────────────────────────────────────────────────── fn detect_platform() -> &'static str { if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") && cfg!(target_env = "musl") { "linux-musl-amd64" } else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") && cfg!(target_env = "musl") { "linux-musl-arm64" } else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") { "linux-amd64" } else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") { "linux-arm64" } else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") { "macos-amd64" } else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") { "macos-arm64" } else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") { "windows-amd64" } else { // All supported targets are covered above; this is unreachable for // any platform we actually build for. panic!("unsupported platform: compile-time target not in the supported matrix") } } // ── GitHub HTTP client ─────────────────────────────────────────────────────── fn build_github_api_client() -> anyhow::Result { let mut headers = reqwest::header::HeaderMap::new(); if let Ok(token) = std::env::var("GITHUB_TOKEN") { headers.insert( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?, ); } headers.insert( reqwest::header::ACCEPT, reqwest::header::HeaderValue::from_static("application/vnd.github+json"), ); Ok(apply_http_client_config( reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .dns_resolver(Arc::new(SafeGithubDnsResolver)) .default_headers(headers), &HttpClientConfig { request_timeout_ms: Some(300_000), user_agent: Some(format!("aether-tunnel/{}", CURRENT_VERSION)), ..HttpClientConfig::default() }, ) .build()?) } fn build_github_download_client() -> anyhow::Result { Ok(apply_http_client_config( reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::custom(|attempt| { if attempt.previous().len() >= 10 { return attempt.error("too many GitHub release redirects"); } if is_trusted_github_download_url(attempt.url()) { attempt.follow() } else { attempt.error("GitHub release redirected to an untrusted URL") } })) .dns_resolver(Arc::new(SafeGithubDnsResolver)), &HttpClientConfig { request_timeout_ms: Some(300_000), user_agent: Some(format!("aether-tunnel/{}", CURRENT_VERSION)), ..HttpClientConfig::default() }, ) .build()?) } #[derive(Debug)] struct SafeGithubDnsResolver; impl Resolve for SafeGithubDnsResolver { fn resolve(&self, name: Name) -> Resolving { let host = name.as_str().trim_end_matches('.').to_ascii_lowercase(); Box::pin(async move { let addresses = aether_http::lookup_host_with_limits( host.as_str(), 0, aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT, ) .await .map_err(|error| -> Box { Box::new(error) })?; validate_github_resolved_addrs(&addresses).map_err(|message| { Box::new(std::io::Error::other(message)) as Box })?; Ok(Box::new(addresses.into_iter()) as Addrs) }) } } fn validate_github_resolved_addrs(addresses: &[SocketAddr]) -> Result<(), &'static str> { if addresses.is_empty() { return Err("GitHub DNS resolution returned no addresses"); } if addresses .iter() .any(|address| aether_http::is_private_or_reserved_ip(address.ip())) { return Err("GitHub DNS resolution returned a private or reserved address"); } Ok(()) } fn is_trusted_github_download_url(url: &url::Url) -> bool { if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() || url.port_or_known_default() != Some(443) { return false; } let Some(host) = url.host_str() else { return false; }; host.eq_ignore_ascii_case("github.com") || host.eq_ignore_ascii_case("objects.githubusercontent.com") || host.ends_with(".objects.githubusercontent.com") || host.eq_ignore_ascii_case("release-assets.githubusercontent.com") || host.ends_with(".release-assets.githubusercontent.com") } // ── Release fetching ───────────────────────────────────────────────────────── async fn fetch_release( client: &reqwest::Client, version: Option<&str>, ) -> anyhow::Result { match version { Some(ver) => { // Accept both "tunnel-v0.2.0" and the legacy "proxy-v0.2.0". let tag = normalize_requested_release_tag(ver)?; let url = format!( "{}/repos/{}/releases/tags/{}", GITHUB_API_BASE, GITHUB_REPO, tag ); let resp = client.get(&url).send().await?; let status = resp.status(); let max_bytes = if status.is_success() { MAX_GITHUB_RELEASE_METADATA_BYTES } else { MAX_GITHUB_ERROR_RESPONSE_BYTES }; let body = read_response_bytes_with_limit(resp, max_bytes) .await .map_err(|error| { anyhow::anyhow!("failed to read GitHub release response: {error}") })?; if !status.is_success() { anyhow::bail!( "release '{}' not found (HTTP {}): {}", tag, status, summarize_remote_error_body(&body) ); } let release: GithubRelease = serde_json::from_slice(&body)?; if release.draft || release.tag_name != tag { anyhow::bail!("GitHub returned a draft or mismatched tunnel release"); } tunnel_release_semver(&release.tag_name)?; Ok(release) } None => { // List releases and find the latest tunnel-v* tag let url = format!( "{}/repos/{}/releases?per_page=20", GITHUB_API_BASE, GITHUB_REPO ); let resp = client.get(&url).send().await?; let status = resp.status(); let max_bytes = if status.is_success() { MAX_GITHUB_RELEASE_METADATA_BYTES } else { MAX_GITHUB_ERROR_RESPONSE_BYTES }; let body = read_response_bytes_with_limit(resp, max_bytes) .await .map_err(|error| { anyhow::anyhow!("failed to read GitHub releases response: {error}") })?; if !status.is_success() { anyhow::bail!( "failed to list releases (HTTP {}): {}", status, summarize_remote_error_body(&body) ); } let releases: Vec = serde_json::from_slice(&body)?; releases .into_iter() .find(|release| { !release.draft && !release.prerelease && tunnel_release_semver(&release.tag_name).is_ok() }) .ok_or_else(|| anyhow::anyhow!("no tunnel-v* release found")) } } } // ── Download via GitHub release direct links ───────────────────────────────── /// Download a release asset via the public direct download URL: /// `https://github.com/{repo}/releases/download/{tag}/{filename}` async fn download_release_file( client: &reqwest::Client, tag: &str, filename: &str, max_bytes: usize, ) -> anyhow::Result> { tunnel_release_semver(tag)?; if filename.is_empty() || filename.len() > 160 || !filename .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) { anyhow::bail!("invalid GitHub release asset name"); } let url = format!( "https://github.com/{}/releases/download/{}/{}", GITHUB_REPO, tag, filename ); let mut resp = client .get(&url) .header(reqwest::header::ACCEPT, "application/octet-stream") .send() .await?; if !resp.status().is_success() { anyhow::bail!( "download failed for '{}' (HTTP {})", filename, resp.status(), ); } if resp .content_length() .is_some_and(|length| length > max_bytes as u64) { anyhow::bail!("download for '{}' exceeds {} bytes", filename, max_bytes); } let mut bytes = Vec::new(); while let Some(chunk) = resp.chunk().await? { append_bounded_download_chunk(&mut bytes, &chunk, max_bytes, filename)?; } Ok(bytes) } fn append_bounded_download_chunk( bytes: &mut Vec, chunk: &[u8], max_bytes: usize, filename: &str, ) -> anyhow::Result<()> { if chunk.len() > max_bytes.saturating_sub(bytes.len()) { anyhow::bail!("download for '{}' exceeds {} bytes", filename, max_bytes); } bytes.extend_from_slice(chunk); Ok(()) } fn parse_checksum(sums_text: &str, filename: &str) -> anyhow::Result { let mut matches = Vec::new(); for line in sums_text.lines() { // Format: " " (GNU coreutils convention) let mut parts = line.split_ascii_whitespace(); let (Some(hash), Some(name)) = (parts.next(), parts.next()) else { continue; }; if parts.next().is_some() { continue; } let name = name.strip_prefix('*').unwrap_or(name); if name == filename && hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) { matches.push(hash.to_ascii_lowercase()); } } match matches.as_slice() { [hash] => Ok(hash.clone()), [] => anyhow::bail!("checksum for '{}' not found in SHA256SUMS.txt", filename), _ => anyhow::bail!( "SHA256SUMS.txt contains multiple valid entries for '{}'", filename ), } } async fn download_and_verify( client: &reqwest::Client, tag: &str, platform: &str, dest: &Path, ) -> anyhow::Result<()> { let archive_name = format!("aether-tunnel-{}.tar.gz", platform); eprintln!(" Downloading {}...", archive_name); let (archive_bytes, checksum_bytes) = tokio::try_join!( download_release_file( client, tag, &archive_name, MAX_RELEASE_ARCHIVE_DOWNLOAD_BYTES, ), download_release_file(client, tag, "SHA256SUMS.txt", MAX_CHECKSUM_DOWNLOAD_BYTES,), )?; let checksum_text = String::from_utf8(checksum_bytes)?; eprintln!( " Downloaded {} ({} bytes)", archive_name, archive_bytes.len() ); // Verify SHA256 let expected_hash = parse_checksum(&checksum_text, &archive_name)?; let mut hasher = Sha256::new(); hasher.update(&archive_bytes); let actual_hash = hex::encode(hasher.finalize()); if actual_hash != expected_hash { anyhow::bail!( "SHA256 mismatch for {}:\n expected: {}\n actual: {}", archive_name, expected_hash, actual_hash ); } eprintln!(" SHA256 verified: {}", &actual_hash[..16]); extract_binary(&archive_bytes, dest)?; Ok(()) } // ── Archive extraction ─────────────────────────────────────────────────────── fn extract_binary(archive_bytes: &[u8], dest: &Path) -> anyhow::Result<()> { use flate2::read::GzDecoder; use tar::Archive; // Guard against decompression bombs const MAX_BINARY_SIZE: u64 = 100 * 1024 * 1024; // 100 MB let decoder = GzDecoder::new(archive_bytes); let mut archive = Archive::new(decoder); let binary_name = if cfg!(target_os = "windows") { "aether-tunnel.exe" } else { "aether-tunnel" }; let mut entries = archive.entries()?; let mut entry = entries .next() .transpose()? .ok_or_else(|| anyhow::anyhow!("release archive is empty"))?; if entry.header().entry_type() != tar::EntryType::Regular { anyhow::bail!("release archive entry is not a regular file"); } let path = entry.path()?; if path.as_ref() != Path::new(binary_name) { anyhow::bail!( "release archive must contain only '{}' at its root", binary_name ); } let size = entry.header().size()?; if size == 0 || size > MAX_BINARY_SIZE { anyhow::bail!( "invalid binary size ({} bytes, expected 1..={} bytes)", size, MAX_BINARY_SIZE ); } let mut binary = Vec::with_capacity(size as usize); entry .by_ref() .take(MAX_BINARY_SIZE + 1) .read_to_end(&mut binary)?; if binary.len() as u64 != size { anyhow::bail!("release archive binary size does not match its header"); } drop(entry); if entries.next().transpose()?.is_some() { anyhow::bail!("release archive must contain exactly one entry"); } let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o700); } let mut file = options.open(dest).map_err(|error| { anyhow::anyhow!( "refusing to overwrite upgrade staging path '{}': {}", dest.display(), error ) })?; let write_result = (|| -> std::io::Result<()> { file.write_all(&binary)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; file.set_permissions(std::fs::Permissions::from_mode(0o755))?; } file.sync_all() })(); drop(file); if let Err(error) = write_result { remove_upgrade_file_if_regular(dest); return Err(error.into()); } Ok(()) } fn remove_upgrade_file_if_regular(path: &Path) { if std::fs::symlink_metadata(path) .is_ok_and(|metadata| !metadata.file_type().is_symlink() && metadata.is_file()) { let _ = std::fs::remove_file(path); } } fn validate_upgrade_storage(current_exe: &Path) -> anyhow::Result<()> { let current_metadata = std::fs::symlink_metadata(current_exe)?; if current_metadata.file_type().is_symlink() || !current_metadata.is_file() { anyhow::bail!("current tunnel executable must be a regular file"); } let exe_dir = current_exe .parent() .ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?; let directory_metadata = std::fs::symlink_metadata(exe_dir)?; if directory_metadata.file_type().is_symlink() || !directory_metadata.is_dir() { anyhow::bail!("tunnel executable directory must be a real directory"); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; // SAFETY: geteuid has no preconditions and does not retain pointers. let effective_uid = unsafe { libc::geteuid() }; if current_metadata.uid() != effective_uid || current_metadata.mode() & 0o7022 != 0 || current_metadata.mode() & 0o100 == 0 || current_metadata.nlink() != 1 { anyhow::bail!("current tunnel executable ownership or permissions are unsafe"); } let directory_mode = directory_metadata.mode(); if directory_metadata.uid() != effective_uid || directory_mode & 0o022 != 0 || ((directory_mode >> 6) & 0o3) != 0o3 { anyhow::bail!("tunnel executable directory ownership or permissions are unsafe"); } // The immediate directory is protected above. Every ancestor must also be // controlled by this user or root; shared writable ancestors are accepted // only when the sticky bit prevents unrelated users from replacing entries. let canonical_exe_dir = std::fs::canonicalize(exe_dir)?; let mut ancestor = canonical_exe_dir.parent(); while let Some(directory) = ancestor { let metadata = std::fs::symlink_metadata(directory)?; let mode = metadata.mode(); if metadata.file_type().is_symlink() || !metadata.is_dir() || (metadata.uid() != effective_uid && metadata.uid() != 0) || (mode & 0o022 != 0 && mode & 0o1000 == 0) { anyhow::bail!( "tunnel executable ancestor '{}' has unsafe ownership or permissions", directory.display() ); } ancestor = directory.parent(); } } #[cfg(not(unix))] anyhow::bail!( "safe atomic tunnel self-upgrade is not supported on this platform; reinstall the release manually" ); #[cfg(unix)] Ok(()) } fn probe_upgrade_directory_write(exe_dir: &Path) -> anyhow::Result<()> { let probe_path = exe_dir.join(format!( ".aether-tunnel.write-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let probe = options.open(&probe_path)?; drop(probe); std::fs::remove_file(&probe_path)?; Ok(()) } // ── Atomic binary replacement ──────────────────────────────────────────────── fn atomic_replace(new_binary: &Path) -> anyhow::Result { let current_exe = std::env::current_exe()?.canonicalize()?; atomic_replace_paths(¤t_exe, new_binary) } fn atomic_replace_paths(current_exe: &Path, new_binary: &Path) -> anyhow::Result { let current_exe = std::fs::canonicalize(current_exe)?; validate_upgrade_storage(¤t_exe)?; let current_parent = current_exe .parent() .ok_or_else(|| anyhow::anyhow!("cannot determine current binary directory"))?; let new_parent = new_binary .parent() .ok_or_else(|| anyhow::anyhow!("cannot determine staged binary directory"))?; if std::fs::canonicalize(current_parent)? != std::fs::canonicalize(new_parent)? { anyhow::bail!("staged and current tunnel binaries must share one directory"); } let new_metadata = std::fs::symlink_metadata(new_binary)?; if new_metadata.file_type().is_symlink() || !new_metadata.is_file() { anyhow::bail!("staged tunnel upgrade must be a regular file"); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; // SAFETY: geteuid has no preconditions and does not retain pointers. let effective_uid = unsafe { libc::geteuid() }; if new_metadata.uid() != effective_uid || new_metadata.mode() & 0o7022 != 0 || new_metadata.nlink() != 1 { anyhow::bail!("staged tunnel upgrade ownership or permissions are unsafe"); } } let backup_path = current_exe.with_extension("bak"); #[cfg(unix)] { let backup_staging = current_parent.join(format!( ".aether-tunnel.backup-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); std::fs::hard_link(¤t_exe, &backup_staging).map_err(|error| { anyhow::anyhow!( "failed to create a no-clobber backup of '{}': {}", current_exe.display(), error ) })?; let prepare_result = std::fs::File::open(&backup_staging) .and_then(|file| file.sync_all()) .and_then(|_| sync_upgrade_directory(current_parent)); if let Err(error) = prepare_result { remove_upgrade_file_if_regular(&backup_staging); return Err(anyhow::anyhow!( "failed to durably stage the current tunnel binary backup: {error}" )); } if let Err(error) = std::fs::rename(new_binary, ¤t_exe) { remove_upgrade_file_if_regular(&backup_staging); anyhow::bail!( "failed to atomically install new binary '{}' -> '{}': {}", new_binary.display(), current_exe.display(), error ); } if let Err(sync_error) = sync_upgrade_directory(current_parent) { let rollback_result = std::fs::rename(&backup_staging, ¤t_exe) .and_then(|_| sync_upgrade_directory(current_parent)); if let Err(rollback_error) = rollback_result { anyhow::bail!( "installed tunnel binary but directory sync failed ({sync_error}); rollback also failed ({rollback_error})" ); } anyhow::bail!( "tunnel binary replacement was rolled back after directory sync failed: {sync_error}" ); } match std::fs::rename(&backup_staging, &backup_path) { Ok(()) => { if let Err(error) = sync_upgrade_directory(current_parent) { eprintln!( " WARNING: new binary is durable, but final backup-name sync failed: {}", error ); } } Err(error) => { eprintln!( " WARNING: could not rotate prior backup ({}); keeping old binary at {}", error, backup_staging.display() ); eprintln!(" Binary replaced: {}", current_exe.display()); return Ok(backup_staging); } } eprintln!(" Binary replaced: {}", current_exe.display()); return Ok(backup_path); } #[cfg(not(unix))] { let _ = (new_binary, backup_path); anyhow::bail!( "safe atomic tunnel self-upgrade is not supported on this platform; reinstall the release manually" ) } } fn sync_upgrade_directory(path: &Path) -> std::io::Result<()> { std::fs::File::open(path)?.sync_all() } fn restore_tunnel_backup(backup_path: &Path) -> anyhow::Result<()> { let current_exe = std::env::current_exe()?.canonicalize()?; restore_tunnel_backup_paths(¤t_exe, backup_path) } fn restore_tunnel_backup_paths(current_exe: &Path, backup_path: &Path) -> anyhow::Result<()> { let current_exe = std::fs::canonicalize(current_exe)?; validate_upgrade_storage(¤t_exe)?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let current_parent = current_exe .parent() .ok_or_else(|| anyhow::anyhow!("cannot determine current binary directory"))?; if backup_path.parent() != Some(current_parent) { anyhow::bail!("tunnel backup is outside the executable directory"); } let metadata = std::fs::symlink_metadata(backup_path)?; // SAFETY: geteuid has no preconditions and does not retain pointers. let effective_uid = unsafe { libc::geteuid() }; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.uid() != effective_uid || metadata.mode() & 0o7022 != 0 || metadata.nlink() != 1 { anyhow::bail!("tunnel backup ownership or permissions are unsafe"); } std::fs::rename(backup_path, ¤t_exe)?; if let Err(error) = sync_upgrade_directory(current_parent) { eprintln!( " WARNING: old binary was restored, but directory sync failed: {}", error ); } return Ok(()); } #[cfg(not(unix))] { let _ = (current_exe, backup_path); anyhow::bail!("safe tunnel upgrade rollback is not supported on this platform") } } // ── Public entry point ─────────────────────────────────────────────────────── #[derive(Clone, Copy)] enum RestartMode { BestEffort, Required, } async fn execute_upgrade( version: Option<&str>, require_root: bool, restart_mode: RestartMode, ) -> anyhow::Result<()> { // Resolve exe path once; reuse throughout the function let current_exe = std::env::current_exe()?.canonicalize()?; let exe_dir = current_exe .parent() .ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?; if require_root && !super::service::is_root() { anyhow::bail!("automatic upgrade requires root privileges"); } if let Err(error) = validate_upgrade_storage(¤t_exe) { if !super::service::is_root() { anyhow::bail!( "no safe write access to {}: {}. Use: sudo aether-tunnel upgrade", exe_dir.display(), error ); } return Err(error); } let temp_path = exe_dir.join(format!( ".aether-tunnel.upgrade-{}-{}.tmp", std::process::id(), uuid::Uuid::new_v4() )); if !require_root && !super::service::is_root() { // Check write permission to binary directory for manual upgrade mode. if probe_upgrade_directory_write(exe_dir).is_err() { anyhow::bail!( "no safe write access to {}. Use: sudo aether-tunnel upgrade", exe_dir.display() ); } } let platform = detect_platform(); eprintln!(" Platform: {}", platform); eprintln!(" Current version: {}", CURRENT_VERSION); let api_client = build_github_api_client()?; let release = fetch_release(&api_client, version).await?; let target_tag = &release.tag_name; let target_semver = tunnel_release_semver(target_tag)?; let current_semver = semver::Version::parse(CURRENT_VERSION) .map_err(|_| anyhow::anyhow!("current tunnel version is not valid semantic versioning"))?; eprintln!( " Target version: {} ({})", target_tag, release.name.as_deref().unwrap_or("unnamed release") ); if target_semver == current_semver { eprintln!( " Already running version {}, nothing to do.", CURRENT_VERSION ); return Ok(()); } if target_semver < current_semver { anyhow::bail!( "refusing to downgrade aether-tunnel from {} to {}", current_semver, target_semver ); } eprintln!(); eprintln!(" Upgrading: {} -> {}", CURRENT_VERSION, target_semver); eprintln!(); let download_client = build_github_download_client()?; if let Err(error) = download_and_verify(&download_client, target_tag, platform, &temp_path).await { remove_upgrade_file_if_regular(&temp_path); return Err(error); } let backup_path = match atomic_replace(&temp_path) { Ok(backup) => backup, Err(e) => { remove_upgrade_file_if_regular(&temp_path); return Err(e); } }; match restart_mode { RestartMode::BestEffort => { // Use best-effort: binary is already replaced, so a restart failure should // not abort the whole upgrade -- the user can restart manually. if super::service::is_service_active() { if super::service::is_root() { eprintln!(" Restarting managed service..."); match super::service::restart_active_service() { Ok(()) => eprintln!(" Service restarted."), Err(e) => { eprintln!(" WARNING: failed to restart service: {}", e); eprintln!(" Run manually: sudo aether-tunnel restart"); } } } else { eprintln!(" Managed service is active, but restart requires root."); eprintln!(" Run: sudo aether-tunnel restart"); eprintln!(" Skipping restart."); } } else { eprintln!(" No active service detected, skipping restart."); } } RestartMode::Required => { eprintln!(" Restarting managed service..."); match super::service::restart_active_service() { Ok(()) => eprintln!(" Service restarted."), Err(restart_error) => { eprintln!( " ERROR: upgraded service did not restart; restoring the previous binary..." ); if let Err(rollback_error) = restore_tunnel_backup(&backup_path) { anyhow::bail!( "upgraded service restart failed ({restart_error}); binary rollback also failed ({rollback_error})" ); } match super::service::restart_active_service() { Ok(()) => { anyhow::bail!( "upgraded service restart failed and the previous binary was restored successfully: {restart_error}" ); } Err(recovery_restart_error) => { anyhow::bail!( "upgraded service restart failed ({restart_error}); previous binary was restored but its restart also failed ({recovery_restart_error})" ); } } } } } } eprintln!(); eprintln!(" Upgrade complete!"); eprintln!( " Backup kept at: {} (will be cleaned up on next upgrade)", backup_path.display() ); Ok(()) } /// `aether-tunnel upgrade [version]` -- self-upgrade from GitHub releases. pub async fn cmd_upgrade(version: Option) -> anyhow::Result<()> { execute_upgrade(version.as_deref(), false, RestartMode::BestEffort).await } /// Perform automatic upgrade to a specific version. /// /// This path is used for server-pushed upgrades: it requires root and expects /// the currently active managed service to restart successfully. pub async fn perform_upgrade(version: &str) -> anyhow::Result<()> { execute_upgrade(Some(version), true, RestartMode::Required).await } #[cfg(test)] mod tests { use super::{ append_bounded_download_chunk, atomic_replace_paths, extract_binary, is_trusted_github_download_url, normalize_requested_release_tag, parse_checksum, probe_upgrade_directory_write, restore_tunnel_backup_paths, summarize_remote_error_body, tunnel_release_semver, validate_github_resolved_addrs, validate_upgrade_storage, }; use flate2::{write::GzEncoder, Compression}; use std::net::SocketAddr; use std::path::Path; const HASH_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; #[test] fn remote_error_body_summary_redacts_payload() { let body = b"token=do-not-log&message=upstream-secret"; let summary = summarize_remote_error_body(body); assert!(summary.starts_with("response body redacted (bytes=")); assert!(summary.contains("sha256=")); assert!(!summary.contains("do-not-log")); assert!(!summary.contains("upstream-secret")); } #[test] fn release_redirects_require_trusted_https_hosts() { for trusted in [ "https://github.com/fawney19/Aether/releases/download/tag/archive.tar.gz", "https://objects.githubusercontent.com/github-production-release-asset/archive", "https://release-assets.githubusercontent.com/github-production-release-asset/archive", ] { assert!(is_trusted_github_download_url( &url::Url::parse(trusted).unwrap() )); } for untrusted in [ "http://github.com/fawney19/Aether/archive.tar.gz", "https://github.com:8443/fawney19/Aether/archive.tar.gz", "https://github.com.evil.example/archive.tar.gz", "https://user@github.com/archive.tar.gz", "https://api.github.com/repos/fawney19/Aether/releases", "https://example.com/archive.tar.gz", ] { assert!(!is_trusted_github_download_url( &url::Url::parse(untrusted).unwrap() )); } } #[test] fn github_dns_rejects_private_or_mixed_answers() { let public = "8.8.8.8:443".parse::().unwrap(); let private = "127.0.0.1:443".parse::().unwrap(); assert!(validate_github_resolved_addrs(&[public]).is_ok()); assert!(validate_github_resolved_addrs(&[private]).is_err()); assert!(validate_github_resolved_addrs(&[public, private]).is_err()); assert!(validate_github_resolved_addrs(&[]).is_err()); } #[test] fn release_download_bytes_are_bounded_without_trusting_content_length() { let mut bytes = Vec::new(); append_bounded_download_chunk(&mut bytes, b"1234", 8, "archive") .expect("first chunk should fit"); append_bounded_download_chunk(&mut bytes, b"5678", 8, "archive") .expect("exact limit should fit"); assert_eq!(bytes, b"12345678"); assert!(append_bounded_download_chunk(&mut bytes, b"9", 8, "archive").is_err()); assert_eq!(bytes, b"12345678"); } fn archive(entries: &[(&str, tar::EntryType, &[u8])]) -> Vec { let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut builder = tar::Builder::new(encoder); for (path, entry_type, body) in entries { let mut header = tar::Header::new_gnu(); header.set_entry_type(*entry_type); header.set_mode(0o755); header.set_size(body.len() as u64); header.set_cksum(); builder .append_data(&mut header, path, *body) .expect("test archive entry should append"); } builder .into_inner() .expect("test archive should finish") .finish() .expect("test gzip should finish") } #[test] fn checksum_requires_one_exact_valid_filename() { let filename = "aether-tunnel-linux-amd64.tar.gz"; assert_eq!( parse_checksum(&format!("{HASH_A} {filename}\n"), filename) .expect("exact checksum should parse"), HASH_A ); assert!(parse_checksum(&format!("{HASH_A} nested/{filename}\n"), filename).is_err()); assert!(parse_checksum( &format!("{HASH_A} {filename}\n{HASH_B} *{filename}\n"), filename ) .is_err()); assert!(parse_checksum(&format!("not-a-hash {filename}\n"), filename).is_err()); } #[test] fn release_tags_require_bounded_semver_without_url_metacharacters() { assert_eq!( normalize_requested_release_tag("0.3.17").unwrap(), "tunnel-v0.3.17" ); assert_eq!( normalize_requested_release_tag("proxy-v0.3.17-rc.1").unwrap(), "proxy-v0.3.17-rc.1" ); assert!(tunnel_release_semver("tunnel-v1.2.3+build.7").is_ok()); for invalid in [ "", "latest", "tunnel-v../other", "tunnel-v1.2.3?x=1", "tunnel-v1.2", "other-v1.2.3", ] { assert!(normalize_requested_release_tag(invalid).is_err()); } } #[test] fn extraction_rejects_ambiguous_or_non_root_archives() { let root = std::env::temp_dir().join(format!( "aether-tunnel-upgrade-test-{}", uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).expect("test directory should be created"); let destination = root.join("candidate"); let binary_name = if cfg!(target_os = "windows") { "aether-tunnel.exe" } else { "aether-tunnel" }; let nested_name = format!("nested/{binary_name}"); assert!(extract_binary( &archive(&[(&nested_name, tar::EntryType::Regular, b"binary")]), &destination ) .is_err()); assert!(extract_binary( &archive(&[ (binary_name, tar::EntryType::Regular, b"binary"), ("extra", tar::EntryType::Regular, b"extra"), ]), &destination ) .is_err()); assert!(!destination.exists()); std::fs::remove_dir_all(root).expect("test directory should be removed"); } #[test] fn extraction_refuses_to_follow_existing_staging_symlink() { #[cfg(unix)] { use std::os::unix::fs::symlink; let root = std::env::temp_dir().join(format!( "aether-tunnel-upgrade-symlink-test-{}", uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).expect("test directory should be created"); let victim = root.join("victim"); let destination = root.join("candidate"); std::fs::write(&victim, b"keep").expect("victim should be written"); symlink(&victim, &destination).expect("test symlink should be created"); let binary_name = if cfg!(target_os = "windows") { "aether-tunnel.exe" } else { "aether-tunnel" }; assert!(extract_binary( &archive(&[(binary_name, tar::EntryType::Regular, b"replace")]), Path::new(&destination) ) .is_err()); assert_eq!( std::fs::read(&victim).expect("victim should remain readable"), b"keep" ); std::fs::remove_dir_all(root).expect("test directory should be removed"); } } #[cfg(unix)] #[test] fn extraction_creates_a_private_synced_executable() { use std::os::unix::fs::PermissionsExt; let root = std::env::temp_dir().join(format!( "aether-tunnel-upgrade-mode-test-{}", uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).unwrap(); let destination = root.join("candidate"); extract_binary( &archive(&[("aether-tunnel", tar::EntryType::Regular, b"new-binary")]), &destination, ) .expect("valid archive should extract"); assert_eq!(std::fs::read(&destination).unwrap(), b"new-binary"); assert_eq!( std::fs::metadata(&destination) .unwrap() .permissions() .mode() & 0o777, 0o755 ); std::fs::remove_dir_all(root).unwrap(); } #[cfg(unix)] #[test] fn upgrade_storage_and_write_probe_reject_unsafe_directories_without_fixed_files() { use std::os::unix::fs::PermissionsExt; let root = std::env::temp_dir().join(format!( "aether-tunnel-upgrade-storage-test-{}", uuid::Uuid::new_v4() )); let executable_directory = root.join("bin"); std::fs::create_dir_all(&executable_directory).unwrap(); let current = executable_directory.join("aether-tunnel"); std::fs::write(¤t, b"old").unwrap(); std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)).unwrap(); validate_upgrade_storage(¤t).expect("private owned storage should pass"); probe_upgrade_directory_write(&executable_directory) .expect("unique write probe should pass"); assert!(std::fs::read_dir(&executable_directory) .unwrap() .all(|entry| { !entry .unwrap() .file_name() .to_string_lossy() .starts_with(".aether-tunnel.write-test-") })); std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); assert!(validate_upgrade_storage(¤t).is_err()); std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::set_permissions( &executable_directory, std::fs::Permissions::from_mode(0o777), ) .unwrap(); assert!(validate_upgrade_storage(¤t).is_err()); std::fs::set_permissions( &executable_directory, std::fs::Permissions::from_mode(0o755), ) .unwrap(); std::fs::remove_dir_all(root).unwrap(); } #[cfg(unix)] #[test] fn atomic_upgrade_replaces_in_one_step_and_preserves_safe_rollback() { use std::os::unix::fs::{symlink, PermissionsExt}; let root = std::env::temp_dir().join(format!( "aether-tunnel-atomic-upgrade-test-{}", uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).unwrap(); let current = root.join("aether-tunnel"); let staged = root.join("candidate"); std::fs::write(¤t, b"old-binary").unwrap(); std::fs::write(&staged, b"new-binary").unwrap(); std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); let victim = root.join("victim"); std::fs::write(&victim, b"known-good").unwrap(); let fixed_backup = current.with_extension("bak"); symlink(&victim, &fixed_backup).unwrap(); let backup = atomic_replace_paths(¤t, &staged).expect("upgrade should succeed"); assert_eq!(std::fs::read(¤t).unwrap(), b"new-binary"); assert_eq!(std::fs::read(&backup).unwrap(), b"old-binary"); assert_eq!(std::fs::read(&victim).unwrap(), b"known-good"); assert!(!std::fs::symlink_metadata(&backup) .unwrap() .file_type() .is_symlink()); assert!(!staged.exists()); restore_tunnel_backup_paths(¤t, &backup).expect("rollback should succeed"); assert_eq!(std::fs::read(¤t).unwrap(), b"old-binary"); assert!(!backup.exists()); std::fs::remove_dir_all(root).unwrap(); } #[cfg(unix)] #[test] fn atomic_upgrade_rejects_hardlinked_staging_without_touching_current() { use std::os::unix::fs::PermissionsExt; let root = std::env::temp_dir().join(format!( "aether-tunnel-hardlink-upgrade-test-{}", uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).unwrap(); let current = root.join("aether-tunnel"); let staged = root.join("candidate"); let outside = root.join("outside"); std::fs::write(¤t, b"old-binary").unwrap(); std::fs::write(&outside, b"new-binary").unwrap(); std::fs::hard_link(&outside, &staged).unwrap(); std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(atomic_replace_paths(¤t, &staged).is_err()); assert_eq!(std::fs::read(¤t).unwrap(), b"old-binary"); assert_eq!(std::fs::read(&outside).unwrap(), b"new-binary"); std::fs::remove_dir_all(root).unwrap(); } }