1
0
forked from brl/citadel-tools

6 Commits

Author SHA1 Message Date
isa
4b2a379eae merge upstream 2025-11-10 20:51:52 +00:00
isa
43f0e3ff98 Add channels and per-channel key signing 2025-09-25 22:17:33 -04:00
isa
991621d489 Make text clearer when checking hash 2025-09-04 16:35:59 -04:00
isa
08460b3d5e Fix broken extra image search 2025-08-29 00:58:06 -04:00
isa
d6a93b3ded Add basic update tooling 2025-08-28 12:34:47 -04:00
isa
756520821e Convert images version to use semver 2025-08-28 00:52:07 -04:00
21 changed files with 1885 additions and 2977 deletions

1762
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["citadel-realms", "citadel-tool", "realmsd", "launch-gnome-software", "update-realmfs" ]
members = ["citadel-realms", "citadel-tool", "realmsd", "launch-gnome-software", "update-realmfs"]
resolver = "2"
[profile.release]
lto = true

View File

@@ -17,7 +17,6 @@ toml = "0.9"
hex = "0.4"
byteorder = "1"
pwhash = "1.0"
rand = "0.8"
tempfile = "3"
zbus = "5.9.0"
anyhow = "1.0"
@@ -29,13 +28,10 @@ tokio = { version = "1", features = ["full"] }
rs-release = "0.1"
glob = "0.3"
serde_cbor = "0.11"
ed25519-dalek = { version = "2.2", features = ["pem", "rand_core"] }
ed25519-dalek = {version = "2.2", features = ["pem"]}
base64ct = "=1.7.3"
ureq = { version = "3.1" }
reqwest = { version = "0.12", features = ["blocking"] }
sha2 = "0.10"
nix = "0.30"
dialoguer = "0.12"
indicatif = "0.18"
serde_json = "1.0"
chrono = "0.4"
env_logger = "0.11"
indicatif = "0.18"

View File

@@ -1,949 +0,0 @@
use super::config::Config;
use super::keyring::ChannelKey;
use super::metadata::*;
use anyhow::{Context, Result};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use indicatif::{ProgressBar, ProgressStyle};
use libcitadel::ImageHeader;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::convert::TryInto;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
const METADATA_CACHE_DIR: &str = "/storage/citadel-state/tuf-cache/metadata";
const TARGETS_CACHE_DIR: &str = "/storage/citadel-state/tuf-cache/targets";
const EMBEDDED_ROOT: &str = "/etc/citadel/root.json";
pub struct TufClient {
config: Config,
metadata_dir: PathBuf,
targets_dir: PathBuf,
root: Option<SignedMetadata<RootMetadata>>,
timestamp: Option<SignedMetadata<TimestampMetadata>>,
snapshot: Option<SignedMetadata<SnapshotMetadata>>,
targets: Option<SignedMetadata<TargetsMetadata>>,
channel_targets: HashMap<String, SignedMetadata<TargetsMetadata>>,
os_release_info: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub component: String,
pub current_version: String,
pub new_version: String,
pub target_path: String,
pub download_size: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ChannelInfo {
pub name: String,
pub key_id: String,
}
impl TufClient {
pub fn new(config: &Config) -> Result<Self> {
let metadata_dir = PathBuf::from(METADATA_CACHE_DIR);
let targets_dir = PathBuf::from(TARGETS_CACHE_DIR);
fs::create_dir_all(&metadata_dir)?;
fs::create_dir_all(&targets_dir)?;
let root = load_or_bootstrap_root(&metadata_dir)?;
let os_release_info =
super::config::parse_conf_file(Path::new("/etc/os-release")).unwrap_or_default();
Ok(TufClient {
config: config.clone(),
metadata_dir,
targets_dir,
root: Some(root),
timestamp: None,
snapshot: None,
targets: None,
channel_targets: HashMap::new(),
os_release_info,
})
}
pub fn refresh_metadata(&mut self) -> Result<()> {
log::info!("Refreshing all metadata");
self.update_root()?;
self.update_timestamp()?;
self.update_snapshot()?;
self.update_targets()?;
log::info!("Metadata refresh complete");
Ok(())
}
fn update_root(&mut self) -> Result<()> {
log::info!("Updating root metadata");
let mut current_root = self.root.clone().unwrap();
let mut version = current_root.signed.version;
loop {
let next_version = version + 1;
log::debug!("Attempting to fetch root metadata version {}", next_version);
let url = format!(
"{}/{}.root.json",
self.config.repository_url(),
next_version
);
match fetch_url(&url) {
Ok(content) => {
log::debug!(
"Successfully fetched root metadata version {}",
next_version
);
let new_root: SignedMetadata<RootMetadata> =
serde_json::from_str(&content).context("Failed to parse root.json")?;
if new_root.signed.version != next_version {
anyhow::bail!("Root version mismatch");
}
check_expiry(&new_root.signed.expires)?;
// Verify with current root's keys
log::debug!("Verifying new root with old root keys");
self.verify_root_signature(&new_root, &current_root)?;
// Update for next iteration
current_root = new_root.clone();
version = next_version;
// Save to cache
let cache_path = self.metadata_dir.join("root.json");
fs::write(&cache_path, &content)?;
log::debug!("Saved new root metadata to cache");
// Update self.root
self.root = Some(new_root);
}
Err(e) => {
log::debug!(
"Failed to fetch root version {}: {}. Assuming this is the latest version.",
next_version,
e
);
break;
}
}
}
Ok(())
}
fn update_timestamp(&mut self) -> Result<()> {
log::info!("Updating timestamp metadata");
let url = format!("{}/timestamp.json", self.config.repository_url());
let content = fetch_url(&url).context("Failed to fetch timestamp.json")?;
let timestamp: SignedMetadata<TimestampMetadata> =
serde_json::from_str(&content).context("Failed to parse timestamp.json")?;
log::debug!("Verifying timestamp metadata signature");
self.verify_role_signature(&timestamp, "timestamp")?;
check_expiry(&timestamp.signed.expires)?;
if let Some(cached) = &self.timestamp {
if timestamp.signed.version < cached.signed.version {
log::error!(
"Timestamp rollback detected! Cached version: {}, new version: {}",
cached.signed.version,
timestamp.signed.version
);
anyhow::bail!("Timestamp rollback detected");
}
}
let cache_path = self.metadata_dir.join("timestamp.json");
fs::write(&cache_path, &content)?;
log::debug!("Saved new timestamp metadata to cache");
self.timestamp = Some(timestamp);
Ok(())
}
fn update_snapshot(&mut self) -> Result<()> {
log::info!("Updating snapshot metadata");
let timestamp = self.timestamp.as_ref().unwrap();
let snapshot_meta = timestamp
.signed
.meta
.get("snapshot.json")
.ok_or_else(|| anyhow::anyhow!("No snapshot.json in timestamp"))?;
let url = format!("{}/snapshot.json", self.config.repository_url());
let content = fetch_url(&url).context("Failed to fetch snapshot.json")?;
if let Some(hashes) = &snapshot_meta.hashes {
if let Some(expected) = hashes.get("sha256") {
let actual = hex::encode(Sha256::digest(content.as_bytes()));
if &actual != expected {
log::error!(
"Snapshot hash mismatch! Expected: {}, actual: {}",
expected,
actual
);
anyhow::bail!("Snapshot hash mismatch");
}
log::debug!("Snapshot hash verified");
}
}
let snapshot: SignedMetadata<SnapshotMetadata> =
serde_json::from_str(&content).context("Failed to parse snapshot.json")?;
log::debug!("Verifying snapshot metadata signature");
self.verify_role_signature(&snapshot, "snapshot")?;
check_expiry(&snapshot.signed.expires)?;
let cache_path = self.metadata_dir.join("snapshot.json");
fs::write(&cache_path, &content)?;
log::debug!("Saved new snapshot metadata to cache");
self.snapshot = Some(snapshot);
Ok(())
}
fn update_targets(&mut self) -> Result<()> {
log::info!("Updating targets metadata");
let snapshot = self.snapshot.as_ref().unwrap();
let _targets_meta = snapshot
.signed
.meta
.get("targets.json")
.ok_or_else(|| anyhow::anyhow!("No targets.json in snapshot"))?;
let url = format!("{}/targets.json", self.config.repository_url());
let content = fetch_url(&url).context("Failed to fetch targets.json")?;
let targets: SignedMetadata<TargetsMetadata> =
serde_json::from_str(&content).context("Failed to parse targets.json")?;
log::debug!("Verifying targets metadata signature");
self.verify_role_signature(&targets, "targets")?;
check_expiry(&targets.signed.expires)?;
let cache_path = self.metadata_dir.join("targets.json");
fs::write(&cache_path, &content)?;
log::debug!("Saved new targets metadata to cache");
self.targets = Some(targets);
Ok(())
}
fn load_channel_targets(&mut self, channel: &str) -> Result<()> {
if self.channel_targets.contains_key(channel) {
log::debug!("Channel targets for '{}' already loaded", channel);
return Ok(());
}
log::info!("Loading channel targets for '{}'", channel);
let url = format!("{}/{}.json", self.config.repository_url(), channel);
let content =
fetch_url(&url).with_context(|| format!("Failed to fetch {}.json", channel))?;
let channel_targets: SignedMetadata<TargetsMetadata> =
serde_json::from_str(&content).context("Failed to parse channel targets")?;
log::debug!(
"Verifying channel targets metadata signature for '{}'",
channel
);
self.verify_delegation_signature(&channel_targets, channel)?;
check_expiry(&channel_targets.signed.expires)?;
let cache_path = self.metadata_dir.join(format!("{}.json", channel));
fs::write(&cache_path, &content)?;
log::debug!(
"Saved new channel targets metadata for '{}' to cache",
channel
);
self.channel_targets
.insert(channel.to_string(), channel_targets);
Ok(())
}
pub fn check_for_updates(&mut self, channel: &str) -> Result<Vec<UpdateInfo>> {
log::info!("Checking for updates for channel '{}'", channel);
self.load_channel_targets(channel)?;
let channel_targets = self.channel_targets.get(channel).unwrap();
let mut updates = Vec::new();
for (target_path, info) in &channel_targets.signed.targets {
log::debug!("Checking target: {}", target_path);
let custom = match &info.custom {
Some(c) => c,
None => {
log::debug!("Target {} has no custom info, skipping", target_path);
continue;
}
};
let component = &custom.image_type;
log::debug!("Target component: {}", component);
let current_version = match component.as_str() {
"rootfs" => {
let version = self
.os_release_info
.get("CITADEL_ROOTFS_VERSION")
.map(|s| s.as_str())
.unwrap_or("0.0.0")
.to_string();
log::debug!("Current rootfs version from os-release: {}", version);
version
}
"kernel" => {
let base_path = format!("/storage/resources/{}", self.config.channel);
// Try to find any citadel-kernel*.img file and pick the highest version
if let Some((image_path, version)) =
find_highest_version_image(&base_path, "citadel-kernel")
{
log::debug!(
"Found kernel image at {} with version {}",
image_path.display(),
version
);
version
} else {
log::debug!("No kernel image found in {}", base_path);
"0.0.0".to_string()
}
}
"extra" => {
let base_path = format!("/storage/resources/{}", self.config.channel);
// Try to find any citadel-extra*.img file and pick the highest version
if let Some((image_path, version)) =
find_highest_version_image(&base_path, "citadel-extra")
{
log::debug!(
"Found extra image at {} with version {}",
image_path.display(),
version
);
version
} else {
log::debug!("No extra image found in {}", base_path);
"0.0.0".to_string()
}
}
_ => {
log::warn!("Unknown component type: {}", component);
"0.0.0".to_string()
}
};
log::debug!(
"Comparing new version {} with current version {}",
&custom.version,
&current_version
);
if version_gt(&custom.version, &current_version) {
// Check min_version requirement
if let Some(min) = &custom.min_version {
if version_gt(min, &current_version) {
log::warn!("Update for {} available, but current version {} is less than minimum required version {}. Skipping.", component, &current_version, min);
continue;
}
}
log::info!(
"Found update for {}: {} -> {}",
component,
&current_version,
&custom.version
);
updates.push(UpdateInfo {
component: component.to_string(),
current_version: current_version.to_string(),
new_version: custom.version.clone(),
target_path: target_path.clone(),
download_size: Some(info.length),
});
} else {
log::debug!("Component {} is up to date", component);
}
}
Ok(updates)
}
pub fn download_target(&mut self, channel: &str, target_path: &str) -> Result<PathBuf> {
log::info!(
"Downloading target '{}' from channel '{}'",
target_path,
channel
);
self.load_channel_targets(channel)?;
let channel_targets = self.channel_targets.get(channel).unwrap();
let target_info = channel_targets
.signed
.targets
.get(target_path)
.ok_or_else(|| anyhow::anyhow!("Target not found: {}", target_path))?;
let expected_hash = target_info
.hashes
.get("sha256")
.ok_or_else(|| anyhow::anyhow!("No sha256 hash for target"))?;
let filename = Path::new(target_path)
.file_name()
.unwrap()
.to_string_lossy();
let url = format!("{}/targets/{}", self.config.repository_url(), target_path);
let local_path = self.targets_dir.join(&*filename);
log::debug!("Downloading from {} to {}", url, local_path.display());
download_file(&url, &local_path, target_info.length)?;
// Verify hash
log::debug!(
"Verifying hash for downloaded file {}",
local_path.display()
);
let actual_hash = hash_file(&local_path)?;
if actual_hash != *expected_hash {
fs::remove_file(&local_path).ok();
log::error!(
"Hash mismatch for {}! Expected: {}, actual: {}",
local_path.display(),
expected_hash,
actual_hash
);
anyhow::bail!(
"Hash mismatch!\n Expected: {}\n Actual: {}",
expected_hash,
actual_hash
);
}
log::info!("Hash verified for {}", local_path.display());
Ok(local_path)
}
pub fn list_channels(&self) -> Result<Vec<ChannelInfo>> {
log::info!("Listing available channels");
let targets = self
.targets
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Targets not loaded"))?;
let mut channels = Vec::new();
if let Some(delegations) = &targets.signed.delegations {
for role in &delegations.roles {
let key_id = role.keyids.first().cloned().unwrap_or_default();
log::debug!("Found channel: {} with key ID: {}", role.name, key_id);
channels.push(ChannelInfo {
name: role.name.clone(),
key_id,
});
}
}
log::debug!("Found {} channels", channels.len());
Ok(channels)
}
pub fn get_channel_key(&self, channel: &str) -> Result<ChannelKey> {
log::info!("Getting channel key for '{}'", channel);
let targets = self
.targets
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Targets not loaded"))?;
let delegations = targets
.signed
.delegations
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No delegations in targets"))?;
let role = delegations
.roles
.iter()
.find(|r| r.name == channel)
.ok_or_else(|| anyhow::anyhow!("Channel '{}' not found", channel))?;
let key_id = role
.keyids
.first()
.ok_or_else(|| anyhow::anyhow!("No key for channel '{}'", channel))?;
let key = delegations
.keys
.get(key_id)
.ok_or_else(|| anyhow::anyhow!("Key {} not found", key_id))?;
log::debug!(
"Retrieved key for channel '{}': key ID = {}, public key = {}...",
channel,
key_id,
&key.keyval.public[..16]
);
Ok(ChannelKey {
key_id: key_id.clone(),
public_key: key.keyval.public.clone(),
})
}
fn verify_root_signature(
&self,
new_root: &SignedMetadata<RootMetadata>,
old_root: &SignedMetadata<RootMetadata>,
) -> Result<()> {
log::debug!("Verifying root signature");
// Verify with old root's keys first
let role_def = old_root
.signed
.roles
.get("root")
.ok_or_else(|| anyhow::anyhow!("No root role in root.json"))?;
log::debug!("Verifying new root with old root's keys");
verify_signatures(
new_root,
&role_def.keyids,
role_def.threshold,
&old_root.signed.keys,
)?;
// Then verify with new root's keys
let new_role_def = new_root
.signed
.roles
.get("root")
.ok_or_else(|| anyhow::anyhow!("No root role in new root.json"))?;
log::debug!("Verifying new root with new root's keys");
verify_signatures(
new_root,
&new_role_def.keyids,
new_role_def.threshold,
&new_root.signed.keys,
)
}
fn verify_role_signature<T: Serialize>(
&self,
signed: &SignedMetadata<T>,
role: &str,
) -> Result<()> {
log::debug!("Verifying role signature for role: {}", role);
let root = self
.root
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Root not loaded"))?;
let role_def = root
.signed
.roles
.get(role)
.ok_or_else(|| anyhow::anyhow!("Role '{}' not found", role))?;
log::debug!(
"Role '{}' found. Key IDs: {:?}, Threshold: {}",
role,
role_def.keyids,
role_def.threshold
);
verify_signatures(
signed,
&role_def.keyids,
role_def.threshold,
&root.signed.keys,
)
}
fn verify_delegation_signature<T: Serialize>(
&self,
signed: &SignedMetadata<T>,
delegation_name: &str,
) -> Result<()> {
log::debug!(
"Verifying delegation signature for delegation: {}",
delegation_name
);
let targets = self
.targets
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Targets not loaded"))?;
let delegations = targets
.signed
.delegations
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No delegations"))?;
let role = delegations
.roles
.iter()
.find(|r| r.name == delegation_name)
.ok_or_else(|| anyhow::anyhow!("Delegation '{}' not found", delegation_name))?;
log::debug!(
"Delegation '{}' found. Key IDs: {:?}, Threshold: {}",
delegation_name,
role.keyids,
role.threshold
);
log::debug!(
"Available delegation keys: {:?}",
delegations.keys.keys().collect::<Vec<_>>()
);
verify_signatures(signed, &role.keyids, role.threshold, &delegations.keys)
}
}
fn verify_signatures<T: Serialize>(
signed: &SignedMetadata<T>,
authorized_keyids: &[String],
threshold: u32,
keys: &HashMap<String, TufKey>,
) -> Result<()> {
let canonical_bytes = canonicalize_json(&signed.signed)?;
log::debug!(
"Canonical JSON (first 200 bytes): {:?}",
&canonical_bytes[..std::cmp::min(200, canonical_bytes.len())]
);
let authorized_set: std::collections::HashSet<&String> = authorized_keyids.iter().collect();
let mut valid_count = 0u32;
log::debug!("Verifying signatures. Threshold: {}", threshold);
log::debug!("Authorized key IDs: {:?}", authorized_keyids);
for sig in &signed.signatures {
log::debug!("Processing signature with key ID: {}", sig.keyid);
if !authorized_set.contains(&sig.keyid) {
log::debug!(
"Signature with key ID {} is not in the authorized set, skipping.",
sig.keyid
);
continue;
}
let tuf_key = match keys.get(&sig.keyid) {
Some(k) => {
log::debug!("Found key for ID {}", sig.keyid);
k
}
None => {
log::warn!("Key not found for ID {}", sig.keyid);
continue;
}
};
if tuf_key.keytype != "ed25519" {
log::warn!(
"Unsupported key type for key ID {}: {}",
sig.keyid,
tuf_key.keytype
);
continue;
}
let pub_bytes = match hex::decode(&tuf_key.keyval.public) {
Ok(b) => b,
Err(e) => {
log::warn!(
"Failed to decode public key for key ID {}: {}",
sig.keyid,
e
);
continue;
}
};
let pub_array: [u8; 32] = match pub_bytes.try_into() {
Ok(a) => a,
Err(_) => {
log::warn!("Public key for key ID {} has incorrect length", sig.keyid);
continue;
}
};
let verifying_key = match VerifyingKey::from_bytes(&pub_array) {
Ok(k) => k,
Err(e) => {
log::warn!(
"Failed to construct verifying key from public key for key ID {}: {}",
sig.keyid,
e
);
continue;
}
};
let sig_bytes = match hex::decode(&sig.sig) {
Ok(b) => b,
Err(e) => {
log::warn!("Failed to decode signature for key ID {}: {}", sig.keyid, e);
continue;
}
};
let sig_array: [u8; 64] = match sig_bytes.try_into() {
Ok(a) => a,
Err(_) => {
log::warn!("Signature for key ID {} has incorrect length", sig.keyid);
continue;
}
};
let signature = Signature::from_bytes(&sig_array);
if verifying_key.verify(&canonical_bytes, &signature).is_ok() {
log::debug!("Signature verified successfully for key ID {}", sig.keyid);
valid_count += 1;
} else {
log::warn!("Signature verification failed for key ID {}", sig.keyid);
}
}
log::debug!(
"Final verification count: valid_count={}, threshold={}",
valid_count,
threshold
);
if valid_count >= threshold {
log::debug!(
"Signature verification successful. Got {} valid signatures, threshold is {}",
valid_count,
threshold
);
Ok(())
} else {
log::error!(
"Signature verification failed: got {} valid, need {}",
valid_count,
threshold
);
anyhow::bail!(
"Signature verification failed: got {} valid, need {}",
valid_count,
threshold
)
}
}
fn canonicalize_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
let json_value = serde_json::to_value(value)?;
let canonical = sort_json_keys(json_value);
Ok(serde_json::to_vec(&canonical)?)
}
fn sort_json_keys(value: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
use std::collections::BTreeMap;
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.into_iter()
.map(|(k, v)| (k, sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.into_iter().map(sort_json_keys).collect()),
other => other,
}
}
fn load_or_bootstrap_root(metadata_dir: &Path) -> Result<SignedMetadata<RootMetadata>> {
let cached_path = metadata_dir.join("root.json");
if cached_path.exists() {
let content = fs::read_to_string(&cached_path)?;
if let Ok(root) = serde_json::from_str(&content) {
return Ok(root);
}
}
if Path::new(EMBEDDED_ROOT).exists() {
let content = fs::read_to_string(EMBEDDED_ROOT)?;
let root: SignedMetadata<RootMetadata> = serde_json::from_str(&content)?;
fs::write(&cached_path, &content)?;
return Ok(root);
}
anyhow::bail!(
"No TUF root.json found at {} or {}",
cached_path.display(),
EMBEDDED_ROOT
)
}
fn check_expiry(expires: &str) -> Result<()> {
use chrono::{DateTime, Utc};
let expiry: DateTime<Utc> = expires.parse().context("Failed to parse expiry")?;
if expiry < Utc::now() {
anyhow::bail!("Metadata expired: {}", expires);
}
Ok(())
}
fn fetch_url(url: &str) -> Result<String> {
let mut response = ureq::get(url)
.call()
.with_context(|| format!("Failed to fetch {}", url))?;
response
.body_mut()
.read_to_string()
.with_context(|| format!("Failed to read response from {}", url))
}
fn download_file(url: &str, dest: &Path, expected_size: u64) -> Result<()> {
println!(" Downloading from {}", url);
// Create progress bar
let pb = ProgressBar::new(expected_size);
pb.set_style(
ProgressStyle::default_bar()
.template(" [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let mut response = ureq::get(url).call().context("Failed to download file")?;
let mut file =
File::create(dest).with_context(|| format!("Failed to create file: {}", dest.display()))?;
let mut downloaded = 0u64;
let mut buffer = [0u8; 8192];
let mut reader = response.body_mut().as_reader();
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
file.write_all(&buffer[..n])?;
downloaded += n as u64;
pb.set_position(downloaded);
}
Err(e) => return Err(e.into()),
}
}
pb.finish_and_clear();
// Verify size
if downloaded != expected_size {
fs::remove_file(dest).ok();
anyhow::bail!(
"Size mismatch: expected {} bytes, got {}",
expected_size,
downloaded
);
}
println!(" ✓ Downloaded {} bytes", downloaded);
Ok(())
}
fn hash_file(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
/// Find the highest versioned image file matching the pattern
/// e.g., citadel-kernel-*.img or citadel-extra-*.img
fn find_highest_version_image(
base_path: &str,
component_prefix: &str,
) -> Option<(PathBuf, String)> {
use glob::glob;
let pattern = format!("{}/{}*.img", base_path, component_prefix);
log::debug!("Searching for images with pattern: {}", pattern);
let mut best_path: Option<PathBuf> = None;
let mut best_version = "0.0.0".to_string();
if let Ok(entries) = glob(&pattern) {
for entry in entries.flatten() {
log::debug!("Found image file: {}", entry.display());
// Try to read version from the image header
if let Ok(header) = ImageHeader::from_file(&entry) {
let version = if component_prefix.contains("kernel") {
header
.metainfo()
.kernel_version()
.map(|v| v.to_string())
.unwrap_or_else(|| "0.0.0".to_string())
} else {
header.metainfo().version().to_string()
};
log::debug!(" Image version: {}", version);
if version_gt(&version, &best_version) {
best_version = version;
best_path = Some(entry);
}
}
}
}
best_path.map(|path| {
log::debug!(
"Selected highest version image: {} (version: {})",
path.display(),
best_version
);
(path, best_version)
})
}
fn version_gt(a: &str, b: &str) -> bool {
let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
let va = parse(a);
let vb = parse(b);
for (a, b) in va.iter().zip(vb.iter()) {
if a > b {
return true;
}
if a < b {
return false;
}
}
va.len() > vb.len()
}

View File

@@ -1,110 +0,0 @@
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const UPDATE_CONF_PATH: &str = "/etc/citadel/update.conf";
const USER_CONF_PATH: &str = "/storage/citadel-state/citadel.conf";
const OS_RELEASE_PATH: &str = "/etc/os-release";
#[derive(Debug, Clone)]
pub struct Config {
pub base_url: String,
pub client: String,
pub channel: String,
pub min_root_version: u32,
pub require_signatures: bool,
}
impl Config {
pub fn load() -> Result<Self> {
let system_conf = parse_conf_file(Path::new(UPDATE_CONF_PATH)).unwrap_or_default();
let user_conf = parse_conf_file(Path::new(USER_CONF_PATH)).unwrap_or_default();
let os_release = parse_conf_file(Path::new(OS_RELEASE_PATH)).unwrap_or_default();
// User config overrides system config, which overrides os-release
let channel = user_conf
.get("UPDATE_CHANNEL")
.or_else(|| system_conf.get("DEFAULT_CHANNEL"))
.or_else(|| os_release.get("CITADEL_CHANNEL"))
.cloned()
.unwrap_or_else(|| "stable".to_string());
let client = system_conf
.get("UPDATE_CLIENT")
.cloned()
.unwrap_or_else(|| "public".to_string());
let base_url = system_conf
.get("UPDATE_BASE_URL")
.cloned()
.unwrap_or_else(|| "https://update.subgraph.com".to_string());
let min_root_version = system_conf
.get("MIN_ROOT_VERSION")
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let require_signatures = system_conf
.get("REQUIRE_SIGNATURES")
.map(|s| s == "true" || s == "1")
.unwrap_or(true);
Ok(Config {
base_url,
client,
channel,
min_root_version,
require_signatures,
})
}
pub fn save(&self) -> Result<()> {
let dir = Path::new(USER_CONF_PATH).parent().unwrap();
fs::create_dir_all(dir)?;
let mut content = String::new();
// Read existing config to preserve other settings
if let Ok(existing) = fs::read_to_string(USER_CONF_PATH) {
for line in existing.lines() {
if !line.starts_with("UPDATE_CHANNEL=") {
content.push_str(line);
content.push('\n');
}
}
}
content.push_str(&format!("UPDATE_CHANNEL=\"{}\"\n", self.channel));
fs::write(USER_CONF_PATH, content)?;
Ok(())
}
pub fn repository_url(&self) -> String {
format!("{}/{}", self.base_url, self.client)
}
}
pub fn parse_conf_file(path: &Path) -> Result<HashMap<String, String>> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let mut conf = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find('=') {
let key = line[..pos].trim().to_string();
let value = line[pos + 1..].trim().trim_matches('"').to_string();
conf.insert(key, value);
}
}
Ok(conf)
}

View File

@@ -0,0 +1,529 @@
use crate::{update, Path};
use anyhow::{Context, Result};
use clap::ArgMatches;
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use ed25519_dalek::{pkcs8::DecodePublicKey, VerifyingKey};
use indicatif::{ProgressBar, ProgressStyle};
use libcitadel::ResourceImage;
use libcitadel::{updates, updates::CitadelVersionStruct};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::env;
use std::fs;
use std::io::prelude::*;
use std::str::FromStr;
use tempfile::Builder;
const IMAGE_DIRECTORY_PATH: &str = "/storage/resources";
const UPDATE_SERVER_KEY_PATH: &str = "/etc/citadel/update_server_key.pub";
const LAST_RESORT_CLIENT: &str = "public";
const LAST_RESORT_CHANNEL: &str = "stable";
const LAST_RESORT_CITADEL_PUBLISHER: &str = "Subgraph";
const DEFAULT_UPDATE_SERVER_HOSTNAME: &str = "update.subgraph.com";
#[derive(Deserialize)]
struct Channels {
channels: Vec<String>,
}
fn get_update_server_hostname() -> String {
env::var("UPDATE_SERVER_HOSTNAME")
.unwrap_or_else(|_| DEFAULT_UPDATE_SERVER_HOSTNAME.to_string())
}
fn verify_hash(path: &std::path::PathBuf, expected_hash: &str) -> Result<()> {
let mut file = fs::File::open(path)?;
let mut sha256 = Sha256::new();
std::io::copy(&mut file, &mut sha256)?;
let hash = format!("{:x}", sha256.finalize());
if hash != expected_hash {
fs::remove_file(path)?;
anyhow::bail!(
"Hash mismatch for file {}. Expected {}, got {}",
path.display(),
expected_hash,
hash
);
}
Ok(())
}
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: \n");
for component in components_to_upgrade {
println!("{}", component);
}
} else {
println!("Your system is up to date!");
}
Ok(())
}
fn get_component_info_from_args<'a>(
sub_matches: &ArgMatches,
server_version: &'a CitadelVersionStruct,
) -> Result<(&'a str, &'a str)> {
let (path, hash) = if sub_matches.get_flag("rootfs") {
(
&server_version.component_version[0].file_path,
&server_version.component_version[0].sha256_hash,
)
} else if sub_matches.get_flag("kernel") {
(
&server_version.component_version[1].file_path,
&server_version.component_version[1].sha256_hash,
)
} else if sub_matches.get_flag("extra") {
(
&server_version.component_version[2].file_path,
&server_version.component_version[2].sha256_hash,
)
} else {
anyhow::bail!("No component specified for download/reinstall.");
};
Ok((path, hash))
}
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 (path, hash) = get_component_info_from_args(sub_matches, server_citadel_version)?;
download_file(path, hash)?;
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()?;
// Check if we are missing local version info, which happens after a channel switch
let mut missing_components = Vec::new();
for component in &current_version.component_version {
if (component.component == updates::Component::Kernel
|| component.component == updates::Component::Extra)
&& component.version == "0.0.0"
{
missing_components.push(component.component.to_string());
}
}
if !missing_components.is_empty() {
println!("WARNING: Your system is missing local version information for the following components:");
for comp in &missing_components {
println!("- {}", comp);
}
println!(
"\nThis is expected if you have recently switched to the '{}' update channel.",
current_version.channel
);
println!("However, it means we cannot verify if the server's version is newer than your installed version.");
println!(
"This could expose you to a downgrade attack if the remote channel is compromised."
);
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(
"Do you want to proceed with fetching and installing the versions from the server?",
)
.default(false)
.interact()?;
if !confirmed {
println!("Upgrade aborted by user.");
return Ok(());
}
}
let server_citadel_version = &fetch_and_verify_version_cbor(current_version)?;
// Find which components have updates available
let components_to_upgrade = compare_citadel_versions(current_version, server_citadel_version)?;
if components_to_upgrade.is_empty() {
println!("Your system is up to date!");
return Ok(());
}
// Create a list of formatted strings for the prompt
let prompt_items: Vec<String> = components_to_upgrade
.iter()
.map(|comp| {
// Find the currently installed version for a nice display
let current_comp_version = current_version
.component_version
.iter()
.find(|c| c.component == comp.component)
.map(|c| c.version.clone())
.unwrap_or_else(|| "N/A".to_string());
// If the current version is 0.0.0, display it as "not installed"
let display_version = if current_comp_version == "0.0.0" {
"not installed".to_string()
} else {
current_comp_version
};
format!(
"{} ({} -> {})",
comp.component, display_version, comp.version
)
})
.collect();
// Build and display the interactive checklist to the user
println!("Found available updates. Please make your selection:");
let selections = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select components to upgrade (use spacebar to select, enter to confirm)")
.items(&prompt_items)
.defaults(&vec![true; prompt_items.len()])
.interact()?;
if selections.is_empty() {
println!("No components selected. Aborting upgrade.");
return Ok(());
}
// Create a final list of only the components the user selected
let mut final_components_to_install = Vec::new();
for index in selections {
final_components_to_install.push(components_to_upgrade[index].clone());
}
println!(
"\nPreparing to install {} component(s)...",
final_components_to_install.len()
);
// Loop through the selected components and install them
for component in final_components_to_install {
println!("---");
println!("Upgrading {}", component.component);
let (_tmp_dir, save_path) = download_file(&component.file_path, &component.sha256_hash)?;
println!("Installing image...");
update::install_image(&save_path, 0)?;
println!("{} installed successfully!", component.component);
}
println!("---");
println!("Update process finished.");
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 (path, hash) = get_component_info_from_args(sub_matches, server_citadel_version)?;
let (_tmp_dir, save_path) = download_file(path, hash)?;
update::install_image(&save_path, 0)?;
Ok(())
}
pub fn status() -> Result<()> {
println!("Gathering local system information...");
let current_config = get_current_os_config()?;
println!("\n--- Citadel Update Status ---");
println!("Client: {}", current_config.client);
println!("Channel: {}", current_config.channel);
println!("Publisher: {}", current_config.publisher);
println!("\n--- Installed Components ---");
for component in current_config.component_version {
// Handle the case where a component might not be installed for the current channel
let version_display = if component.version == "0.0.0" {
"Not installed for this channel".to_string()
} else {
component.version
};
println!(
"{:<12}{}",
format!("{}:", component.component),
version_display
);
}
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();
if current.channel != offered.channel {
anyhow::bail!(
"Update channel mismatch. Your system is on '{}', but the server is configured for '{}'. Please check your configuration.",
current.channel,
offered.channel
);
}
if current.client != offered.client {
anyhow::bail!(
"Update client mismatch. Your system is configured as '{}', but the server is configured for '{}'. Please check your configuration.",
current.client,
offered.client
);
}
if current.publisher != offered.publisher {
anyhow::bail!(
"Update publisher mismatch. Your system is configured for '{}', but the server is configured for '{}'. Please check your configuration.",
current.publisher,
offered.publisher
);
}
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)
}
fn get_image_version<P: AsRef<Path>>(path: &P) -> Result<String> {
let resource_image = ResourceImage::from_path(path)?;
Ok(resource_image.metainfo().version().to_string())
}
/// Reads and displays the currently configured update channel.
pub fn show_channel() -> Result<()> {
let channel = updates::get_citadel_conf("CITADEL_CHANNEL")?
.unwrap_or_else(|| LAST_RESORT_CHANNEL.to_string());
println!("Current update channel: {}", channel);
Ok(())
}
/// Sets the system's update channel by writing to the config file.
pub fn set_channel(channel_to_set: &str) -> Result<()> {
// Validate the channel name against the server's list.
let available_channels = fetch_available_channels()?;
if !available_channels.contains(&channel_to_set.to_string()) {
anyhow::bail!(
"Channel '{}' is not a valid remote channel. Use 'channel list' to see options.",
channel_to_set
);
}
// Check if the directory for the new channel already exists.
let channel_dir_path = format!("{IMAGE_DIRECTORY_PATH}/{channel_to_set}");
let path = Path::new(&channel_dir_path);
if !path.exists() {
println!(
"You are switching to a new channel ('{}') for the first time.",
channel_to_set
);
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Are you sure you want to change channels to: '{}'?",
channel_dir_path
))
.default(true)
.interact()?;
if confirmed {
fs::create_dir_all(path).context(format!(
"Failed to create directory for channel '{}'",
channel_to_set
))?;
println!("Directory created.");
} else {
println!("Channel switch aborted by user.");
return Ok(());
}
}
updates::set_citadel_conf("CITADEL_CHANNEL", channel_to_set)?;
println!("\nUpdate channel has been set to: {}", channel_to_set);
Ok(())
}
fn get_current_os_config() -> Result<updates::CitadelVersionStruct> {
let client = updates::get_citadel_conf("CITADEL_CLIENT")?
.unwrap_or_else(|| LAST_RESORT_CLIENT.to_string());
let channel = updates::get_citadel_conf("CITADEL_CHANNEL")?
.unwrap_or_else(|| LAST_RESORT_CHANNEL.to_string());
let mut kernel_version = String::from("0.0.0");
let glob_pattern = format!("{IMAGE_DIRECTORY_PATH}/{channel}/citadel-kernel*.img");
if let Ok(glob_results) = glob::glob(&glob_pattern) {
for path_result in glob_results {
if let Ok(path) = path_result {
// If we find a kernel image, try to get its version.
// If we succeed, update the version and stop looking.
if let Ok(version) = get_image_version(&path) {
kernel_version = version;
break;
}
}
}
}
// RootFS version is always available from the running system
let rootfs_version = updates::get_os_release("CITADEL_ROOTFS_VERSION")?.unwrap();
// Extra version - gracefully handle if not found
let mut extra_version = String::from("0.0.0");
let glob_pattern_extra = format!("{IMAGE_DIRECTORY_PATH}/{channel}/citadel-extra*.img");
if let Ok(glob_results) = glob::glob(&glob_pattern_extra) {
for path_result in glob_results {
if let Ok(path) = path_result {
if let Ok(version) = get_image_version(&path) {
extra_version = version;
break;
}
}
}
}
let publisher = updates::get_citadel_conf("CITADEL_PUBLISHER")?
.unwrap_or_else(|| LAST_RESORT_CITADEL_PUBLISHER.to_string());
let mut component_version = Vec::new();
component_version.push(updates::AvailableComponentVersion {
component: updates::Component::Rootfs,
version: rootfs_version.to_owned(),
file_path: "".to_owned(),
sha256_hash: "".to_owned(),
});
component_version.push(updates::AvailableComponentVersion {
component: updates::Component::Kernel,
version: kernel_version.to_owned(),
file_path: "".to_owned(),
sha256_hash: "".to_owned(),
});
component_version.push(updates::AvailableComponentVersion {
component: updates::Component::Extra,
version: extra_version.to_owned(),
file_path: "".to_owned(),
sha256_hash: "".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",
get_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)?;
// construct the struct
let citadel_version_struct: updates::CitadelVersionStruct =
serde_cbor::from_slice(&crypto_container.serialized_citadel_version)?;
Ok(citadel_version_struct)
}
fn download_file(path: &str, hash: &str) -> Result<(tempfile::TempDir, std::path::PathBuf)> {
let client = reqwest::blocking::Client::new();
let url = format!("https://{}/{}", get_update_server_hostname(), path);
println!("Downloading from {}", url);
let component_download_response = client.get(&url).send()?;
// Get the total size of the file from the server's response headers.
let total_size = component_download_response
.content_length()
.context("Failed to get content length from server")?;
// Create a new progress bar and set its style.
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
.tick_strings(&["", "", "", "", "", "", "", "", "", "", " "])
.progress_chars("=>-"),
);
// Create the temporary directory and destination file.
let tmp_dir = Builder::new().prefix("citadel-fetch").tempdir()?;
let file_name = Path::new(path).file_name().unwrap();
let dest_path = tmp_dir.path().join(file_name);
let mut dest_file = fs::File::create(&dest_path)?;
// Wrap the download stream with the progress bar.
let mut source = pb.wrap_read(component_download_response);
// Copy the stream to the file, which automatically updates the progress bar.
std::io::copy(&mut source, &mut dest_file)?;
println!("\nSaved file to {}\n", dest_path.display());
verify_hash(&dest_path, hash)?;
println!("File hash verified\n");
Ok((tmp_dir, dest_path))
}
pub fn list_channels() -> Result<()> {
println!("Fetching available channels...");
let channels = fetch_available_channels()?;
println!("Available channels:");
for channel in channels {
println!("- {}", channel);
}
Ok(())
}
/// Fetches the list of available channels from the remote server.
fn fetch_available_channels() -> Result<Vec<String>> {
let url = format!(
"https://{}/{}/channels.cbor",
get_update_server_hostname(),
LAST_RESORT_CLIENT
);
let response_bytes = reqwest::blocking::get(&url)?.bytes()?;
let available_channels: Channels = serde_cbor::from_slice(&response_bytes)
.context("Failed to parse channel list CBOR from server")?;
Ok(available_channels.channels)
}

View File

@@ -1,210 +0,0 @@
use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const KEYRING_PATH: &str = "/storage/citadel-state/trusted-keys/keyring.json";
const BUILTIN_KEYS_DIR: &str = "/usr/share/citadel/keys";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Keyring {
pub version: u32,
pub trusted_keys: HashMap<String, TrustedKey>,
#[serde(default)]
pub default_channels: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustedKey {
pub key_id: String,
pub public_key: String,
pub added_at: String,
pub trust_level: TrustLevel,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
Default,
UserApproved,
Pending,
}
#[derive(Debug, Clone)]
pub enum TrustStatus {
TrustedDefault,
TrustedUser,
Pending,
Unknown,
KeyMismatch { expected: String, actual: String },
}
#[derive(Debug, Clone)]
pub struct ChannelKey {
pub key_id: String,
pub public_key: String,
}
impl Keyring {
pub fn load() -> Result<Self> {
let mut keyring = if Path::new(KEYRING_PATH).exists() {
let content = fs::read_to_string(KEYRING_PATH).context("Failed to read keyring")?;
serde_json::from_str(&content).context("Failed to parse keyring")?
} else {
Keyring {
version: 1,
trusted_keys: HashMap::new(),
default_channels: Vec::new(),
}
};
// Load built-in keys from rootfs
keyring.load_builtin_keys()?;
Ok(keyring)
}
fn load_builtin_keys(&mut self) -> Result<()> {
let keys_dir = Path::new(BUILTIN_KEYS_DIR);
if !keys_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(keys_dir)? {
let entry = entry?;
let path = entry.path();
// Look for channel public keys (not image keys)
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
// Skip image signing keys (*_image.pub)
if filename.ends_with("_image") {
continue;
}
if path.extension().map_or(false, |e| e == "pub") {
let channel = filename.to_string();
let public_key = fs::read_to_string(&path)?.trim().to_string();
let key_id = compute_key_id(&public_key);
// Built-in keys don't override user keys
if !self.trusted_keys.contains_key(&channel) {
self.trusted_keys.insert(
channel.clone(),
TrustedKey {
key_id,
public_key,
added_at: "built-in".to_string(),
trust_level: TrustLevel::Default,
comment: Some("Shipped with OS".to_string()),
},
);
if !self.default_channels.contains(&channel) {
self.default_channels.push(channel);
}
}
}
}
Ok(())
}
pub fn save(&self) -> Result<()> {
let dir = Path::new(KEYRING_PATH).parent().unwrap();
fs::create_dir_all(dir)?;
// Only save non-default keys (default keys come from rootfs)
let saveable: HashMap<_, _> = self
.trusted_keys
.iter()
.filter(|(_, k)| k.trust_level != TrustLevel::Default)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let save_keyring = Keyring {
version: self.version,
trusted_keys: saveable,
default_channels: Vec::new(), // Don't save default list
};
let content = serde_json::to_string_pretty(&save_keyring)?;
fs::write(KEYRING_PATH, content)?;
Ok(())
}
pub fn check_trust(&self, channel: &str, key_id: &str) -> TrustStatus {
match self.trusted_keys.get(channel) {
Some(key) => {
if key.key_id == key_id {
match key.trust_level {
TrustLevel::Default => TrustStatus::TrustedDefault,
TrustLevel::UserApproved => TrustStatus::TrustedUser,
TrustLevel::Pending => TrustStatus::Pending,
}
} else {
TrustStatus::KeyMismatch {
expected: key.key_id.clone(),
actual: key_id.to_string(),
}
}
}
None => TrustStatus::Unknown,
}
}
pub fn get_key(&self, channel: &str) -> Option<&TrustedKey> {
self.trusted_keys.get(channel)
}
pub fn is_default(&self, channel: &str) -> bool {
self.default_channels.contains(&channel.to_string())
}
pub fn add_trusted(
&mut self,
channel: &str,
key: &ChannelKey,
level: TrustLevel,
) -> Result<()> {
self.trusted_keys.insert(
channel.to_string(),
TrustedKey {
key_id: key.key_id.clone(),
public_key: key.public_key.clone(),
added_at: Utc::now().to_rfc3339(),
trust_level: level,
comment: None,
},
);
self.save()
}
pub fn add_pending(&mut self, channel: &str, key: &ChannelKey) -> Result<()> {
self.add_trusted(channel, key, TrustLevel::Pending)
}
pub fn approve(&mut self, channel: &str) -> Result<()> {
if let Some(key) = self.trusted_keys.get_mut(channel) {
key.trust_level = TrustLevel::UserApproved;
key.added_at = Utc::now().to_rfc3339();
key.comment = Some("Approved by user".to_string());
}
self.save()
}
pub fn remove(&mut self, channel: &str) -> Result<()> {
self.trusted_keys.remove(channel);
self.save()
}
}
fn compute_key_id(public_key: &str) -> String {
let hash = Sha256::digest(public_key.as_bytes());
hex::encode(&hash[..8])
}

View File

@@ -1,121 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedMetadata<T> {
pub signatures: Vec<Signature>,
pub signed: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
pub keyid: String,
pub sig: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootMetadata {
#[serde(rename = "_type")]
pub metadata_type: String,
pub spec_version: String,
pub version: u32,
pub expires: String,
pub keys: HashMap<String, TufKey>,
pub roles: HashMap<String, RoleDefinition>,
pub consistent_snapshot: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TufKey {
pub keytype: String,
pub scheme: String,
pub keyval: KeyValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyValue {
pub public: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleDefinition {
pub keyids: Vec<String>,
pub threshold: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampMetadata {
#[serde(rename = "_type")]
pub metadata_type: String,
pub spec_version: String,
pub version: u32,
pub expires: String,
pub meta: HashMap<String, MetaFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaFile {
pub version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub length: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hashes: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMetadata {
#[serde(rename = "_type")]
pub metadata_type: String,
pub spec_version: String,
pub version: u32,
pub expires: String,
pub meta: HashMap<String, MetaFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetsMetadata {
#[serde(rename = "_type")]
pub metadata_type: String,
pub spec_version: String,
pub version: u32,
pub expires: String,
pub targets: HashMap<String, TargetInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegations: Option<Delegations>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetInfo {
pub length: u64,
pub hashes: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom: Option<TargetCustom>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetCustom {
pub version: String,
pub image_type: String,
pub channel: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_notes_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delegations {
pub keys: HashMap<String, TufKey>,
pub roles: Vec<DelegatedRole>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegatedRole {
pub name: String,
pub keyids: Vec<String>,
pub threshold: u32,
pub paths: Vec<String>,
#[serde(default)]
pub terminating: bool,
}

View File

@@ -1,699 +1,85 @@
mod client;
mod config;
mod keyring;
mod metadata;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::Path;
#[derive(Parser)]
#[command(name = "citadel-fetch")]
#[command(about = "Citadel update client")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Check for available updates
Check,
/// Download and install updates
Update {
/// Don't prompt for confirmation
#[arg(short, long)]
yes: bool,
/// Only download specific component
#[arg(short, long)]
component: Option<String>,
},
/// Show current update status
Status,
/// Manage update channels
Channel {
#[command(subcommand)]
command: ChannelCommands,
},
/// Manage trusted signing keys
Keyring {
#[command(subcommand)]
command: KeyringCommands,
},
/// Refresh TUF metadata from server
Refresh,
}
#[derive(Debug, Subcommand)]
enum ChannelCommands {
/// Show current channel
Show,
/// List available channels
List,
/// Switch to a different channel
Set {
/// Channel name to switch to
channel: String,
},
}
#[derive(Debug, Subcommand)]
enum KeyringCommands {
/// List all trusted keys
List,
/// Show details of a specific channel's key
Show {
/// Channel name
channel: String,
},
/// Add a key from the remote repository
Add {
/// Channel name
channel: String,
},
/// Remove a key from the keyring
Remove {
/// Channel name
channel: String,
},
/// Discover available channels from remote
Discover,
}
use clap::{arg, command, ArgAction, Command};
use std::process::exit;
mod fetch;
use clap::Arg;
pub fn main() {
env_logger::init();
if let Err(e) = run() {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
log::debug!("Executing command: {:?}", cli.command);
match cli.command {
Commands::Check => cmd_check(),
Commands::Update { yes, component } => cmd_update(yes, component),
Commands::Status => cmd_status(),
Commands::Channel { command } => match command {
ChannelCommands::Show => cmd_channel_show(),
ChannelCommands::List => cmd_channel_list(),
ChannelCommands::Set { channel } => cmd_channel_set(&channel),
let matches = command!() // requires `cargo` feature
.subcommand_required(true)
.subcommand(Command::new("check").about("Check for updates from remote server"))
.subcommand(Command::new("status").about("Show the currently installed versions and update channel"))
.subcommand(
Command::new("download")
.about("Download a specific component 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 components if the server has a more recent version than currently installed on the 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),
)
.subcommand(
Command::new("channel")
.about("Manage the update channel")
.subcommand_required(true)
.subcommand(Command::new("list").about("List available channels from the server"))
.subcommand(Command::new("show").about("Show the current update channel"))
.subcommand(
Command::new("set")
.about("Set a new update channel (requires root)")
.arg(Arg::new("CHANNEL").required(true)),
),
)
.get_matches();
let result = match matches.subcommand() {
Some(("check", _sub_matches)) => fetch::check(),
Some(("status", _sub_matches)) => fetch::status(),
Some(("download", sub_matches)) => fetch::download(sub_matches),
Some(("read-remote", _sub_matches)) => fetch::read_remote(),
Some(("upgrade", _sub_matches)) => {
let _ = require_root(); // Check for root privileges
fetch::upgrade()
}
Some(("reinstall", sub_matches)) => {
let _ = require_root(); // Check for root privileges.
fetch::reinstall(sub_matches)
}
Some(("channel", sub_matches)) => match sub_matches.subcommand() {
Some(("list", _)) => fetch::list_channels(),
Some(("show", _)) => fetch::show_channel(),
Some(("set", set_matches)) => {
let _ = require_root();
let channel_name = set_matches.get_one::<String>("CHANNEL").unwrap();
fetch::set_channel(channel_name)
}
_ => unreachable!(),
},
Commands::Keyring { command } => match command {
KeyringCommands::List => cmd_keyring_list(),
KeyringCommands::Show { channel } => cmd_keyring_show(&channel),
KeyringCommands::Add { channel } => cmd_keyring_add(&channel),
KeyringCommands::Remove { channel } => cmd_keyring_remove(&channel),
KeyringCommands::Discover => cmd_keyring_discover(),
},
Commands::Refresh => cmd_refresh(),
}
}
fn cmd_check() -> Result<()> {
log::debug!("Executing cmd_check");
let config = config::Config::load()?;
let mut client = client::TufClient::new(&config)?;
println!("Checking for updates...");
println!(" Repository: {}", config.repository_url());
println!(" Channel: {}", config.channel);
client.refresh_metadata()?;
let updates = client.check_for_updates(&config.channel)?;
if updates.is_empty() {
println!("✓ System is up to date");
} else {
println!("Updates available:");
for update in &updates {
println!(
" {} {}{}",
update.component, update.current_version, update.new_version
);
if let Some(size) = update.download_size {
println!(" Download size: {:.1} MB", size as f64 / 1_048_576.0);
}
}
println!("Run 'citadel-fetch update' to install updates");
}
Ok(())
}
fn cmd_update(yes: bool, component: Option<String>) -> Result<()> {
log::debug!(
"Executing cmd_update with yes={}, component={:?}",
yes,
component
);
let config = config::Config::load()?;
let mut keyring = keyring::Keyring::load()?;
let mut client = client::TufClient::new(&config)?;
println!("Checking for updates...");
client.refresh_metadata()?;
// Verify channel key trust
let channel_key = client.get_channel_key(&config.channel)?;
let trust_status = keyring.check_trust(&config.channel, &channel_key.key_id);
println!(
"Channel '{}' trust status: {:?}",
config.channel, trust_status
);
match &trust_status {
keyring::TrustStatus::TrustedDefault => {
println!(
" Channel '{}' signed by: {} (default key)",
config.channel,
&channel_key.key_id[..16]
);
}
keyring::TrustStatus::TrustedUser => {
println!(
" Channel '{}' signed by: {} (user approved)",
config.channel,
&channel_key.key_id[..16]
);
}
keyring::TrustStatus::Unknown => {
println!(
"\n⚠ WARNING: Channel '{}' key not in trusted keyring",
config.channel
);
println!(" Key ID: {}", channel_key.key_id);
println!(" Public Key: {}...", &channel_key.public_key[..48]);
println!("\n This key was verified by the TUF root certificate,");
println!(" but you haven't explicitly trusted it yet.");
if !yes && !prompt_yes_no("\nTrust this key and continue?")? {
println!("Update cancelled by user");
return Ok(());
}
keyring.add_trusted(
&config.channel,
&channel_key,
keyring::TrustLevel::UserApproved,
)?;
println!(
"✓ Channel key for '{}' added to trusted keyring",
config.channel
);
}
keyring::TrustStatus::KeyMismatch { expected, actual } => {
println!("\n🚨 WARNING: Channel key has changed!");
println!(" Expected: {}", expected);
println!(" Received: {}", actual);
if !yes {
println!("\nThis could indicate a security issue. Contact channel maintainer.");
if !prompt_yes_no("Accept new key anyway?")? {
println!("Update cancelled by user due to key mismatch");
return Ok(());
}
}
keyring.add_trusted(
&config.channel,
&channel_key,
keyring::TrustLevel::UserApproved,
)?;
println!(
"✓ New channel key for '{}' accepted and added to trusted keyring",
config.channel
);
}
keyring::TrustStatus::Pending => {
println!("\n⚠ Channel key pending approval");
if !yes && !prompt_yes_no("Approve this key and continue?")? {
println!("Update cancelled by user");
return Ok(());
}
keyring.approve(&config.channel)?;
println!("✓ Channel key for '{}' approved", config.channel);
}
}
let updates = client.check_for_updates(&config.channel)?;
// Filter by component if specified
let updates: Vec<_> = if let Some(ref comp) = component {
println!("Filtering updates for component: {}", comp);
updates
.into_iter()
.filter(|u| &u.component == comp)
.collect()
} else {
updates
_ => unreachable!("Please pass a subcommand"),
};
if updates.is_empty() {
println!("\n✓ System is up to date");
return Ok(());
if let Err(ref e) = result {
eprintln!("Error: {}", e);
exit(1);
}
}
println!("\nUpdates to install:");
let mut total_size: u64 = 0;
for update in &updates {
println!(
" {} {}{}",
update.component, update.current_version, update.new_version
);
if let Some(size) = update.download_size {
total_size += size;
}
fn require_root() -> anyhow::Result<()> {
if !nix::unistd::geteuid().is_root() {
// Using anyhow::bail! is a concise way to return an error.
anyhow::bail!("This command requires root privileges. Please try again with sudo.");
}
println!(
"\nTotal download: {:.1} MB",
total_size as f64 / 1_048_576.0
);
if !yes && !prompt_yes_no("\nProceed with update?")? {
println!("Update cancelled by user");
return Ok(());
}
println!("Initiating download and installation...");
for update in &updates {
println!("Downloading {}...", update.component);
let path = client.download_target(&config.channel, &update.target_path)?;
println!(" ✓ Downloaded to {}", path.display());
println!("Installing {}...", update.component);
install_update(&update.component, &path)?;
println!(" ✓ Installed");
}
println!("\n═══════════════════════════════════════════════════════════");
println!(" ✓ UPDATE COMPLETE");
println!(" Reboot to apply changes");
println!("═══════════════════════════════════════════════════════════");
Ok(())
}
fn cmd_status() -> Result<()> {
log::debug!("Executing cmd_status");
let config = config::Config::load()?;
let os_release_info = config::parse_conf_file(Path::new("/etc/os-release")).unwrap_or_default();
println!("Citadel Update Status");
println!("═══════════════════════════════════════════════════════════");
println!("System Information:");
println!(
" Distro: {} {}",
os_release_info
.get("NAME")
.unwrap_or(&"Unknown".to_string()),
os_release_info
.get("VERSION_ID")
.unwrap_or(&"Unknown".to_string())
);
println!(" Channel: {}", config.channel);
println!("Update Server:");
println!(" URL: {}", config.repository_url());
Ok(())
}
fn cmd_channel_show() -> Result<()> {
log::debug!("Executing cmd_channel_show");
let config = config::Config::load()?;
let keyring = keyring::Keyring::load()?;
println!("Current channel: {}", config.channel);
if let Some(key) = keyring.get_key(&config.channel) {
println!(" Key ID: {}", key.key_id);
println!(" Trust: {:?}", key.trust_level);
} else {
println!(" No key found for current channel in keyring.");
}
Ok(())
}
fn cmd_channel_list() -> Result<()> {
log::debug!("Executing cmd_channel_list");
let config = config::Config::load()?;
let mut client = client::TufClient::new(&config)?;
let keyring = keyring::Keyring::load()?;
client.refresh_metadata()?;
let channels = client.list_channels()?;
println!("Available channels:");
println!("───────────────────────────────────────────────────────────");
if channels.is_empty() {
println!(" No channels found.");
} else {
for channel in channels {
let current = if channel.name == config.channel {
" (current)"
} else {
""
};
let trust = match keyring.get_key(&channel.name) {
Some(k) => match k.trust_level {
keyring::TrustLevel::Default => "✓ default",
keyring::TrustLevel::UserApproved => "✓ trusted",
keyring::TrustLevel::Pending => "○ pending",
},
None => "○ unknown",
};
println!(
" {:<12} {} [{}]{}",
channel.name,
&channel.key_id[..16],
trust,
current
);
}
}
Ok(())
}
fn cmd_channel_set(channel: &str) -> Result<()> {
log::debug!("Executing cmd_channel_set for channel '{}'", channel);
let mut config = config::Config::load()?;
let mut keyring = keyring::Keyring::load()?;
let mut client = client::TufClient::new(&config)?;
println!("Switching to channel '{}'...", channel);
// Refresh metadata to get channel info
client.refresh_metadata()?;
// Get the channel's signing key
let channel_key = client
.get_channel_key(channel)
.with_context(|| format!("Channel '{}' not found", channel))?;
let trust_status = keyring.check_trust(channel, &channel_key.key_id);
println!("Channel '{}' trust status: {:?}", channel, trust_status);
match trust_status {
keyring::TrustStatus::TrustedDefault => {
println!(
"\n Key: {} (default, shipped with OS)",
&channel_key.key_id[..16]
);
}
keyring::TrustStatus::TrustedUser => {
println!(
"\n Key: {} (previously approved)",
&channel_key.key_id[..16]
);
}
keyring::TrustStatus::Pending => {
println!("\n════════════════════════════════════════════════════════");
println!(" PENDING KEY APPROVAL");
println!("════════════════════════════════════════════════════════");
println!("\n Channel: {}", channel);
println!(" Key ID: {}", channel_key.key_id);
println!("\n This key was fetched but not yet approved.\n");
if !prompt_yes_no("Approve this key?")? {
println!("Channel switch cancelled by user");
return Ok(());
}
keyring.approve(channel)?;
println!("Channel key for '{}' approved", channel);
}
keyring::TrustStatus::Unknown => {
println!("\n════════════════════════════════════════════════════════");
println!(" NEW CHANNEL KEY");
println!("════════════════════════════════════════════════════════");
println!("\n Channel: {}", channel);
println!(" Key ID: {}", channel_key.key_id);
println!(" Key: {}...", &channel_key.public_key[..48]);
println!("\n ⚠ This key is not in your trusted keyring.");
println!(" Only trust keys from verified sources.\n");
if !prompt_yes_no("Trust this key?")? {
println!("Channel switch cancelled by user");
return Ok(());
}
keyring.add_trusted(channel, &channel_key, keyring::TrustLevel::UserApproved)?;
println!(
"New channel key for '{}' trusted and added to keyring",
channel
);
}
keyring::TrustStatus::KeyMismatch { expected, actual } => {
println!("\n════════════════════════════════════════════════════════");
println!(" 🚨 KEY MISMATCH WARNING");
println!("════════════════════════════════════════════════════════");
println!("\n Channel: {}", channel);
println!(" Expected: {}", expected);
println!(" Received: {}", actual);
println!("\n The signing key for this channel has changed.");
println!(" This could indicate:");
println!(" - Legitimate key rotation");
println!(" - A potential security issue");
println!("\n Verify with channel maintainer before accepting.\n");
if !prompt_yes_no("Accept new key?")? {
println!("Channel switch cancelled by user due to key mismatch");
return Ok(());
}
keyring.add_trusted(channel, &channel_key, keyring::TrustLevel::UserApproved)?;
println!(
"New key for channel '{}' accepted despite mismatch",
channel
);
}
}
// Save new channel to config
config.channel = channel.to_string();
config.save()?;
println!("\n✓ Channel switched to '{}'", channel);
Ok(())
}
fn cmd_keyring_list() -> Result<()> {
println!("Executing cmd_keyring_list");
let keyring = keyring::Keyring::load()?;
println!("Trusted Channel Keys");
println!("═══════════════════════════════════════════════════════════");
if keyring.trusted_keys.is_empty() {
println!(" No keys in keyring");
return Ok(());
}
for (channel, key) in &keyring.trusted_keys {
let trust = match key.trust_level {
keyring::TrustLevel::Default => "[default]",
keyring::TrustLevel::UserApproved => "[user] ",
keyring::TrustLevel::Pending => "[pending]",
};
let comment = key.comment.as_deref().unwrap_or("");
println!(
" {:<12} {} {} {}",
channel,
&key.key_id[..16],
trust,
comment
);
}
Ok(())
}
fn cmd_keyring_show(channel: &str) -> Result<()> {
log::debug!("Executing cmd_keyring_show for channel '{}'", channel);
let keyring = keyring::Keyring::load()?;
match keyring.get_key(channel) {
Some(key) => {
println!("Channel: {}", channel);
println!("═══════════════════════════════════════════════════════════");
println!(" Key ID: {}", key.key_id);
println!(" Public Key: {}", key.public_key);
println!(" Trust: {:?}", key.trust_level);
println!(" Added: {}", key.added_at);
if let Some(comment) = &key.comment {
println!(" Comment: {}", comment);
}
}
None => {
println!("No key found for channel '{}'", channel);
println!("Use 'citadel-fetch keyring add {}' to add it", channel);
}
}
Ok(())
}
fn cmd_keyring_add(channel: &str) -> Result<()> {
log::debug!("Executing cmd_keyring_add for channel '{}'", channel);
let config = config::Config::load()?;
let mut keyring = keyring::Keyring::load()?;
let mut client = client::TufClient::new(&config)?;
println!("Fetching key for channel '{}'...", channel);
client.refresh_metadata()?;
let channel_key = client
.get_channel_key(channel)
.with_context(|| format!("Channel '{}' not found", channel))?;
println!("\n Channel: {}", channel);
println!(" Key ID: {}", channel_key.key_id);
println!(" Key: {}...", &channel_key.public_key[..48]);
if keyring.get_key(channel).is_some() {
println!("\n ⚠ A key for this channel already exists.");
if !prompt_yes_no("Replace existing key?")? {
println!("Key add cancelled by user");
return Ok(());
}
}
if !prompt_yes_no("\nTrust this key?")? {
println!("Key add cancelled by user");
return Ok(());
}
keyring.add_trusted(channel, &channel_key, keyring::TrustLevel::UserApproved)?;
println!("\n✓ Key added to keyring");
Ok(())
}
fn cmd_keyring_remove(channel: &str) -> Result<()> {
log::debug!("Executing cmd_keyring_remove for channel '{}'", channel);
let mut keyring = keyring::Keyring::load()?;
if keyring.get_key(channel).is_none() {
println!("No key found for channel '{}'", channel);
return Ok(());
}
if keyring.is_default(channel) {
println!("⚠ '{}' is a default key shipped with the OS.", channel);
println!(" Removing it may prevent updates from this channel.");
}
if !prompt_yes_no(&format!("Remove key for '{}'?", channel))? {
println!("Key removal cancelled by user");
return Ok(());
}
keyring.remove(channel)?;
println!("✓ Key removed");
Ok(())
}
fn cmd_keyring_discover() -> Result<()> {
log::debug!("Executing cmd_keyring_discover");
let config = config::Config::load()?;
let mut client = client::TufClient::new(&config)?;
let keyring = keyring::Keyring::load()?;
println!("Discovering channels from {}...\n", config.base_url);
client.refresh_metadata()?;
let channels = client.list_channels()?;
println!("Available Channels");
println!("═══════════════════════════════════════════════════════════");
if channels.is_empty() {
println!(" No channels found in repository.");
} else {
for channel in channels {
let status = match keyring.get_key(&channel.name) {
Some(k) => match k.trust_level {
keyring::TrustLevel::Default => "✓ trusted (default)",
keyring::TrustLevel::UserApproved => "✓ trusted (user)",
keyring::TrustLevel::Pending => "○ pending approval",
},
None => "○ not in keyring",
};
println!(
" {:<12} {} {}",
channel.name,
&channel.key_id[..16],
status
);
}
}
println!("\nAdd a key: citadel-fetch keyring add <channel>");
Ok(())
}
fn cmd_refresh() -> Result<()> {
log::debug!("Executing cmd_refresh");
let config = config::Config::load()?;
let mut client = client::TufClient::new(&config)?;
println!("Refreshing TUF metadata...");
client.refresh_metadata()?;
println!("✓ Metadata refreshed");
Ok(())
}
fn prompt_yes_no(prompt: &str) -> Result<bool> {
use std::io::{self, Write};
print!("{} (y/N): ", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let answer = input.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
fn install_update(component: &str, image_path: &std::path::Path) -> Result<()> {
// This integrates with libcitadel's image installation
// For now, just copy to the appropriate location
use std::process::Command;
let dest = match component {
"rootfs" => "/run/citadel/images/citadel-rootfs.img",
"kernel" => "/run/citadel/images/citadel-kernel.img",
"extra" => "/run/citadel/images/citadel-extra.img",
_ => return Err(anyhow::anyhow!("Unknown component: {}", component)),
};
// Use citadel-image to install
let status = Command::new("/usr/bin/citadel-image")
.args(["install", &image_path.to_string_lossy(), dest])
.status()?;
if !status.success() {
anyhow::bail!("Failed to install {} image", component);
}
Ok(())
}

View File

@@ -1,11 +1,10 @@
use clap::{command, ArgAction, Command};
use clap::{Arg, ArgMatches};
use hex;
use std::path::Path;
use std::process::exit;
use clap::{Arg,ArgMatches};
use clap::{command, ArgAction, Command};
use hex;
use libcitadel::public_key_for_channel;
use libcitadel::{util, ImageHeader, KeyPair, Partition, ResourceImage, Result};
use libcitadel::{Result, ResourceImage, Logger, LogLevel, Partition, KeyPair, ImageHeader, util};
pub fn main() {
let matches = command!()
@@ -104,7 +103,7 @@ pub fn main() {
fn info(arg_matches: &ArgMatches) -> Result<()> {
let img = load_image(arg_matches)?;
print!("{}", String::from_utf8(img.header().metainfo_bytes())?);
print!("{}",String::from_utf8(img.header().metainfo_bytes())?);
info_signature(&img)?;
Ok(())
}
@@ -114,22 +113,22 @@ fn info_signature(img: &ResourceImage) -> Result<()> {
println!("Signature: {}", hex::encode(&img.header().signature()));
} else {
println!("Signature: No Signature");
return Ok(());
}
let pubkey = public_key_for_channel(img.metainfo().channel())?;
if img.header().verify_signature(pubkey) {
println!("Signature is valid");
} else {
println!("Signature verify FAILED");
match img.header().public_key() {
Ok(pubkey) => {
if img.header().verify_signature(pubkey) {
println!("Signature is valid");
} else {
println!("Signature verify FAILED");
}
},
Err(_) => { println!("No public key found for channel '{}'", img.metainfo().channel()) },
}
Ok(())
Ok(())
}
fn metainfo(arg_matches: &ArgMatches) -> Result<()> {
let img = load_image(arg_matches)?;
print!("{}", String::from_utf8(img.header().metainfo_bytes())?);
print!("{}",String::from_utf8(img.header().metainfo_bytes())?);
Ok(())
}
@@ -168,8 +167,7 @@ fn verify_shasum(arg_matches: &ArgMatches) -> Result<()> {
}
fn load_image(arg_matches: &ArgMatches) -> Result<ResourceImage> {
let path = arg_matches
.get_one::<String>("path")
let path = arg_matches.get_one::<String>("path")
.expect("path argument missing");
if !Path::new(path).exists() {
@@ -185,7 +183,7 @@ fn load_image(arg_matches: &ArgMatches) -> Result<ResourceImage> {
fn install_rootfs(arg_matches: &ArgMatches) -> Result<()> {
if arg_matches.get_flag("choose") {
let _ = choose_install_partition(true)?;
return Ok(());
return Ok(())
}
let img = load_image(arg_matches)?;
@@ -224,18 +222,14 @@ fn sign_image(arg_matches: &ArgMatches) -> Result<()> {
}
fn install_image(arg_matches: &ArgMatches) -> Result<()> {
let source = arg_matches
.get_one::<String>("path")
let source = arg_matches.get_one::<String>("path")
.expect("path argument missing");
let img = load_image(arg_matches)?;
let _hdr = img.header();
let metainfo = img.metainfo();
// Use existing function from libcitadel
let pubkey = public_key_for_channel(metainfo.channel())?;
if !img.header().verify_signature(pubkey) {
bail!("Image signature verification failed");
}
// XXX verify signature?
if !(metainfo.image_type() == "kernel" || metainfo.image_type() == "extra") {
bail!("Cannot install image type {}", metainfo.image_type());
@@ -256,20 +250,13 @@ fn install_image(arg_matches: &ArgMatches) -> Result<()> {
if kernel_version.chars().any(|c| c == '/') {
bail!("Kernel version field has / char");
}
format!(
"citadel-kernel-{}-{}.img",
kernel_version,
metainfo.version()
)
format!("citadel-kernel-{}-{}.img", kernel_version, metainfo.version())
} else {
format!("citadel-extra-{}.img", metainfo.version())
};
if !metainfo.channel().chars().all(|c| c.is_ascii_lowercase()) {
bail!(
"Refusing to build path from strange channel name {}",
metainfo.channel()
);
bail!("Refusing to build path from strange channel name {}", metainfo.channel());
}
let image_dir = Path::new("/storage/resources").join(metainfo.channel());
let image_dest = image_dir.join(filename);

View File

@@ -12,7 +12,6 @@ use libcitadel::RealmFS;
use libcitadel::Result;
use libcitadel::OsRelease;
use libcitadel::KeyRing;
use libcitadel::ResourceImage;
use libcitadel::terminal::Base16Scheme;
use libcitadel::UtsName;
@@ -480,29 +479,11 @@ impl Installer {
}
fn setup_storage_resources(&self) -> Result<()> {
// Get the channel from the extra image metadata rather than OsRelease
// because during installation in live mode, OsRelease reads from initramfs
// which doesn't have CITADEL_CHANNEL set, but the images have it in metadata
let extra_path = self.artifact_path(EXTRA_IMAGE_NAME);
let channel = if extra_path.exists() {
match ResourceImage::from_path(&extra_path) {
Ok(img) => img.metainfo().channel().to_string(),
Err(_) => {
// Fallback to OsRelease if we can't read the image
OsRelease::citadel_channel()
.ok_or_else(|| {
format_err!("Failed to determine channel from extra image or OsRelease")
})?
.to_string()
}
}
} else {
OsRelease::citadel_channel()
.ok_or_else(|| format_err!("Failed to determine channel from OsRelease"))?
.to_string()
let channel = match OsRelease::citadel_channel() {
Some(channel) => channel,
None => "dev",
};
let resources = self.storage().join("resources").join(&channel);
let resources = self.storage().join("resources").join(channel);
util::create_dir(&resources)?;
self.sparse_copy_artifact(EXTRA_IMAGE_NAME, &resources)?;

View File

@@ -1,18 +1,14 @@
#[macro_use]
extern crate libcitadel;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate lazy_static;
#[macro_use] extern crate libcitadel;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate lazy_static;
use libcitadel::RealmManager;
use std::env;
use std::path::Path;
use std::ffi::OsStr;
use std::iter;
use std::path::Path;
use libcitadel::RealmManager;
mod boot;
mod fetch;
mod image;
mod install;
mod install_backend;
@@ -20,13 +16,14 @@ mod mkimage;
mod realmfs;
mod sync;
mod update;
mod fetch;
fn main() {
let exe = match env::current_exe() {
Ok(path) => path,
Err(_e) => {
return;
}
},
};
let args = env::args().collect::<Vec<String>>();
@@ -66,7 +63,6 @@ fn dispatch_command(args: Vec<String>) {
"image" => image::main(),
"realmfs" => realmfs::main(rebuild_args("citadel-realmfs", args)),
"update" => update::main(rebuild_args("citadel-update", args)),
"fetch" => fetch::main(),
"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

@@ -1,13 +1,13 @@
use std::fs::OpenOptions;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::PathBuf;
use std::fs::OpenOptions;
use std::fs::{self,File};
use std::io::{self,Write};
use libcitadel::{devkeys, keypair_for_channel_signing, util, ImageHeader, Result};
use libcitadel::{Result, ImageHeader, devkeys, util, keypair_for_channel_signing};
use super::config::BuildConfig;
use libcitadel::verity::Verity;
use std::path::Path;
use libcitadel::verity::Verity;
pub struct UpdateBuilder {
config: BuildConfig,
@@ -19,12 +19,15 @@ pub struct UpdateBuilder {
verity_root: Option<String>,
}
const BLOCK_SIZE: usize = 4096;
fn align(sz: usize, n: usize) -> usize {
(sz + (n - 1)) & !(n - 1)
}
impl UpdateBuilder {
pub fn new(config: BuildConfig) -> UpdateBuilder {
let image_data = config.workdir_path(UpdateBuilder::build_filename(&config));
UpdateBuilder {
@@ -35,29 +38,15 @@ impl UpdateBuilder {
}
fn target_filename(&self) -> String {
format!(
"citadel-{}-{}-{}.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-{}-{}-{}",
config.image_type(),
config.channel(),
config.version()
)
format!("citadel-{}-{}-{}", config.image_type(), config.channel(), config.version())
}
fn verity_filename(&self) -> String {
format!(
"verity-hash-{}-{}",
self.config.image_type(),
self.config.version()
)
format!("verity-hash-{}-{}", self.config.image_type(), self.config.version())
}
pub fn build(&mut self) -> Result<()> {
@@ -215,7 +204,7 @@ impl UpdateBuilder {
if generated_signature_bytes.iter().all(|&b| b == 0) {
bail!("Generated signature is all zeros. Signing failed!");
}
hdr.set_signature(&generated_signature_bytes);
hdr.set_signature(generated_signature_bytes);
}
Ok(hdr)
}
@@ -245,29 +234,8 @@ impl UpdateBuilder {
writeln!(v, "timestamp = \"{}\"", self.config.timestamp())?;
writeln!(v, "nblocks = {}", self.nblocks.unwrap())?;
writeln!(v, "shasum = \"{}\"", self.shasum.as_ref().unwrap())?;
writeln!(
v,
"verity-salt = \"{}\"",
self.verity_salt.as_ref().unwrap()
)?;
writeln!(
v,
"verity-root = \"{}\"",
self.verity_root.as_ref().unwrap()
)?;
if let Some(path) = self.config.public_key_path() {
if let Ok(key) = fs::read_to_string(path) {
writeln!(v, "public-key = \"{}\"", key.trim())?;
}
}
if let Some(path) = self.config.certificate_path() {
if let Ok(cert) = fs::read_to_string(path) {
writeln!(v, "authorizing-signature = \"{}\"", cert.trim())?;
}
}
writeln!(v, "verity-salt = \"{}\"", self.verity_salt.as_ref().unwrap())?;
writeln!(v, "verity-root = \"{}\"", self.verity_root.as_ref().unwrap())?;
Ok(v)
}
}

View File

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf};
use toml;
use libcitadel::{Result, util};
#[derive(Deserialize)]
@@ -23,12 +25,6 @@ pub struct BuildConfig {
#[serde(rename = "private-key-path")]
private_key_path: Option<String>,
#[serde(rename = "public-key-path")]
public_key_path: Option<String>,
#[serde(rename = "certificate-path")]
certificate_path: Option<String>,
#[serde(skip)]
basedir: PathBuf,
#[serde(skip)]
@@ -53,43 +49,6 @@ impl BuildConfig {
Some(ref version) => format!("{}-{}", &config.image_type, version),
None => config.image_type.to_owned(),
};
// Auto-detect private key path if not specified
if config.private_key_path.is_none() && config.channel != "dev" {
let default_key_path = format!("/usr/share/citadel/keys/{}_image.key", config.channel);
info!("No private-key-path specified, using default: {}", default_key_path);
config.private_key_path = Some(default_key_path);
}
// Auto-detect public key and certificate paths if not specified
if config.public_key_path.is_none() && config.channel != "dev" {
// First check relative to config file (for build machine)
let local_path = config.basedir.join(format!("{}_image.pub", config.channel));
if local_path.exists() {
config.public_key_path = Some(local_path.to_string_lossy().into_owned());
} else {
// Fallback to runtime path
let path = format!("/usr/share/citadel/keys/{}_image.pub", config.channel);
if Path::new(&path).exists() {
config.public_key_path = Some(path);
}
}
}
if config.certificate_path.is_none() && config.channel != "dev" {
// First check relative to config file (for build machine)
let local_path = config.basedir.join(format!("{}_image.cert", config.channel));
if local_path.exists() {
config.certificate_path = Some(local_path.to_string_lossy().into_owned());
} else {
// Fallback to runtime path
let path = format!("/usr/share/citadel/keys/{}_image.cert", config.channel);
if Path::new(&path).exists() {
config.certificate_path = Some(path);
}
}
}
Ok(config)
}
@@ -165,12 +124,4 @@ impl BuildConfig {
pub fn private_key_path(&self) -> Option<&str> {
self.private_key_path.as_ref().map(|s| s.as_str())
}
pub fn public_key_path(&self) -> Option<&str> {
self.public_key_path.as_ref().map(|s| s.as_str())
}
pub fn certificate_path(&self) -> Option<&str> {
self.certificate_path.as_ref().map(|s| s.as_str())
}
}

View File

@@ -24,8 +24,6 @@ anyhow = "1.0"
clap = "4.5"
tempfile = "3.21"
semver = "1.0"
sha2 = "0.10"
ed25519-dalek = { version = "2.1", features = ["pkcs8"] }
[dependencies.inotify]
version = "0.8"

View File

@@ -383,32 +383,7 @@ impl ImageHeader {
}
pub fn public_key(&self) -> Result<PublicKey> {
let metainfo = self.metainfo();
// 1. Try Hierarchical Verification if fields are present
if let (Some(pk_hex), Some(sig_hex)) = (metainfo.public_key(), metainfo.authorizing_signature()) {
let root_pubkey = match crate::root_image_public_key() {
Ok(rk) => rk,
Err(e) => {
warn!("Could not load Root Image Key for hierarchical verification: {}", e);
return public_key_for_channel(metainfo.channel());
}
};
let channel_pubkey = PublicKey::from_hex(pk_hex)?;
let sig_bytes = hex::decode(sig_hex).map_err(context!("Invalid authorizing-signature hex"))?;
let pk_bytes = hex::decode(pk_hex).map_err(context!("Invalid public-key hex"))?;
if root_pubkey.verify(&pk_bytes, &sig_bytes) {
info!("Channel key authorization SUCCESS...");
return Ok(channel_pubkey);
} else {
bail!("Security Critical: Channel key signature verification FAILED. Aborting.");
}
}
// 2. Fallback to standard channel-based lookup (key must be on disk)
public_key_for_channel(metainfo.channel())
public_key_for_channel(self.metainfo().channel())
}
pub fn verify_signature(&self, pubkey: PublicKey) -> bool {
@@ -494,12 +469,6 @@ pub struct MetaInfo {
#[serde(default, rename = "verity-root")]
verity_root: String,
#[serde(rename = "public-key")]
public_key: Option<String>,
#[serde(rename = "authorizing-signature")]
authorizing_signature: Option<String>,
}
impl MetaInfo {
@@ -566,13 +535,5 @@ impl MetaInfo {
pub fn verity_tag(&self) -> &str {
&self.verity_root()[..8]
}
pub fn public_key(&self) -> Option<&str> {
self.public_key.as_deref()
}
pub fn authorizing_signature(&self) -> Option<&str> {
self.authorizing_signature.as_deref()
}
}

View File

@@ -35,13 +35,8 @@ impl PublicKey {
}
pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
let sig = match sign::Signature::try_from(signature) {
Ok(s) => s,
Err(_) => {
warn!("Invalid signature length: {}", signature.len());
return false;
}
};
let sig = sign::Signature::try_from(signature)
.expect("Signature::from_slice() failed");
let is_valid = sign::verify_detached(&sig, data, &self.0);
if !is_valid {

View File

@@ -66,11 +66,6 @@ pub fn keypair_for_channel_signing(private_key_path: &Path) -> KeyPair {
}
pub fn public_key_for_channel(channel: &str) -> Result<PublicKey> {
// Validate input first
if !channel.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
bail!("Invalid channel name: {}", channel);
}
// Kernel command line override for developers
if Some(channel) == CommandLine::channel_name() {
if let Some(hex) = CommandLine::channel_pubkey() {
@@ -79,7 +74,7 @@ pub fn public_key_for_channel(channel: &str) -> Result<PublicKey> {
}
}
let key_path = Path::new("/usr/share/citadel/keys/").join(format!("{}_image.pub", channel));
let key_path = Path::new("/usr/share/citadel/keys/").join(format!("{}.pub", channel));
if !key_path.exists() {
if channel == "dev" {
@@ -95,16 +90,6 @@ pub fn public_key_for_channel(channel: &str) -> Result<PublicKey> {
Ok(pubkey)
}
pub fn root_image_public_key() -> Result<PublicKey> {
let key_path = Path::new("/usr/share/citadel/keys/root_image.pub");
if !key_path.exists() {
bail!("Root image public key not found at {}", key_path.display());
}
let hex_key = fs::read_to_string(&key_path)
.map_err(context!("could not read root public key from {}", key_path.display()))?;
PublicKey::from_hex(hex_key.trim())
}
pub use error::{Result,Error};
pub const BLOCK_SIZE: usize = 4096;

View File

@@ -1,6 +1,7 @@
use std::ffi::OsStr;
use std::fs;
use std::io::Write;
use std::os::unix::fs::MetadataExt;
use std::path::{Path,PathBuf};
use std::sync::{Arc, Weak, RwLock};

View File

@@ -42,21 +42,7 @@ impl ResourceImage {
info!("Searching run directory for image {} with channel {}", image_type, channel);
let run_channel = if CommandLine::live_mode() || CommandLine::install_mode() {
info!("Live/Install mode: searching {} without channel filter", RUN_DIRECTORY);
None
} else {
info!("Normal mode: searching {} with channel filter: {}", RUN_DIRECTORY, channel);
Some(channel)
};
// In live/install mode, skip strict kernel version/id matching since we're
// using whatever kernel image was provided on the boot media
let skip_kernel_match = CommandLine::live_mode() || CommandLine::install_mode();
info!("Searching in {}", RUN_DIRECTORY);
if let Some(image) = search_directory(RUN_DIRECTORY, image_type, run_channel, skip_kernel_match)? {
info!("Found image in {}: {}", RUN_DIRECTORY, image.path().display());
if let Some(image) = search_directory(RUN_DIRECTORY, image_type, Some(&channel))? {
return Ok(image);
}
@@ -66,9 +52,8 @@ impl ResourceImage {
let storage_path = Path::new(STORAGE_BASEDIR).join(&channel);
if let Some(image) = search_directory(&storage_path, image_type, Some(&channel), false)? {
info!("Found image in storage: {}", image.path().display());
return Ok(image);
if let Some(image) = search_directory(storage_path, image_type, Some(&channel))? {
return Ok(image);
}
bail!("failed to find resource image of type: {}", image_type)
@@ -81,7 +66,7 @@ impl ResourceImage {
/// Locate a rootfs image in /run/citadel/images and return it
pub fn find_rootfs() -> Result<Self> {
match search_directory(RUN_DIRECTORY, "rootfs", None, false)? {
match search_directory(RUN_DIRECTORY, "rootfs", None)? {
Some(image) => Ok(image),
None => bail!("failed to find rootfs resource image"),
}
@@ -361,13 +346,6 @@ impl ResourceImage {
// If the /storage directory is not mounted, attempt to mount it.
// Return true if already mounted or if the attempt to mount it succeeds.
pub fn ensure_storage_mounted() -> Result<bool> {
// In live/install mode, storage is a tmpfs and /dev/mapper/citadel-storage
// doesn't exist. All resources should be in /run/citadel/images.
if CommandLine::live_mode() || CommandLine::install_mode() {
info!("Live/Install mode: skipping storage mount (using tmpfs)");
return Ok(false);
}
if Mounts::is_source_mounted("/dev/mapper/citadel-storage")? {
return Ok(true);
}
@@ -389,29 +367,11 @@ impl ResourceImage {
}
fn rootfs_channel() -> &'static str {
let cmdline_channel = CommandLine::channel_name();
info!("CommandLine::channel_name() = {:?}", cmdline_channel);
match cmdline_channel {
Some(channel) => {
info!("Using channel from kernel command line: {}", channel);
channel
},
None => {
let osrelease_channel = OsRelease::citadel_channel();
info!("OsRelease::citadel_channel() = {:?}", osrelease_channel);
match osrelease_channel {
Some(channel) => {
info!("Using channel from OsRelease: {}", channel);
channel
},
None => {
info!("No channel found, defaulting to 'dev'");
"dev"
}
}
match CommandLine::channel_name() {
Some(channel) => channel,
None => match OsRelease::citadel_channel() {
Some(channel) => channel,
None => "dev",
},
}
}
@@ -422,24 +382,15 @@ impl ResourceImage {
// in the image header metainfo. If multiple matches are found, return the image
// with the highest version number. If multiple images have the same highest version
// number, return the image with the newest file creation time.
// If skip_kernel_match is true, kernel version/id matching is skipped for kernel images.
fn search_directory<P: AsRef<Path>>(dir: P, image_type: &str, channel: Option<&str>, skip_kernel_match: bool) -> Result<Option<ResourceImage>> {
info!("search_directory: dir={}, image_type={}, channel={:?}, skip_kernel_match={}",
dir.as_ref().display(), image_type, channel, skip_kernel_match);
fn search_directory<P: AsRef<Path>>(dir: P, image_type: &str, channel: Option<&str>) -> Result<Option<ResourceImage>> {
if !dir.as_ref().exists() {
return Ok(None)
}
let mut best = None;
let mut matches = all_matching_images(dir.as_ref(), image_type, channel, skip_kernel_match)?;
info!("Found {} matching images in {}", matches.len(), dir.as_ref().display());
for (i, img) in matches.iter().enumerate() {
info!(" Match {}: {} (channel: {}, version: {})",
i, img.path().display(), img.metainfo().channel(), img.metainfo().version());
}
let mut matches = all_matching_images(dir.as_ref(), image_type, channel)?;
debug!("Found {} matching images", matches.len());
if channel.is_none() {
if matches.is_empty() {
@@ -505,21 +456,17 @@ fn current_kernel_version() -> String {
//
// Read a directory search for ResourceImages which match the channel
// and image_type. If skip_kernel_match is true, skip kernel version/id matching.
// and image_type.
//
fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>, skip_kernel_match: bool) -> Result<Vec<ResourceImage>> {
fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>) -> Result<Vec<ResourceImage>> {
let kernel_version = current_kernel_version();
let kv = if image_type == "kernel" && !skip_kernel_match {
let kv = if image_type == "kernel" {
Some(kernel_version.as_str())
} else {
None
};
let kernel_id = if !skip_kernel_match {
OsRelease::citadel_kernel_id()
} else {
None
};
let kernel_id = OsRelease::citadel_kernel_id();
let mut v = Vec::new();
util::read_directory(dir, |dent| {
@@ -531,9 +478,8 @@ fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>, skip
// Examine a directory entry to determine if it is a resource image which
// matches a given channel and image_type. If the image_type is "kernel"
// then also match the kernel-version and kernel-id fields (unless those
// parameters are None, in which case skip version/id checking).
// If channel is None then don't consider the channel in the match.
// then also match the kernel-version and kernel-id fields. If channel
// is None then don't consider the channel in the match.
//
// If the entry is a match, then instantiate a ResourceImage and add it to
// the images vector.
@@ -545,12 +491,9 @@ fn maybe_add_dir_entry(entry: &DirEntry,
images: &mut Vec<ResourceImage>) -> Result<()> {
let path = entry.path();
info!("Examining directory entry: {}", path.display());
let meta = entry.metadata()
.map_err(context!("failed to read metadata for {:?}", entry.path()))?;
if !meta.is_file() {
info!(" Skipping: not a regular file");
if !meta.is_file() || meta.len() < ImageHeader::HEADER_SIZE as u64 {
return Ok(())
}
let header = match ImageHeader::from_file(&path) {
@@ -562,38 +505,25 @@ fn maybe_add_dir_entry(entry: &DirEntry,
};
if !header.is_magic_valid() {
info!(" Skipping: invalid magic header");
return Ok(())
}
let metainfo = header.metainfo();
info!("Found an image type={} channel={} kernel={:?}", metainfo.image_type(), metainfo.channel(), metainfo.kernel_version());
debug!("Found an image type={} channel={} kernel={:?}", metainfo.image_type(), metainfo.channel(), metainfo.kernel_version());
if let Some(channel) = channel {
if metainfo.channel() != channel {
info!(" Skipping: channel mismatch (want {}, got {})", channel, metainfo.channel());
return Ok(());
}
}
if image_type != metainfo.image_type() {
info!(" Skipping: image_type mismatch (want {}, got {})", image_type, metainfo.image_type());
return Ok(())
}
// Only check kernel version/id if they are specified (Some)
// kernel_version must match if specified
// kernel_id must match only if specified (if None, any kernel_id is acceptable)
if image_type == "kernel" && kernel_version.is_some() {
let version_matches = metainfo.kernel_version() == kernel_version;
let id_matches = kernel_id.is_none() || metainfo.kernel_id() == kernel_id;
if !version_matches || !id_matches {
info!(" Skipping: kernel version/id mismatch (want version={:?}, id={:?}; got version={:?}, id={:?})",
kernel_version, kernel_id, metainfo.kernel_version(), metainfo.kernel_id());
return Ok(());
}
if image_type == "kernel" && (metainfo.kernel_version() != kernel_version || metainfo.kernel_id() != kernel_id) {
return Ok(());
}
images.push(ResourceImage::new(&path, header));

View File

@@ -6,7 +6,7 @@ use std::slice::Iter;
pub const UPDATE_SERVER_HOSTNAME: &str = "update.subgraph.com";
const CITADEL_CONFIG_PATH: &str = "/storage/citadel-state/citadel.conf";
/// This struct embeds the CitadelVersion datastruct as well as the cryptographic validation of that information
/// 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