Files
Aether/apps/aether-tunnel/src/setup/upgrade.rs
T

1289 lines
47 KiB
Rust
Raw Normal View History

//! Self-upgrade support for `aether-tunnel`.
2026-02-08 14:54:10 +08:00
//!
//! Downloads a release from GitHub, verifies the SHA256 checksum, replaces the
//! running binary atomically, and restarts the active managed service when
//! applicable.
2026-02-08 14:54:10 +08:00
use std::io::{Read, Write};
use std::net::SocketAddr;
2026-02-08 14:54:10 +08:00
use std::path::{Path, PathBuf};
use std::sync::Arc;
2026-02-08 14:54:10 +08:00
use aether_http::{apply_http_client_config, read_response_bytes_with_limit, HttpClientConfig};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
2026-02-08 14:54:10 +08:00
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)
)
}
2026-02-08 14:54:10 +08:00
// ── GitHub API types ─────────────────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
}
fn tunnel_release_semver(tag: &str) -> anyhow::Result<semver::Version> {
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<String> {
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)
2026-02-08 14:54:10 +08:00
}
// ── 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") {
2026-02-08 14:54:10 +08:00
"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<reqwest::Client> {
2026-02-08 14:54:10 +08:00
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))?,
);
}
2026-02-08 14:54:10 +08:00
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()?)
2026-02-08 14:54:10 +08:00
}
fn build_github_download_client() -> anyhow::Result<reqwest::Client> {
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<dyn std::error::Error + Send + Sync> { Box::new(error) })?;
validate_github_resolved_addrs(&addresses).map_err(|message| {
Box::new(std::io::Error::other(message)) as Box<dyn std::error::Error + Send + Sync>
})?;
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")
}
2026-02-08 14:54:10 +08:00
// ── Release fetching ─────────────────────────────────────────────────────────
async fn fetch_release(
client: &reqwest::Client,
version: Option<&str>,
) -> anyhow::Result<GithubRelease> {
match version {
Some(ver) => {
// Accept both "tunnel-v0.2.0" and the legacy "proxy-v0.2.0".
let tag = normalize_requested_release_tag(ver)?;
2026-02-08 14:54:10 +08:00
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)
);
2026-02-08 14:54:10 +08:00
}
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)
2026-02-08 14:54:10 +08:00
}
None => {
// List releases and find the latest tunnel-v* tag
2026-02-08 14:54:10 +08:00
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)
);
2026-02-08 14:54:10 +08:00
}
let releases: Vec<GithubRelease> = serde_json::from_slice(&body)?;
2026-02-08 14:54:10 +08:00
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"))
2026-02-08 14:54:10 +08:00
}
}
}
// ── Download via GitHub release direct links ─────────────────────────────────
2026-02-08 14:54:10 +08:00
/// Download a release asset via the public direct download URL:
/// `https://github.com/{repo}/releases/download/{tag}/{filename}`
async fn download_release_file(
2026-02-08 14:54:10 +08:00
client: &reqwest::Client,
tag: &str,
filename: &str,
max_bytes: usize,
2026-02-08 14:54:10 +08:00
) -> anyhow::Result<Vec<u8>> {
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");
}
2026-02-08 14:54:10 +08:00
let url = format!(
"https://github.com/{}/releases/download/{}/{}",
GITHUB_REPO, tag, filename
2026-02-08 14:54:10 +08:00
);
let mut resp = client
2026-02-08 14:54:10 +08:00
.get(&url)
.header(reqwest::header::ACCEPT, "application/octet-stream")
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!(
"download failed for '{}' (HTTP {})",
filename,
2026-02-08 14:54:10 +08:00
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<u8>,
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(())
2026-02-08 14:54:10 +08:00
}
fn parse_checksum(sums_text: &str, filename: &str) -> anyhow::Result<String> {
let mut matches = Vec::new();
2026-02-08 14:54:10 +08:00
for line in sums_text.lines() {
// Format: "<hash> <filename>" (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());
2026-02-08 14:54:10 +08:00
}
}
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
),
}
2026-02-08 14:54:10 +08:00
}
async fn download_and_verify(
client: &reqwest::Client,
tag: &str,
2026-02-08 14:54:10 +08:00
platform: &str,
dest: &Path,
) -> anyhow::Result<()> {
let archive_name = format!("aether-tunnel-{}.tar.gz", platform);
2026-02-08 14:54:10 +08:00
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,),
2026-02-08 14:54:10 +08:00
)?;
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"
2026-02-08 14:54:10 +08:00
} else {
"aether-tunnel"
2026-02-08 14:54:10 +08:00
};
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))?;
2026-02-08 14:54:10 +08:00
}
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)
{
2026-02-08 14:54:10 +08:00
anyhow::bail!(
"tunnel executable ancestor '{}' has unsafe ownership or permissions",
directory.display()
2026-02-08 14:54:10 +08:00
);
}
ancestor = directory.parent();
2026-02-08 14:54:10 +08:00
}
}
#[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(())
2026-02-08 14:54:10 +08:00
}
// ── Atomic binary replacement ────────────────────────────────────────────────
fn atomic_replace(new_binary: &Path) -> anyhow::Result<PathBuf> {
let current_exe = std::env::current_exe()?.canonicalize()?;
atomic_replace_paths(&current_exe, new_binary)
}
2026-02-08 14:54:10 +08:00
fn atomic_replace_paths(current_exe: &Path, new_binary: &Path) -> anyhow::Result<PathBuf> {
let current_exe = std::fs::canonicalize(current_exe)?;
validate_upgrade_storage(&current_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");
2026-02-08 14:54:10 +08:00
}
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(&current_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, &current_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, &current_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(&current_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(&current_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, &current_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")
}
2026-02-08 14:54:10 +08:00
}
// ── 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<()> {
2026-02-08 14:54:10 +08:00
// 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(&current_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()
);
2026-02-08 14:54:10 +08:00
}
}
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?;
2026-02-08 14:54:10 +08:00
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"))?;
2026-02-08 14:54:10 +08:00
eprintln!(
" Target version: {} ({})",
target_tag,
release.name.as_deref().unwrap_or("unnamed release")
);
2026-02-08 14:54:10 +08:00
if target_semver == current_semver {
2026-02-08 14:54:10 +08:00
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
);
}
2026-02-08 14:54:10 +08:00
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);
2026-02-08 14:54:10 +08:00
}
let backup_path = match atomic_replace(&temp_path) {
Ok(backup) => backup,
Err(e) => {
remove_upgrade_file_if_regular(&temp_path);
2026-02-08 14:54:10 +08:00
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})"
);
}
}
}
}
}
2026-02-08 14:54:10 +08:00
}
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<String>) -> 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://[email protected]/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::<SocketAddr>().unwrap();
let private = "127.0.0.1:443".parse::<SocketAddr>().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<u8> {
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(&current, b"old").unwrap();
std::fs::set_permissions(&current, std::fs::Permissions::from_mode(0o755)).unwrap();
validate_upgrade_storage(&current).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(&current).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(&current).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(&current, b"old-binary").unwrap();
std::fs::write(&staged, b"new-binary").unwrap();
std::fs::set_permissions(&current, 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(&current, &staged).expect("upgrade should succeed");
assert_eq!(std::fs::read(&current).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(&current, &backup).expect("rollback should succeed");
assert_eq!(std::fs::read(&current).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(&current, b"old-binary").unwrap();
std::fs::write(&outside, b"new-binary").unwrap();
std::fs::hard_link(&outside, &staged).unwrap();
std::fs::set_permissions(&current, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(atomic_replace_paths(&current, &staged).is_err());
assert_eq!(std::fs::read(&current).unwrap(), b"old-binary");
assert_eq!(std::fs::read(&outside).unwrap(), b"new-binary");
std::fs::remove_dir_all(root).unwrap();
}
}