[WIP] Daemon & CLI
This commit is contained in:
parent
56aad3a023
commit
8b158108bb
100 changed files with 6519 additions and 1596 deletions
|
|
@ -29,4 +29,5 @@ serde = "1.0.228"
|
|||
tempfile = "3.27.0"
|
||||
anyhow = "1.0.102"
|
||||
semver = "1.0.28"
|
||||
self-replace = "1.5.0"
|
||||
ed25519-dalek = "2.2.0"
|
||||
serde_json = "1.0"
|
||||
|
|
|
|||
|
|
@ -1,163 +1,13 @@
|
|||
/* This file is used for the auto update function for the Iota.
|
||||
* It connects to the git server from methanium and checks if
|
||||
* the version has updated inside the cargo.toml file.
|
||||
* It is made by Yolokit and pasted in by AlexEmmet */
|
||||
pub mod manifest;
|
||||
pub mod transaction;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use iota_logger::log;
|
||||
use self_replace::self_replace;
|
||||
use semver::Version;
|
||||
use std::fs::File;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const API_BASE: &str = "https://git.methanium.net/api/v1";
|
||||
const OWNER: &str = "Tensamin";
|
||||
const REPO: &str = "Iota";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
async fn latest_release() -> Result<Release> {
|
||||
let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest");
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.context("failed to query latest release.")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("release API returned {}", response.status()));
|
||||
}
|
||||
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.context("failed to read response text")?;
|
||||
|
||||
let parsed = json::parse(&text).map_err(|e| anyhow!("failed to parse JSON: {}", e))?;
|
||||
|
||||
let tag_name = parsed["tag_name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing tag_name"))?
|
||||
.to_string();
|
||||
|
||||
let assets_json = parsed["assets"].members().collect::<Vec<_>>();
|
||||
|
||||
let mut assets = Vec::new();
|
||||
|
||||
for asset in assets_json {
|
||||
let name = asset["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing asset name"))?
|
||||
.to_string();
|
||||
|
||||
let browser_download_url = asset["browser_download_url"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing download url"))?
|
||||
.to_string();
|
||||
|
||||
assets.push(Asset {
|
||||
name,
|
||||
browser_download_url,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Release { tag_name, assets })
|
||||
}
|
||||
|
||||
async fn parse_tag_version(tag: &str) -> Result<Version> {
|
||||
let normalized = tag.strip_prefix('v').unwrap_or(tag);
|
||||
Ok(Version::parse(normalized)?)
|
||||
}
|
||||
|
||||
async fn current_version() -> Result<Version> {
|
||||
Ok(Version::parse(CURRENT_VERSION)?)
|
||||
}
|
||||
|
||||
async fn asset_name_for_current_platform() -> String {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
|
||||
match (os, arch) {
|
||||
("linux", "x86_64") => "iota-linux-x86_64".to_string(),
|
||||
("linux", "aarch64") => "iota-linux-aarch64".to_string(),
|
||||
("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(),
|
||||
("macos", "x86_64") => "iota-macos-x86_64".to_string(),
|
||||
("macos", "aarch64") => "iota-macos-aarch64".to_string(),
|
||||
_ => panic!("unsupported platform: {os}/{arch}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_asset(url: &str) -> Result<NamedTempFile> {
|
||||
let response = reqwest::get(url)
|
||||
.await
|
||||
.context("failed to download asset")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("asset download returned {}", response.status()));
|
||||
}
|
||||
|
||||
let tmp = NamedTempFile::new().context("failed to create temp file")?;
|
||||
let _out = File::create(tmp.path()).context("failed to open temp file")?;
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read response bytes")?;
|
||||
|
||||
std::fs::write(tmp.path(), &bytes).context("failed to write file")?;
|
||||
|
||||
Ok(tmp)
|
||||
}
|
||||
|
||||
async fn check_for_update() -> Result<Option<Release>> {
|
||||
let current = current_version().await?;
|
||||
let release = latest_release().await?;
|
||||
let latest = parse_tag_version(&release.tag_name).await?;
|
||||
|
||||
if latest > current {
|
||||
Ok(Some(release))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_update() -> Result<bool> {
|
||||
let Some(release) = check_for_update().await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let wanted_asset = asset_name_for_current_platform().await;
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == wanted_asset)
|
||||
.ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?;
|
||||
|
||||
log!("Downloading update: {}", asset.name);
|
||||
|
||||
let downloaded = download_asset(&asset.browser_download_url).await?;
|
||||
|
||||
self_replace(downloaded.path()).context("failed to replace current executable")?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
use anyhow::Result;
|
||||
|
||||
/// Compatibility entry point used by the UI. Updates are now manifest-driven;
|
||||
/// this function only checks and never replaces the invoking executable.
|
||||
pub async fn check_update() -> Result<bool> {
|
||||
if perform_update().await? {
|
||||
return Ok(true);
|
||||
} else {
|
||||
if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
|
|
|||
21
iota-updater/src/main.rs
Normal file
21
iota-updater/src/main.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
|
||||
match command.as_str() {
|
||||
"check" => println!("update check is manifest-driven"),
|
||||
"status" => println!("updater ready"),
|
||||
"apply" | "rollback" => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"explicit signed transaction input is required"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"usage: iota-updater check|status|apply|rollback"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
83
iota-updater/src/manifest.rs
Normal file
83
iota-updater/src/manifest.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ReleaseManifest {
|
||||
pub product_version: String,
|
||||
pub channel: String,
|
||||
pub published_at: String,
|
||||
pub minimum_data_schema: u64,
|
||||
pub supported_ipc_min: u16,
|
||||
pub supported_ipc_max: u16,
|
||||
pub artifacts: Vec<Artifact>,
|
||||
pub release_signing_key_id: String,
|
||||
pub rollback_compatible: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub role: String,
|
||||
pub os: String,
|
||||
pub architecture: String,
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub fn canonical_bytes(manifest: &ReleaseManifest) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(manifest)?)
|
||||
}
|
||||
pub fn verify_signature(
|
||||
manifest: &ReleaseManifest,
|
||||
signature: &[u8],
|
||||
public_key: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
let key = VerifyingKey::from_bytes(public_key).context("invalid release public key")?;
|
||||
let signature = Signature::from_slice(signature).context("invalid release signature")?;
|
||||
key.verify(&canonical_bytes(manifest)?, &signature)
|
||||
.context("release manifest signature verification failed")
|
||||
}
|
||||
pub fn verify_artifact(path: &std::path::Path, artifact: &Artifact) -> Result<()> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() != artifact.size {
|
||||
bail!("artifact size mismatch for {}", artifact.path);
|
||||
}
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = std::io::Read::read(&mut file, &mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
let actual = hex::encode(hasher.finalize());
|
||||
if actual != artifact.sha256.to_ascii_lowercase() {
|
||||
bail!("artifact hash mismatch for {}", artifact.path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rejects_size_or_hash_mismatch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("iota-daemon");
|
||||
std::fs::write(&path, b"daemon").unwrap();
|
||||
let artifact = Artifact {
|
||||
role: "daemon".into(),
|
||||
os: "linux".into(),
|
||||
architecture: "x86_64".into(),
|
||||
path: "bin/iota-daemon".into(),
|
||||
url: "https://example.invalid".into(),
|
||||
sha256: "00".repeat(32),
|
||||
size: 6,
|
||||
};
|
||||
assert!(verify_artifact(&path, &artifact).is_err());
|
||||
}
|
||||
}
|
||||
79
iota-updater/src/transaction.rs
Normal file
79
iota-updater/src/transaction.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use crate::manifest::{Artifact, verify_artifact};
|
||||
use anyhow::{Context, Result};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpdateTransaction {
|
||||
pub root: PathBuf,
|
||||
pub staging: PathBuf,
|
||||
}
|
||||
impl UpdateTransaction {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let root = root.into();
|
||||
Self {
|
||||
staging: root.join(".staging"),
|
||||
root,
|
||||
}
|
||||
}
|
||||
pub fn acquire(&self) -> Result<fs::File> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
let path = self.root.join("update.lock");
|
||||
let file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.context("update already in progress")?;
|
||||
Ok(file)
|
||||
}
|
||||
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> {
|
||||
fs::create_dir_all(&self.staging)?;
|
||||
let target = self.staging.join(&artifact.path);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(source, &target)?;
|
||||
verify_artifact(&target, artifact)?;
|
||||
Ok(target)
|
||||
}
|
||||
pub fn activate(&self, version: &str) -> Result<()> {
|
||||
let version_dir = self.root.join("versions").join(version);
|
||||
fs::create_dir_all(version_dir.parent().unwrap())?;
|
||||
fs::rename(&self.staging, &version_dir).context("activate staged release")?;
|
||||
let current_tmp = self.root.join("current.new");
|
||||
let _ = fs::remove_file(¤t_tmp);
|
||||
std::os::unix::fs::symlink(&version_dir, ¤t_tmp)?;
|
||||
fs::rename(current_tmp, self.root.join("current"))?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn rollback(&self, previous: &str) -> Result<()> {
|
||||
let current = self.root.join("current");
|
||||
let tmp = self.root.join("current.rollback");
|
||||
let _ = fs::remove_file(&tmp);
|
||||
std::os::unix::fs::symlink(self.root.join("versions").join(previous), &tmp)?;
|
||||
fs::rename(tmp, current)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn lock_is_exclusive_and_activation_switches_current() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tx = UpdateTransaction::new(dir.path());
|
||||
let lock = tx.acquire().unwrap();
|
||||
assert!(tx.acquire().is_err());
|
||||
drop(lock);
|
||||
std::fs::create_dir_all(&tx.staging).unwrap();
|
||||
std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap();
|
||||
tx.activate("1.0.0").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("current/manifest.json")).unwrap(),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue