Add tools to generate and remote upgrade citadel
This commit is contained in:
parent
904707a7c3
commit
44d408f84a
1792
Cargo.lock
generated
1792
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -19,3 +19,8 @@ byteorder = "1"
|
||||
dbus = "0.8.4"
|
||||
pwhash = "1.0"
|
||||
tempfile = "3"
|
||||
ed25519-dalek = {version = "2.1", features = ["pem"]}
|
||||
anyhow = "1.0"
|
||||
reqwest = {version = "0.12", features = ["blocking"]}
|
||||
glob = "0.3"
|
||||
serde_cbor = "0.11"
|
||||
|
@ -8,7 +8,7 @@ use std::path::Path;
|
||||
|
||||
mod live;
|
||||
mod disks;
|
||||
mod rootfs;
|
||||
pub mod rootfs;
|
||||
|
||||
pub fn main(args: Vec<String>) {
|
||||
if CommandLine::debug() {
|
||||
|
@ -94,7 +94,7 @@ fn choose_revert_partition(best: Option<Partition>) -> Option<Partition> {
|
||||
best
|
||||
}
|
||||
|
||||
fn choose_boot_partiton(scan: bool, revert_rootfs: bool) -> Result<Partition> {
|
||||
pub fn choose_boot_partiton(scan: bool, revert_rootfs: bool) -> Result<Partition> {
|
||||
let mut partitions = Partition::rootfs_partitions()?;
|
||||
|
||||
if scan {
|
||||
|
293
citadel-tool/src/fetch/fetch.rs
Normal file
293
citadel-tool/src/fetch/fetch.rs
Normal file
@ -0,0 +1,293 @@
|
||||
use crate::{update, Path};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::ArgMatches;
|
||||
use ed25519_dalek::{pkcs8::DecodePublicKey, VerifyingKey};
|
||||
use libcitadel::updates::UPDATE_SERVER_HOSTNAME;
|
||||
use libcitadel::{updates, updates::CitadelVersionStruct};
|
||||
use libcitadel::{OsRelease, ResourceImage};
|
||||
|
||||
use std::io::prelude::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
const UPDATE_SERVER_KEY_PATH: &str = "/etc/citadel/update_server_key.pub";
|
||||
|
||||
pub fn check() -> Result<()> {
|
||||
let current_version = get_current_os_config()?;
|
||||
|
||||
let server_citadel_version = fetch_and_verify_version_cbor(¤t_version)?;
|
||||
|
||||
let components_to_upgrade =
|
||||
compare_citadel_versions(¤t_version, &server_citadel_version)?;
|
||||
|
||||
if components_to_upgrade.len() == 1 {
|
||||
println!(
|
||||
"We found the following component to upgrade: {}",
|
||||
components_to_upgrade[0]
|
||||
);
|
||||
} else if components_to_upgrade.len() > 1 {
|
||||
println!("We found the following components to upgrade:");
|
||||
|
||||
for component in components_to_upgrade {
|
||||
println!("{}", component);
|
||||
}
|
||||
} else {
|
||||
println!("Your system is up to date!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download(sub_matches: &ArgMatches) -> Result<()> {
|
||||
let current_version = &get_current_os_config()?;
|
||||
let server_citadel_version = &fetch_and_verify_version_cbor(¤t_version)?;
|
||||
|
||||
let mut path = "";
|
||||
if sub_matches.get_flag("rootfs") {
|
||||
path = &server_citadel_version.component_version[0].file_path;
|
||||
} else if sub_matches.get_flag("kernel") {
|
||||
path = &server_citadel_version.component_version[1].file_path;
|
||||
} else if sub_matches.get_flag("extra") {
|
||||
path = &server_citadel_version.component_version[2].file_path;
|
||||
}
|
||||
|
||||
download_file(path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_remote() -> Result<()> {
|
||||
let server_citadel_version = fetch_and_verify_version_cbor(&get_current_os_config()?)?;
|
||||
|
||||
println!("Server offers:\n{server_citadel_version}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upgrade() -> Result<()> {
|
||||
// First get access to the current citadel's parameters
|
||||
let current_version = &get_current_os_config()?;
|
||||
let server_citadel_version = &fetch_and_verify_version_cbor(¤t_version)?;
|
||||
|
||||
// What do we need to upgrade?
|
||||
let components_to_upgrade =
|
||||
compare_citadel_versions(¤t_version, &server_citadel_version)?;
|
||||
|
||||
if components_to_upgrade.len() == 1 {
|
||||
println!("We found a component to upgrade!");
|
||||
let allow_download = prompt_user_for_permission_to_download(&components_to_upgrade[0])?;
|
||||
|
||||
if allow_download {
|
||||
let save_path = download_file(&components_to_upgrade[0].file_path)?;
|
||||
|
||||
// run citadel-update to upgrade
|
||||
println!("Installing image");
|
||||
update::install_image(&save_path, 0)?;
|
||||
println!("Image installed correctly");
|
||||
} else {
|
||||
println!("Ok! Maybe later");
|
||||
}
|
||||
} else if components_to_upgrade.len() > 1 {
|
||||
println!("We found some components to upgrade!");
|
||||
for component in components_to_upgrade {
|
||||
let allow_download = prompt_user_for_permission_to_download(&component)?;
|
||||
|
||||
if allow_download {
|
||||
let save_path = download_file(&component.file_path)?;
|
||||
|
||||
println!("Installing image");
|
||||
update::install_image(&save_path, 0)?;
|
||||
println!("Image installed correctly");
|
||||
} else {
|
||||
println!("Ok! Maybe later");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Your system is up to date!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reinstall(sub_matches: &ArgMatches) -> Result<()> {
|
||||
let current_version = &get_current_os_config()?;
|
||||
let server_citadel_version = &fetch_and_verify_version_cbor(¤t_version)?;
|
||||
|
||||
let mut path = "";
|
||||
if sub_matches.get_flag("rootfs") {
|
||||
path = &server_citadel_version.component_version[0].file_path;
|
||||
} else if sub_matches.get_flag("kernel") {
|
||||
path = &server_citadel_version.component_version[1].file_path;
|
||||
} else if sub_matches.get_flag("extra") {
|
||||
path = &server_citadel_version.component_version[2].file_path;
|
||||
}
|
||||
|
||||
let save_path = download_file(path)?;
|
||||
update::install_image(&save_path, 0)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a vec of ComponentVersion structs of the components which can be upgraded
|
||||
fn compare_citadel_versions(
|
||||
current: &CitadelVersionStruct,
|
||||
offered: &CitadelVersionStruct,
|
||||
) -> Result<Vec<updates::AvailableComponentVersion>> {
|
||||
let mut update_vec: Vec<updates::AvailableComponentVersion> = Vec::new();
|
||||
|
||||
// safety checks
|
||||
if current.channel != offered.channel {
|
||||
panic!("Error: channels do not match");
|
||||
} else if current.client != offered.client {
|
||||
panic!("Error: clients do not match");
|
||||
} else if current.publisher != offered.publisher {
|
||||
panic!("Error: publishers do not match");
|
||||
}
|
||||
|
||||
for i in 0..current.component_version.len() {
|
||||
if current.component_version[i] < offered.component_version[i] {
|
||||
update_vec.push(offered.component_version[i].clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(update_vec)
|
||||
}
|
||||
|
||||
// We need to get the version of the rootfs, kernel and extra images currently installed
|
||||
fn get_current_os_config() -> Result<updates::CitadelVersionStruct> {
|
||||
let client = OsRelease::citadel_client().context("Failed to find client of current system")?;
|
||||
let channel = OsRelease::citadel_channel().context("Failed to find channel of current system")?;
|
||||
let publisher = OsRelease::citadel_publisher().context("Failed to find publisher of current system")?;
|
||||
|
||||
let metainfo;
|
||||
// choose best partion to boot from as the partition to compare versions with
|
||||
let rootfs_version = match crate::boot::rootfs::choose_boot_partiton(false, false) {
|
||||
Ok(part) => {metainfo = part.header().metainfo();
|
||||
metainfo.version() }
|
||||
Err(e) => bail!("Rootfs version not found. Error: {e}"),
|
||||
};
|
||||
|
||||
// Get highest values of image versions
|
||||
let kernel_resource = ResourceImage::find("kernel")?.metainfo();
|
||||
let kernel_version = kernel_resource.version();
|
||||
|
||||
let extra_resource = ResourceImage::find("extra")?.metainfo();
|
||||
let extra_version = extra_resource.version();
|
||||
|
||||
let mut component_version = Vec::new();
|
||||
component_version.push(updates::AvailableComponentVersion {
|
||||
component: updates::Component::Rootfs,
|
||||
version: rootfs_version.to_owned(),
|
||||
file_path: "".to_owned(),
|
||||
});
|
||||
component_version.push(updates::AvailableComponentVersion {
|
||||
component: updates::Component::Kernel,
|
||||
version: kernel_version.to_owned(),
|
||||
file_path: "".to_owned(),
|
||||
});
|
||||
component_version.push(updates::AvailableComponentVersion {
|
||||
component: updates::Component::Extra,
|
||||
version: extra_version.to_owned(),
|
||||
file_path: "".to_owned(),
|
||||
});
|
||||
|
||||
let current_version_struct = updates::CitadelVersionStruct {
|
||||
client: client.to_owned(),
|
||||
channel: channel.to_owned(),
|
||||
component_version,
|
||||
publisher: publisher.to_owned(),
|
||||
};
|
||||
|
||||
Ok(current_version_struct)
|
||||
}
|
||||
|
||||
fn fetch_and_verify_version_cbor(
|
||||
current_citadel_version: &updates::CitadelVersionStruct,
|
||||
) -> Result<updates::CitadelVersionStruct> {
|
||||
let url = format!(
|
||||
"https://{}/{}/{}/version.cbor",
|
||||
UPDATE_SERVER_HOSTNAME, current_citadel_version.client, current_citadel_version.channel
|
||||
);
|
||||
|
||||
let version_file_bytes = reqwest::blocking::get(&url)?
|
||||
.bytes()
|
||||
.context(format!("Failed to get version_file_bytes from {url}"))?;
|
||||
|
||||
let crypto_container: updates::CryptoContainerFile =
|
||||
serde_cbor::from_slice(&version_file_bytes)
|
||||
.context(format!("Failed to parse version.cbor from {}", url))?;
|
||||
|
||||
// find update server public key kept in the rootfs
|
||||
let mut file = std::fs::File::open(UPDATE_SERVER_KEY_PATH).context(format!(
|
||||
"Failed to open update_server_key file from {}",
|
||||
UPDATE_SERVER_KEY_PATH
|
||||
))?;
|
||||
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
|
||||
let public_key = VerifyingKey::from_public_key_pem(&contents)
|
||||
.context("Failed to parse public key from file.")?;
|
||||
|
||||
let signature = ed25519_dalek::Signature::from_str(&crypto_container.signature)?;
|
||||
|
||||
// verify signature
|
||||
public_key.verify_strict(&crypto_container.serialized_citadel_version, &signature)
|
||||
.context("We failed to verify the signature update release file. Please make sure the key at /etc/citade/update_server_key.pub matches the one publicly linked to your update provider.")?;
|
||||
|
||||
// construct the struct
|
||||
let citadel_version_struct: updates::CitadelVersionStruct =
|
||||
serde_cbor::from_slice(&crypto_container.serialized_citadel_version)?;
|
||||
|
||||
Ok(citadel_version_struct)
|
||||
}
|
||||
|
||||
fn prompt_user_for_permission_to_download(
|
||||
component: &updates::AvailableComponentVersion,
|
||||
) -> Result<bool> {
|
||||
println!(
|
||||
"Would you like to download and install the new version of the {} image with version {}? (y/n)",
|
||||
component.component, component.version
|
||||
);
|
||||
|
||||
loop {
|
||||
let stdin = std::io::stdin();
|
||||
let mut user_input = String::new();
|
||||
|
||||
stdin.read_line(&mut user_input)?;
|
||||
|
||||
if user_input.trim() == "y" {
|
||||
return Ok(true);
|
||||
} else {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn download_file(path: &str) -> Result<std::path::PathBuf> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
let url = format!("https://{UPDATE_SERVER_HOSTNAME}/{path}");
|
||||
println!("Downloading from {url}");
|
||||
|
||||
let component_download_response = client.get(&url).send()?;
|
||||
|
||||
if !component_download_response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"Failed to download image from {}. Server returned error {}",
|
||||
path,
|
||||
component_download_response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let path = Path::new(path);
|
||||
let path = format!("/tmp/{}", path.file_name().unwrap().to_str().unwrap());
|
||||
|
||||
let mut content = std::io::Cursor::new(component_download_response.bytes()?);
|
||||
let mut file =
|
||||
std::fs::File::create(&path).context(format!("Failed to create file at {path}"))?;
|
||||
std::io::copy(&mut content, &mut file)?;
|
||||
|
||||
println!("Saved file to {path}");
|
||||
|
||||
Ok(std::path::PathBuf::from(path))
|
||||
}
|
55
citadel-tool/src/fetch/mod.rs
Normal file
55
citadel-tool/src/fetch/mod.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use clap::{arg, command, ArgAction, Command};
|
||||
use std::process::exit;
|
||||
use libcitadel::util;
|
||||
|
||||
mod fetch;
|
||||
|
||||
pub fn main() {
|
||||
if !util::is_euid_root() {
|
||||
println!("Please run this program as root");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let matches = command!()
|
||||
.subcommand_required(true)
|
||||
.subcommand(Command::new("check").about("Check for updates from remote server"))
|
||||
.subcommand(
|
||||
Command::new("download")
|
||||
.about("Download a specific file from the server")
|
||||
.arg(arg!(-r --rootfs "rootfs component").action(ArgAction::SetTrue))
|
||||
.arg(arg!(-k --kernel "kernel component").action(ArgAction::SetTrue))
|
||||
.arg(arg!(-e --extra "extra component").action(ArgAction::SetTrue))
|
||||
.arg_required_else_help(true),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("read-remote")
|
||||
.about("Read the remote server and print information on versions offered"),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("upgrade")
|
||||
.about("Download and install all components found on the server to be more recent than currently installed on system")
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("reinstall")
|
||||
.about("Download and install a specific component even if the server's component version is not greater than currently installed")
|
||||
.arg(arg!(-r --rootfs "rootfs component").action(ArgAction::SetTrue))
|
||||
.arg(arg!(-k --kernel "kernel component").action(ArgAction::SetTrue))
|
||||
.arg(arg!(-e --extra "extra component").action(ArgAction::SetTrue))
|
||||
.arg_required_else_help(true),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
let result = match matches.subcommand() {
|
||||
Some(("check", _sub_matches)) => fetch::check(),
|
||||
Some(("download", sub_matches)) => fetch::download(sub_matches),
|
||||
Some(("read-remote", _sub_matches)) => fetch::read_remote(),
|
||||
Some(("upgrade", _sub_matches)) => fetch::upgrade(),
|
||||
Some(("reinstall", sub_matches)) => fetch::reinstall(sub_matches),
|
||||
_ => unreachable!("Please pass a subcommand"),
|
||||
};
|
||||
|
||||
if let Err(ref e) = result {
|
||||
println!("Error: {}", e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
@ -16,6 +16,7 @@ mod mkimage;
|
||||
mod realmfs;
|
||||
mod sync;
|
||||
mod update;
|
||||
mod fetch;
|
||||
|
||||
fn main() {
|
||||
let exe = match env::current_exe() {
|
||||
@ -39,6 +40,8 @@ fn main() {
|
||||
realmfs::main();
|
||||
} else if exe == Path::new("/usr/bin/citadel-update") {
|
||||
update::main(args);
|
||||
} else if exe == Path::new("/usr/bin/citadel-fetch") {
|
||||
fetch::main();
|
||||
} else if exe == Path::new("/usr/libexec/citadel-desktop-sync") {
|
||||
sync::main(args);
|
||||
} else if exe == Path::new("/usr/libexec/citadel-run") {
|
||||
@ -60,6 +63,7 @@ fn dispatch_command(args: Vec<String>) {
|
||||
"image" => image::main(),
|
||||
"realmfs" => realmfs::main(),
|
||||
"update" => update::main(rebuild_args("citadel-update", args)),
|
||||
"fetch" => update::main(rebuild_args("citadel-fetch", args)),
|
||||
"mkimage" => mkimage::main(rebuild_args("citadel-mkimage", args)),
|
||||
"sync" => sync::main(rebuild_args("citadel-desktop-sync", args)),
|
||||
"run" => do_citadel_run(rebuild_args("citadel-run", args)),
|
||||
|
@ -21,6 +21,7 @@ dbus = "0.6"
|
||||
posix-acl = "1.0.0"
|
||||
procfs = "0.12.0"
|
||||
semver = "1.0"
|
||||
clap = "4.5"
|
||||
|
||||
[dependencies.inotify]
|
||||
version = "0.8"
|
||||
|
@ -84,8 +84,8 @@ impl OsRelease {
|
||||
OsRelease::get_value("CITADEL_IMAGE_PUBKEY")
|
||||
}
|
||||
|
||||
pub fn citadel_rootfs_version() -> Option<usize> {
|
||||
OsRelease::get_int_value("CITADEL_ROOTFS_VERSION")
|
||||
pub fn citadel_rootfs_version() -> Option<&'static str> {
|
||||
OsRelease::get_value("CITADEL_ROOTFS_VERSION")
|
||||
}
|
||||
|
||||
pub fn citadel_kernel_version() -> Option<&'static str> {
|
||||
@ -96,6 +96,14 @@ impl OsRelease {
|
||||
OsRelease::get_value("CITADEL_KERNEL_ID")
|
||||
}
|
||||
|
||||
pub fn citadel_client() -> Option<&'static str> {
|
||||
OsRelease::get_value("CITADEL_CLIENT")
|
||||
}
|
||||
|
||||
pub fn citadel_publisher() -> Option<&'static str> {
|
||||
OsRelease::get_value("CITADEL_PUBLISHER")
|
||||
}
|
||||
|
||||
fn _get_value(&self, key: &str) -> Option<&str> {
|
||||
self.vars.get(key).map(|v| v.as_str())
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ pub mod symlink;
|
||||
mod realm;
|
||||
pub mod terminal;
|
||||
mod system;
|
||||
pub mod updates;
|
||||
|
||||
pub mod flatpak;
|
||||
|
||||
|
@ -40,6 +40,11 @@ impl ResourceImage {
|
||||
pub fn find(image_type: &str) -> Result<Self> {
|
||||
let channel = Self::rootfs_channel();
|
||||
|
||||
// search when citadel is installed
|
||||
if let Some(image) = search_directory(format!("/storage/resources/{channel}/"), image_type, Some(&channel))? {
|
||||
return Ok(image);
|
||||
}
|
||||
|
||||
info!("Searching run directory for image {} with channel {}", image_type, channel);
|
||||
|
||||
if let Some(image) = search_directory(RUN_DIRECTORY, image_type, Some(&channel))? {
|
||||
@ -353,6 +358,11 @@ impl ResourceImage {
|
||||
if Mounts::is_source_mounted("/dev/mapper/citadel-storage")? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if Mounts::is_source_mounted("/storage")? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let path = Path::new("/dev/mapper/citadel-storage");
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
@ -524,9 +534,15 @@ fn maybe_add_dir_entry(entry: &DirEntry,
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
if kernel_id.is_some() {
|
||||
if image_type == "kernel" && (metainfo.kernel_version() != kernel_version || metainfo.kernel_id() != kernel_id) {
|
||||
return Ok(());
|
||||
}
|
||||
} else { // in live mode, kernel_id is None
|
||||
if image_type == "kernel" && metainfo.kernel_version() != kernel_version {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
images.push(ResourceImage::new(&path, header));
|
||||
|
||||
|
@ -15,11 +15,12 @@ impl Mounts {
|
||||
pub fn is_source_mounted<P: AsRef<Path>>(path: P) -> Result<bool> {
|
||||
let path = path.as_ref();
|
||||
|
||||
let mounted = Self::load()?
|
||||
.mounts()
|
||||
.any(|m| m.source_path() == path);
|
||||
|
||||
Ok(mounted)
|
||||
for i in Self::load()?.mounts() {
|
||||
if i.line.contains(&path.display().to_string()) {
|
||||
return Ok(true)
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn is_target_mounted<P: AsRef<Path>>(path: P) -> Result<bool> {
|
||||
|
97
libcitadel/src/updates.rs
Normal file
97
libcitadel/src/updates.rs
Normal file
@ -0,0 +1,97 @@
|
||||
use std::fmt;
|
||||
use std::slice::Iter;
|
||||
|
||||
pub const UPDATE_SERVER_HOSTNAME: &str = "update.subgraph.com";
|
||||
|
||||
/// This struct embeds the CitadelVersion datastruct as well as the cryptographic validation of the that information
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CryptoContainerFile {
|
||||
pub serialized_citadel_version: Vec<u8>, // we serialize CitadelVersion
|
||||
pub signature: String, // serialized CitadelVersion gets signed
|
||||
pub signatory: String, // name of org or person who holds the key
|
||||
}
|
||||
|
||||
/// This struct contains the entirety of the information needed to decide whether to update or not
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct CitadelVersionStruct {
|
||||
pub client: String,
|
||||
pub channel: String, // dev, prod ...
|
||||
pub component_version: Vec<AvailableComponentVersion>,
|
||||
pub publisher: String, // name of org or person who released this update
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CitadelVersionStruct {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} image with channel {} has components:\n",
|
||||
self.client, self.channel
|
||||
)?;
|
||||
|
||||
for i in &self.component_version {
|
||||
write!(
|
||||
f,
|
||||
"\n{} with version {} at location {}",
|
||||
i.component, i.version, i.file_path
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Deserialize, PartialEq, Eq, Ord)]
|
||||
pub struct AvailableComponentVersion {
|
||||
pub component: Component, // rootfs, kernel or extra
|
||||
pub version: String, // stored as semver
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
impl PartialOrd for AvailableComponentVersion {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
// absolutely require that the components be in the same order in all structs (rootfs, kernel, extra)
|
||||
if &self.component != &other.component {
|
||||
panic!("ComponentVersion comparison failed because comparing different components");
|
||||
}
|
||||
|
||||
Some(
|
||||
semver::Version::parse(&self.version)
|
||||
.unwrap()
|
||||
.cmp(&semver::Version::parse(&other.version).unwrap()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AvailableComponentVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} image has version {}",
|
||||
self.component, self.version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, clap::ValueEnum)]
|
||||
pub enum Component {
|
||||
Rootfs,
|
||||
Kernel,
|
||||
Extra,
|
||||
}
|
||||
|
||||
impl Component {
|
||||
pub fn iterator() -> Iter<'static, Component> {
|
||||
static COMPONENTS: [Component; 3] =
|
||||
[Component::Rootfs, Component::Kernel, Component::Extra];
|
||||
COMPONENTS.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Component {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Component::Rootfs => write!(f, "rootfs"),
|
||||
Component::Kernel => write!(f, "kernel"),
|
||||
&Component::Extra => write!(f, "extra"),
|
||||
}
|
||||
}
|
||||
}
|
529
update-generator/src/main.rs
Normal file
529
update-generator/src/main.rs
Normal file
@ -0,0 +1,529 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use ed25519_dalek::pkcs8::DecodePublicKey;
|
||||
use ed25519_dalek::pkcs8::EncodePublicKey;
|
||||
use ed25519_dalek::Signature;
|
||||
use ed25519_dalek::Signer;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use ed25519_dalek::KEYPAIR_LENGTH;
|
||||
use glob::glob;
|
||||
use libcitadel::updates::Component;
|
||||
use libcitadel::{updates, ImageHeader};
|
||||
use rand::rngs::OsRng;
|
||||
use sodiumoxide::crypto::pwhash;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use sodiumoxide::crypto::stream;
|
||||
use std::env;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::str::FromStr;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
const SALSA_NONCE: &[u8] = &[
|
||||
116, 138, 142, 103, 234, 105, 192, 48, 117, 29, 150, 214, 106, 116, 195, 64, 120, 251, 94, 20,
|
||||
212, 118, 125, 189,
|
||||
];
|
||||
|
||||
const PASSWORD_SALT: &[u8] = &[
|
||||
18, 191, 168, 237, 156, 199, 54, 43, 122, 165, 35, 9, 89, 106, 36, 209, 145, 161, 90, 2, 121,
|
||||
51, 242, 182, 14, 245, 47, 253, 237, 153, 251, 219,
|
||||
];
|
||||
|
||||
const LAST_RESORT_CLIENT: &str = "public";
|
||||
const LAST_RESORT_CHANNEL: &str = "prod";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Perform tasks needed to create an update and publish it")]
|
||||
#[command(author, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
#[command(arg_required_else_help = true)]
|
||||
enum Commands {
|
||||
/// Generate a keypair to be used to sign version file. You will be asked to provide a mandatory password.
|
||||
GenerateKeypair {
|
||||
/// keypair filepath to save to
|
||||
#[arg(short, long)]
|
||||
keypair_filepath: Option<String>,
|
||||
},
|
||||
|
||||
/// Generate the complete cbor file. If no components are passed, generate by reading image of each component
|
||||
CreateSignedFile {
|
||||
/// rootfs image semver version
|
||||
#[arg(short, long)]
|
||||
rootfs_image_version: Option<String>,
|
||||
/// kernel image semver version
|
||||
#[arg(short, long)]
|
||||
kernel_image_version: Option<String>,
|
||||
#[arg(short, long)]
|
||||
extra_image_version: Option<String>,
|
||||
/// keypair filepath
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
path_keypair: Option<String>,
|
||||
/// command output complete filepath
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
versionfile_filepath: Option<String>,
|
||||
},
|
||||
|
||||
/// Verify that the version file is correctly signed
|
||||
VerifySignature {
|
||||
/// public key filepath
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
publickey_filepath: Option<String>,
|
||||
/// command output complete filepath
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
versionfile_filepath: Option<String>,
|
||||
},
|
||||
|
||||
UploadToServer {
|
||||
#[arg(long)]
|
||||
component: Option<updates::Component>,
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match &cli.command {
|
||||
Some(Commands::GenerateKeypair { keypair_filepath }) => {
|
||||
generate_keypair(keypair_filepath).context("Failed to generate keypair")?
|
||||
}
|
||||
Some(Commands::CreateSignedFile {
|
||||
path_keypair,
|
||||
rootfs_image_version,
|
||||
kernel_image_version,
|
||||
extra_image_version,
|
||||
versionfile_filepath,
|
||||
}) => create_signed_version_file(
|
||||
path_keypair,
|
||||
rootfs_image_version,
|
||||
kernel_image_version,
|
||||
extra_image_version,
|
||||
versionfile_filepath,
|
||||
)
|
||||
.context("Failed to create signed file")?,
|
||||
Some(Commands::VerifySignature {
|
||||
publickey_filepath,
|
||||
versionfile_filepath,
|
||||
}) => verify_version_signature(publickey_filepath, versionfile_filepath)
|
||||
.context("Failed to verify signature")?,
|
||||
Some(Commands::UploadToServer { component, path }) => {
|
||||
upload_components_to_server(component, path)
|
||||
.context("Failed to upload to the server")?
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_keypair(keypair_filepath: &Option<String>) -> Result<()> {
|
||||
// if the user did not pass a path, we save key files to current directory
|
||||
let keypair_filepath = &keypair_filepath.clone().unwrap_or_else(|| ".".to_string());
|
||||
let path = std::path::Path::new(keypair_filepath);
|
||||
|
||||
let mut password;
|
||||
|
||||
loop {
|
||||
// get passphrase used to encrypt key from user
|
||||
password = rpassword::prompt_password(
|
||||
"Please enter the passphrase we will use to encrypt the private key with: ",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let password_confirm = rpassword::prompt_password("Retype same passphrase: ").unwrap();
|
||||
|
||||
if password != password_confirm {
|
||||
println!("\nPassphrases did not match. Please try again")
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// generate keypair
|
||||
let mut csprng = OsRng;
|
||||
let signing_key: SigningKey = SigningKey::generate(&mut csprng);
|
||||
|
||||
let keypair_fp: PathBuf;
|
||||
let publickey_fp: PathBuf;
|
||||
|
||||
if path.is_dir() {
|
||||
keypair_fp = path.join("keypair.priv");
|
||||
publickey_fp = path.join("update_server_key.pub");
|
||||
} else {
|
||||
keypair_fp = path.to_path_buf();
|
||||
publickey_fp = path.parent().unwrap().join("update_server_key.pub");
|
||||
}
|
||||
|
||||
let mut keyfile = std::fs::File::create(&keypair_fp)?;
|
||||
let mut public_key = std::fs::File::create(&publickey_fp)?;
|
||||
|
||||
// encrypt private key
|
||||
let mut k = secretbox::Key([0; secretbox::KEYBYTES]);
|
||||
|
||||
let secretbox::Key(ref mut kb) = k;
|
||||
let password_hash = pwhash::derive_key(
|
||||
kb,
|
||||
password.as_bytes(),
|
||||
&sodiumoxide::crypto::pwhash::scryptsalsa208sha256::Salt::from_slice(PASSWORD_SALT)
|
||||
.unwrap(),
|
||||
pwhash::OPSLIMIT_INTERACTIVE,
|
||||
pwhash::MEMLIMIT_INTERACTIVE,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let plaintext = signing_key.to_keypair_bytes();
|
||||
let key = sodiumoxide::crypto::stream::xsalsa20::Key::from_slice(password_hash)
|
||||
.expect("failed to unwrap key");
|
||||
let nonce = sodiumoxide::crypto::stream::xsalsa20::Nonce::from_slice(SALSA_NONCE)
|
||||
.expect("failed to unwrap nonce");
|
||||
|
||||
// encrypt the plaintext
|
||||
let ciphertext = stream::stream_xor(&plaintext, &nonce, &key);
|
||||
|
||||
keyfile.write_all(&ciphertext)?;
|
||||
public_key.write_all(
|
||||
&signing_key
|
||||
.verifying_key()
|
||||
.to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
|
||||
.unwrap()
|
||||
.as_bytes(),
|
||||
)?;
|
||||
|
||||
password.zeroize();
|
||||
|
||||
println!(
|
||||
"Generated the keypair and wrote to {}. The public key is here: {}",
|
||||
keypair_fp.display(),
|
||||
publickey_fp.display()
|
||||
);
|
||||
println!(
|
||||
"You may now move the public key from {} to the citadel build path at citadel/meta-citadel/recipes-citadel/citadel-config/files/citadel-fetch/update_server_key.pub",
|
||||
publickey_fp.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_signed_version_file(
|
||||
signing_key_path: &Option<String>,
|
||||
citadel_rootfs_version: &Option<String>,
|
||||
citadel_kernel_version: &Option<String>,
|
||||
citadel_extra_version: &Option<String>,
|
||||
versionfile_filepath: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let rootfs_version = match citadel_rootfs_version {
|
||||
Some(v) => semver::Version::parse(v)
|
||||
.expect("Error: Failed to parse rootfs semver version")
|
||||
.to_string(),
|
||||
None => get_imageheader_version(&Component::Rootfs).unwrap_or(String::from("0.0.0")),
|
||||
};
|
||||
|
||||
let kernel_version = match citadel_kernel_version {
|
||||
Some(v) => semver::Version::parse(v)
|
||||
.expect("Error: Failed to parse kernel semver version")
|
||||
.to_string(),
|
||||
None => get_imageheader_version(&Component::Kernel).unwrap_or(String::from("0.0.0")),
|
||||
};
|
||||
|
||||
let extra_version = match citadel_extra_version {
|
||||
Some(v) => semver::Version::parse(v)
|
||||
.expect("Error: Failed to parse extra semver version")
|
||||
.to_string(),
|
||||
None => get_imageheader_version(&Component::Extra).unwrap_or(String::from("0.0.0")),
|
||||
};
|
||||
|
||||
let rootfs_path = get_component_path(&Component::Rootfs);
|
||||
let channel;
|
||||
|
||||
if rootfs_path.is_ok() {
|
||||
channel = match ImageHeader::from_file(rootfs_path?) {
|
||||
Ok(image_header) => image_header.metainfo().channel().to_string(),
|
||||
Err(_) => env::var("UPDATES_CHANNEL").unwrap_or(LAST_RESORT_CHANNEL.to_string()),
|
||||
};
|
||||
} else {
|
||||
channel = env::var("UPDATES_CHANNEL").unwrap_or(LAST_RESORT_CHANNEL.to_string());
|
||||
}
|
||||
|
||||
let component_version_vector = vec![
|
||||
updates::AvailableComponentVersion {
|
||||
component: updates::Component::Rootfs,
|
||||
version: rootfs_version.clone(),
|
||||
file_path: format!(
|
||||
"{}/{}/{}_{}.img",
|
||||
env::var("UPDATES_CLIENT").unwrap_or(LAST_RESORT_CLIENT.to_string()),
|
||||
channel,
|
||||
"rootfs",
|
||||
rootfs_version
|
||||
)
|
||||
.to_string(),
|
||||
},
|
||||
updates::AvailableComponentVersion {
|
||||
component: updates::Component::Kernel,
|
||||
version: kernel_version.clone(),
|
||||
file_path: format!(
|
||||
"{}/{}/{}_{}.img",
|
||||
env::var("UPDATES_CLIENT").unwrap_or(LAST_RESORT_CLIENT.to_string()),
|
||||
channel,
|
||||
"kernel",
|
||||
kernel_version
|
||||
)
|
||||
.to_string(),
|
||||
},
|
||||
updates::AvailableComponentVersion {
|
||||
component: updates::Component::Extra,
|
||||
version: extra_version.clone(),
|
||||
file_path: format!(
|
||||
"{}/{}/{}_{}.img",
|
||||
env::var("UPDATES_CLIENT").unwrap_or(LAST_RESORT_CLIENT.to_string()),
|
||||
channel,
|
||||
"extra",
|
||||
extra_version
|
||||
)
|
||||
.to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// generate version file
|
||||
let citadel_version = updates::CitadelVersionStruct {
|
||||
client: env::var("UPDATES_CLIENT").unwrap_or(LAST_RESORT_CLIENT.to_string()),
|
||||
channel: channel.to_string(),
|
||||
component_version: component_version_vector,
|
||||
publisher: "Subgraph".to_string(),
|
||||
};
|
||||
|
||||
let fp = match signing_key_path {
|
||||
Some(fp) => Path::new(fp),
|
||||
None => Path::new("keypair.priv"),
|
||||
};
|
||||
|
||||
// serialized to cbor
|
||||
let serialized_citadel_version = serde_cbor::to_vec(&citadel_version)?;
|
||||
|
||||
// get signing_key_bytes from the file passed
|
||||
let mut keyfile = std::fs::File::open(&fp)?;
|
||||
let mut buf = [0; KEYPAIR_LENGTH];
|
||||
keyfile.read_exact(&mut buf)?;
|
||||
|
||||
// prompt user for keypair decryption password
|
||||
let mut password =
|
||||
rpassword::prompt_password("Please enter the passphrase used to decrypt the private key: ")
|
||||
.unwrap();
|
||||
|
||||
// decrypt private key
|
||||
let mut k = secretbox::Key([0; secretbox::KEYBYTES]);
|
||||
|
||||
let secretbox::Key(ref mut kb) = k;
|
||||
let password_hash = pwhash::derive_key(
|
||||
kb,
|
||||
password.as_bytes(),
|
||||
&sodiumoxide::crypto::pwhash::scryptsalsa208sha256::Salt::from_slice(PASSWORD_SALT)
|
||||
.unwrap(),
|
||||
pwhash::OPSLIMIT_INTERACTIVE,
|
||||
pwhash::MEMLIMIT_INTERACTIVE,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let key = sodiumoxide::crypto::stream::xsalsa20::Key::from_slice(password_hash)
|
||||
.expect("failed to unwrap key");
|
||||
let nonce = sodiumoxide::crypto::stream::xsalsa20::Nonce::from_slice(SALSA_NONCE)
|
||||
.expect("failed to unwrap nonce");
|
||||
|
||||
// decrypt the ciphertext
|
||||
let plaintext = stream::stream_xor(&buf, &nonce, &key);
|
||||
|
||||
let signing_key = SigningKey::from_keypair_bytes(plaintext[0..64].try_into()?)?;
|
||||
|
||||
// sign serialized_citadel_version for inclusion in version_file
|
||||
let signature: Signature = signing_key.sign(&serialized_citadel_version);
|
||||
|
||||
// generate signature of citadel_version cbor format (signed)
|
||||
let version_file = updates::CryptoContainerFile {
|
||||
serialized_citadel_version,
|
||||
signature: signature.to_string(),
|
||||
signatory: "Subgraph".to_string(),
|
||||
};
|
||||
|
||||
let vf_fp = match versionfile_filepath {
|
||||
Some(vf_fp) => {
|
||||
if Path::new(vf_fp).is_dir() {
|
||||
Path::new(vf_fp).join("version.cbor")
|
||||
} else {
|
||||
Path::new(vf_fp).to_path_buf()
|
||||
}
|
||||
}
|
||||
None => Path::new("version.cbor").to_path_buf(),
|
||||
};
|
||||
|
||||
let outfile = std::fs::File::create(&vf_fp)?;
|
||||
|
||||
serde_cbor::to_writer(outfile, &version_file)?;
|
||||
|
||||
password.zeroize();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that the completed version file correctly verifies given the self-embedded signature and public key
|
||||
fn verify_version_signature(
|
||||
pubkey_filepath: &Option<String>,
|
||||
versionfile_filepath: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let pub_fp = match pubkey_filepath {
|
||||
Some(pub_fp) => Path::new(pub_fp),
|
||||
None => Path::new("update_server_key.pub"),
|
||||
};
|
||||
|
||||
let version_fp = match versionfile_filepath {
|
||||
Some(version_fp) => Path::new(version_fp),
|
||||
None => Path::new("version.cbor"),
|
||||
};
|
||||
|
||||
let mut pubkey_file = std::fs::File::open(&pub_fp)?;
|
||||
|
||||
let mut buf = String::new();
|
||||
pubkey_file.read_to_string(&mut buf)?;
|
||||
|
||||
let verifying_key = VerifyingKey::from_public_key_pem(&buf)?;
|
||||
|
||||
let version_file = &std::fs::File::open(version_fp)?;
|
||||
let crypto_container_struct: updates::CryptoContainerFile =
|
||||
serde_cbor::from_reader(version_file)?;
|
||||
|
||||
let citadel_version_struct: updates::CitadelVersionStruct =
|
||||
serde_cbor::from_slice(&crypto_container_struct.serialized_citadel_version)?;
|
||||
|
||||
let signature = ed25519_dalek::Signature::from_str(&crypto_container_struct.signature)?;
|
||||
|
||||
match verifying_key.verify_strict(
|
||||
&crypto_container_struct.serialized_citadel_version,
|
||||
&signature,
|
||||
) {
|
||||
Ok(_) => println!("Everythin ok. Signature verified correctly"),
|
||||
Err(e) => panic!(
|
||||
"Error: Signature was not able to be verified! Threw error: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
println!(
|
||||
"The destructured version file contains the following information:\n{}",
|
||||
citadel_version_struct
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Make sure to add your ssh key to the "updates" user on the server
|
||||
fn send_with_scp(from: &PathBuf, to: &PathBuf) -> Result<()> {
|
||||
Command::new("scp")
|
||||
.arg(from)
|
||||
.arg(format!(
|
||||
"updates@{}:/updates/files/{}",
|
||||
updates::UPDATE_SERVER_HOSTNAME,
|
||||
to.to_string_lossy()
|
||||
))
|
||||
.spawn()
|
||||
.context("scp command failed to run")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_imageheader_version(component: &Component) -> Result<String> {
|
||||
let version = match ImageHeader::from_file(get_component_path(component)?) {
|
||||
Ok(image_header) => image_header.metainfo().version().to_string(),
|
||||
Err(_) => String::from("0.0.0"),
|
||||
};
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn get_component_path(component: &updates::Component) -> Result<PathBuf> {
|
||||
let mut gl = match component {
|
||||
Component::Rootfs => glob("../build/images/citadel-rootfs*.img")?,
|
||||
Component::Kernel => glob("../build/images/citadel-kernel*.img")?,
|
||||
Component::Extra => glob("../build/images/citadel-extra*.img")?,
|
||||
};
|
||||
|
||||
let first = gl.nth(0).context(format!(
|
||||
"Failed to find citadel-{}*.img in ../build/images/",
|
||||
component
|
||||
))?;
|
||||
|
||||
Ok(PathBuf::from(first?))
|
||||
}
|
||||
|
||||
fn upload_all_components() -> Result<()> {
|
||||
for component in updates::Component::iterator() {
|
||||
upload_component(component, &None)?
|
||||
}
|
||||
upload_cbor()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload_component(component: &updates::Component, path: &Option<PathBuf>) -> Result<()> {
|
||||
let final_path;
|
||||
|
||||
// uggliest if statement i've ever written
|
||||
// if path was passed to this function
|
||||
if let Some(p) = path {
|
||||
final_path = p.to_path_buf();
|
||||
} else {
|
||||
// if the path wasn't passed to this function, search for the component path
|
||||
if let Ok(p) = get_component_path(&component) {
|
||||
final_path = p;
|
||||
} else {
|
||||
// if path was not passed and we failed to locate the component's path
|
||||
println!(
|
||||
"We failed to find the {} image we were looking for... Skipping...",
|
||||
component
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let image_header = libcitadel::ImageHeader::from_file(&final_path)?;
|
||||
|
||||
let to = PathBuf::from(format!(
|
||||
"{}/{}/{}_{}.img",
|
||||
env::var("UPDATES_CLIENT").unwrap_or(LAST_RESORT_CLIENT.to_string()),
|
||||
env::var("UPDATES_CHANNEL").unwrap_or(LAST_RESORT_CHANNEL.to_string()),
|
||||
component,
|
||||
image_header.metainfo().version()
|
||||
));
|
||||
send_with_scp(&final_path, &to)
|
||||
}
|
||||
|
||||
// If no parameters are passed to this command, upload all components regardless of version on the server
|
||||
// If a path is passed, upload that image to the server regardless version.
|
||||
fn upload_components_to_server(
|
||||
component: &Option<updates::Component>,
|
||||
path: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
// check if a component is passed to this function:
|
||||
match component {
|
||||
Some(comp) => upload_component(comp, path)?, // This function handles the option of not passing a path
|
||||
None => upload_all_components()?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload_cbor() -> Result<()> {
|
||||
send_with_scp(
|
||||
&PathBuf::from("version.cbor"),
|
||||
&PathBuf::from(format!(
|
||||
"{}/{}/version.cbor",
|
||||
env::var("UPDATES_CLIENT").unwrap_or(LAST_RESORT_CLIENT.to_string()),
|
||||
env::var("UPDATES_CHANNEL").unwrap_or(LAST_RESORT_CHANNEL.to_string()),
|
||||
)),
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user