upgrade_tools #6

Closed
isa wants to merge 2 commits from isa/citadel-tools:upgrade_tools into master
20 changed files with 2519 additions and 364 deletions

1797
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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() {

View File

@ -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 {
@ -136,8 +136,11 @@ fn compare_boot_partitions(a: Option<Partition>, b: Partition) -> Option<Partiti
}
// Compare versions and channels
let a_v = a.metainfo().version();
let b_v = b.metainfo().version();
let bind_a = a.metainfo();
let bind_b = b.metainfo();
let a_v = bind_a.version();
let b_v = bind_b.version();
// Compare versions only if channels match
if a.metainfo().channel() == b.metainfo().channel() {

View 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(&current_version)?;
let components_to_upgrade =
compare_citadel_versions(&current_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(&current_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(&current_version)?;
// What do we need to upgrade?
let components_to_upgrade =
compare_citadel_versions(&current_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(&current_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))
}

View 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);
}
}

View File

@ -250,9 +250,9 @@ fn install_image(arg_matches: &ArgMatches) -> Result<()> {
if kernel_version.chars().any(|c| c == '/') {
bail!("Kernel version field has / char");
}
format!("citadel-kernel-{}-{:03}.img", kernel_version, metainfo.version())
format!("citadel-kernel-{}-{}.img", kernel_version, metainfo.version())
} else {
format!("citadel-extra-{:03}.img", metainfo.version())
format!("citadel-extra-{}.img", metainfo.version())
};
if !metainfo.channel().chars().all(|c| c.is_ascii_lowercase()) {

View File

@ -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)),

View File

@ -38,15 +38,15 @@ impl UpdateBuilder {
}
fn target_filename(&self) -> String {
format!("citadel-{}-{}-{:03}.img", self.config.img_name(), self.config.channel(), self.config.version())
format!("citadel-{}-{}-{}.img", self.config.img_name(), self.config.channel(), self.config.version())
}
fn build_filename(config: &BuildConfig) -> String {
format!("citadel-{}-{}-{:03}", config.image_type(), config.channel(), config.version())
format!("citadel-{}-{}-{}", config.image_type(), config.channel(), config.version())
}
fn verity_filename(&self) -> String {
format!("verity-hash-{}-{:03}", self.config.image_type(), self.config.version())
format!("verity-hash-{}-{}", self.config.image_type(), self.config.version())
}
pub fn build(&mut self) -> Result<()> {
@ -154,7 +154,7 @@ impl UpdateBuilder {
bail!("failed to compress {:?}: {}", self.image(), err);
}
// Rename back to original image_data filename
util::rename(self.image().with_extension("xz"), self.image())?;
util::rename(util::append_to_path(self.image(), ".xz"), self.image())?;
}
Ok(())
}
@ -217,7 +217,7 @@ impl UpdateBuilder {
writeln!(v, "realmfs-name = \"{}\"", name)?;
}
writeln!(v, "channel = \"{}\"", self.config.channel())?;
writeln!(v, "version = {}", self.config.version())?;
writeln!(v, "version = \"{}\"", self.config.version())?;
writeln!(v, "timestamp = \"{}\"", self.config.timestamp())?;
writeln!(v, "nblocks = {}", self.nblocks.unwrap())?;
writeln!(v, "shasum = \"{}\"", self.shasum.as_ref().unwrap())?;

View File

@ -9,7 +9,7 @@ pub struct BuildConfig {
#[serde(rename = "image-type")]
image_type: String,
channel: String,
version: usize,
version: String,
timestamp: String,
source: String,
#[serde(default)]
@ -102,8 +102,8 @@ impl BuildConfig {
self.realmfs_name.as_ref().map(|s| s.as_str())
}
pub fn version(&self) -> usize {
self.version
pub fn version(&self) -> &str {
&self.version
}
pub fn channel(&self) -> &str {

View File

@ -93,7 +93,7 @@ fn create_tmp_copy(path: &Path) -> Result<PathBuf> {
Ok(path)
}
fn install_image(path: &Path, flags: u32) -> Result<()> {
pub fn install_image(path: &Path, flags: u32) -> Result<()> {
if !path.exists() || path.file_name().is_none() {
bail!("file path {} does not exist", path.display());
}
@ -140,7 +140,7 @@ fn prepare_image(image: &ResourceImage, flags: u32) -> Result<()> {
}
fn install_extra_image(image: &ResourceImage) -> Result<()> {
let filename = format!("citadel-extra-{:03}.img", image.header().metainfo().version());
let filename = format!("citadel-extra-{}.img", image.header().metainfo().version());
install_image_file(image, filename.as_str())?;
remove_old_extra_images(image)?;
Ok(())
@ -186,7 +186,7 @@ fn install_kernel_image(image: &mut ResourceImage) -> Result<()> {
info!("kernel version is {}", kernel_version);
install_kernel_file(image, &kernel_version)?;
let filename = format!("citadel-kernel-{}-{:03}.img", kernel_version, version);
let filename = format!("citadel-kernel-{}-{}.img", kernel_version, version);
install_image_file(image, &filename)?;
let all_versions = all_boot_kernel_versions()?;

View File

@ -20,6 +20,8 @@ walkdir = "2"
dbus = "0.6"
posix-acl = "1.0.0"
procfs = "0.12.0"
semver = "1.0"
clap = "4.5"
[dependencies.inotify]
version = "0.8"

View File

@ -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())
}

View File

@ -453,7 +453,7 @@ pub struct MetaInfo {
realmfs_owner: Option<String>,
#[serde(default)]
version: u32,
version: String,
#[serde(default)]
timestamp: String,
@ -508,8 +508,8 @@ impl MetaInfo {
Self::str_ref(&self.realmfs_owner)
}
pub fn version(&self) -> u32 {
self.version
pub fn version(&self) -> &str {
&self.version
}
pub fn timestamp(&self) -> &str {

View File

@ -20,6 +20,7 @@ pub mod symlink;
mod realm;
pub mod terminal;
mod system;
pub mod updates;
pub mod flatpak;

View File

@ -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);
@ -420,8 +430,11 @@ fn compare_images(a: Option<ResourceImage>, b: ResourceImage) -> Result<Resource
None => return Ok(b),
};
let ver_a = a.metainfo().version();
let ver_b = b.metainfo().version();
let bind_a = a.metainfo();
let bind_b = b.metainfo();
let ver_a = bind_a.version();
let ver_b = bind_b.version();
if ver_a > ver_b {
Ok(a)
@ -521,8 +534,14 @@ fn maybe_add_dir_entry(entry: &DirEntry,
return Ok(())
}
if image_type == "kernel" && (metainfo.kernel_version() != kernel_version || metainfo.kernel_id() != kernel_id) {
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));

View File

@ -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
View 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"),
}
}
}

View File

@ -48,6 +48,12 @@ fn search_path(filename: &str) -> Result<PathBuf> {
bail!("could not find {} in $PATH", filename)
}
pub fn append_to_path(p: &Path, s: &str) -> PathBuf {
let mut p_osstr = p.as_os_str().to_owned();
p_osstr.push(s);
p_osstr.into()
}
pub fn ensure_command_exists(cmd: &str) -> Result<()> {
let path = Path::new(cmd);
if !path.is_absolute() {
@ -338,7 +344,6 @@ pub fn is_euid_root() -> bool {
}
}
fn utimes(path: &Path, atime: i64, mtime: i64) -> Result<()> {
let cstr = CString::new(path.as_os_str().as_bytes())
.expect("path contains null byte");

View 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()),
)),
)
}