1
0
forked from brl/citadel-tools

9 Commits

Author SHA1 Message Date
isa
8e80644d4c Implement TUF updates 2026-01-13 23:25:08 -05:00
Bruce Leidl
3a3d5c3b9b Improvements to RealmFS updates and resizing
1. Ensure resized image is resealed again
2. Add property to RealmFS dbus object to indicate if update is in
   progress
3. Notify that free space may have changed when committing an update
4. Implement ResizeGrowBy dbus method for RealmFS
2025-11-03 15:41:48 +00:00
Bruce Leidl
be413476b2 Big refactor and changes to fork realmfs and create realm 2025-10-26 19:51:40 +00:00
Bruce Leidl
87575e396c Correctly report the number of allocated blocks 2025-10-26 19:49:27 +00:00
Bruce Leidl
d4035cb9c3 Add a constant for block size 2025-10-26 19:45:36 +00:00
Bruce Leidl
df6e0de7c0 Fix arguments to realmfs tool 2025-10-26 14:12:27 +00:00
Bruce Leidl
3966d1d753 Fixed a couple of bugs related to forking realmfs.
Make sure new RealmFS instance has manager assigned.

Check for stale header information when activating RealmFS
2025-10-26 14:09:56 +00:00
Bruce Leidl
ba305af893 Removed some old code and added a warning.
Warn if an entry in the realmfs directory fails to process.
2025-10-26 14:08:03 +00:00
Bruce Leidl
9a273b78ff Prevent corruption of updated RealmFS
Make sure that the mounted update filesystem cannot
be written to before generating the dm-verity data.
2025-10-26 14:05:34 +00:00
32 changed files with 4126 additions and 900 deletions

1295
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -159,7 +159,7 @@ impl <'a> RealmFSInfoRender <'a> {
fn render_image(&mut self) {
fn sizes(r: &RealmFS) -> Result<(usize,usize)> {
let free = r.free_size_blocks()?;
let allocated = r.allocated_size_blocks()?;
let allocated = r.allocated_size_blocks();
Ok((free,allocated))
}

View File

@@ -17,6 +17,7 @@ toml = "0.9"
hex = "0.4"
byteorder = "1"
pwhash = "1.0"
rand = "0.8"
tempfile = "3"
zbus = "5.9.0"
anyhow = "1.0"
@@ -24,4 +25,17 @@ log = "0.4"
zbus_macros = "5.9"
event-listener = "5.4"
futures-timer = "3.0"
tokio = { version = "1", features = ["full"] }
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"] }
base64ct = "=1.7.3"
ureq = { version = "3.1" }
sha2 = "0.10"
nix = "0.30"
dialoguer = "0.12"
indicatif = "0.18"
serde_json = "1.0"
chrono = "0.4"
env_logger = "0.11"

View File

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

View File

@@ -0,0 +1,949 @@
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

@@ -0,0 +1,110 @@
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,210 @@
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

@@ -0,0 +1,121 @@
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

@@ -0,0 +1,699 @@
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,
}
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),
},
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
};
if updates.is_empty() {
println!("\n✓ System is up to date");
return Ok(());
}
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;
}
}
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,10 +1,11 @@
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::{Result, ResourceImage, Logger, LogLevel, Partition, KeyPair, ImageHeader, util};
use libcitadel::public_key_for_channel;
use libcitadel::{util, ImageHeader, KeyPair, Partition, ResourceImage, Result};
pub fn main() {
let matches = command!()
@@ -103,7 +104,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(())
}
@@ -113,22 +114,22 @@ fn info_signature(img: &ResourceImage) -> Result<()> {
println!("Signature: {}", hex::encode(&img.header().signature()));
} else {
println!("Signature: No Signature");
return Ok(());
}
match img.header().public_key()? {
Some(pubkey) => {
if img.header().verify_signature(pubkey) {
println!("Signature is valid");
} else {
println!("Signature verify FAILED");
}
},
None => { println!("No public key found for channel '{}'", img.metainfo().channel()) },
let pubkey = public_key_for_channel(img.metainfo().channel())?;
if img.header().verify_signature(pubkey) {
println!("Signature is valid");
} else {
println!("Signature verify FAILED");
}
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(())
}
@@ -167,7 +168,8 @@ 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() {
@@ -183,7 +185,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)?;
@@ -222,14 +224,18 @@ 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();
// XXX verify signature?
// Use existing function from libcitadel
let pubkey = public_key_for_channel(metainfo.channel())?;
if !img.header().verify_signature(pubkey) {
bail!("Image signature verification failed");
}
if !(metainfo.image_type() == "kernel" || metainfo.image_type() == "extra") {
bail!("Cannot install image type {}", metainfo.image_type());
@@ -250,13 +256,20 @@ fn install_image(arg_matches: &ArgMatches) -> Result<()> {
if kernel_version.chars().any(|c| c == '/') {
bail!("Kernel version field has / char");
}
format!("citadel-kernel-{}-{:03}.img", kernel_version, metainfo.version())
format!(
"citadel-kernel-{}-{}.img",
kernel_version,
metainfo.version()
)
} else {
format!("citadel-extra-{:03}.img", metainfo.version())
format!("citadel-extra-{}.img", metainfo.version())
};
if !metainfo.channel().chars().all(|c| c.is_ascii_lowercase()) {
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,6 +12,7 @@ use libcitadel::RealmFS;
use libcitadel::Result;
use libcitadel::OsRelease;
use libcitadel::KeyRing;
use libcitadel::ResourceImage;
use libcitadel::terminal::Base16Scheme;
use libcitadel::UtsName;
@@ -479,11 +480,29 @@ impl Installer {
}
fn setup_storage_resources(&self) -> Result<()> {
let channel = match OsRelease::citadel_channel() {
Some(channel) => channel,
None => "dev",
// 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 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,14 +1,18 @@
#[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 libcitadel::RealmManager;
use std::path::Path;
mod boot;
mod fetch;
mod image;
mod install;
mod install_backend;
@@ -22,7 +26,7 @@ fn main() {
Ok(path) => path,
Err(_e) => {
return;
},
}
};
let args = env::args().collect::<Vec<String>>();
@@ -36,9 +40,11 @@ fn main() {
} else if exe == Path::new("/usr/bin/citadel-image") {
image::main();
} else if exe == Path::new("/usr/bin/citadel-realmfs") {
realmfs::main();
realmfs::main(args);
} else if exe == Path::new("/usr/bin/citadel-update") {
update::main(args);
} else if exe == Path::new("/usr/bin/citadel-fetch") {
fetch::main();
} else if exe == Path::new("/usr/libexec/citadel-desktop-sync") {
sync::main(args);
} else if exe == Path::new("/usr/libexec/citadel-run") {
@@ -58,8 +64,9 @@ fn dispatch_command(args: Vec<String>) {
"boot" => boot::main(rebuild_args("citadel-boot", args)),
"install" => install::main(rebuild_args("citadel-install", args)),
"image" => image::main(),
"realmfs" => realmfs::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::path::PathBuf;
use std::fs::OpenOptions;
use std::fs::{self,File};
use std::io::{self,Write};
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::PathBuf;
use libcitadel::{Result, ImageHeader, devkeys, util};
use libcitadel::{devkeys, keypair_for_channel_signing, util, ImageHeader, Result};
use super::config::BuildConfig;
use std::path::Path;
use libcitadel::verity::Verity;
use std::path::Path;
pub struct UpdateBuilder {
config: BuildConfig,
@@ -19,15 +19,12 @@ 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 {
@@ -38,15 +35,29 @@ impl UpdateBuilder {
}
fn target_filename(&self) -> String {
format!("citadel-{}-{}-{:03}.img", self.config.img_name(), self.config.channel(), self.config.version())
format!(
"citadel-{}-{}-{}.img",
self.config.img_name(),
self.config.channel(),
self.config.version()
)
}
fn build_filename(config: &BuildConfig) -> String {
format!("citadel-{}-{}-{:03}", config.image_type(), config.channel(), config.version())
format!(
"citadel-{}-{}-{}",
config.image_type(),
config.channel(),
config.version()
)
}
fn verity_filename(&self) -> String {
format!("verity-hash-{}-{:03}", self.config.image_type(), self.config.version())
format!(
"verity-hash-{}-{}",
self.config.image_type(),
self.config.version()
)
}
pub fn build(&mut self) -> Result<()> {
@@ -154,7 +165,7 @@ impl UpdateBuilder {
bail!("failed to compress {:?}: {}", self.image(), err);
}
// Rename back to original image_data filename
util::rename(self.image().with_extension("xz"), self.image())?;
util::rename(util::append_to_path(self.image(), ".xz"), self.image())?;
}
Ok(())
}
@@ -192,6 +203,19 @@ impl UpdateBuilder {
if self.config.channel() == "dev" {
let sig = devkeys().sign(&metainfo);
hdr.set_signature(sig.to_bytes());
} else {
let private_key_path_str = match self.config.private_key_path() {
Some(path) => path,
None => bail!("private-key-path not found in config for non-dev channel"),
};
let private_key_path = Path::new(private_key_path_str);
let sig = keypair_for_channel_signing(private_key_path).sign(&metainfo);
info!("Generated signature: {}", hex::encode(sig.to_bytes()));
let generated_signature_bytes = sig.to_bytes();
if generated_signature_bytes.iter().all(|&b| b == 0) {
bail!("Generated signature is all zeros. Signing failed!");
}
hdr.set_signature(&generated_signature_bytes);
}
Ok(hdr)
}
@@ -217,12 +241,33 @@ impl UpdateBuilder {
writeln!(v, "realmfs-name = \"{}\"", name)?;
}
writeln!(v, "channel = \"{}\"", self.config.channel())?;
writeln!(v, "version = {}", self.config.version())?;
writeln!(v, "version = \"{}\"", self.config.version())?;
writeln!(v, "timestamp = \"{}\"", self.config.timestamp())?;
writeln!(v, "nblocks = {}", self.nblocks.unwrap())?;
writeln!(v, "shasum = \"{}\"", self.shasum.as_ref().unwrap())?;
writeln!(v, "verity-salt = \"{}\"", self.verity_salt.as_ref().unwrap())?;
writeln!(v, "verity-root = \"{}\"", self.verity_root.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())?;
}
}
Ok(v)
}
}

View File

@@ -1,7 +1,5 @@
use std::path::{Path, PathBuf};
use toml;
use libcitadel::{Result, util};
#[derive(Deserialize)]
@@ -9,7 +7,7 @@ pub struct BuildConfig {
#[serde(rename = "image-type")]
image_type: String,
channel: String,
version: usize,
version: String,
timestamp: String,
source: String,
#[serde(default)]
@@ -22,6 +20,15 @@ pub struct BuildConfig {
#[serde(rename = "realmfs-name")]
realmfs_name: Option<String>,
#[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)]
@@ -46,6 +53,43 @@ 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)
}
@@ -102,8 +146,8 @@ impl BuildConfig {
self.realmfs_name.as_ref().map(|s| s.as_str())
}
pub fn version(&self) -> usize {
self.version
pub fn version(&self) -> &str {
&self.version
}
pub fn channel(&self) -> &str {
@@ -117,4 +161,16 @@ impl BuildConfig {
pub fn compress(&self) -> bool {
self.compress
}
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

@@ -6,7 +6,7 @@ use libcitadel::util::is_euid_root;
use libcitadel::ResizeSize;
use std::process::exit;
pub fn main() {
pub fn main(args: Vec<String>) {
Logger::set_log_level(LogLevel::Debug);
@@ -65,11 +65,7 @@ is the final absolute size of the image.")
.help("Path or name of RealmFS image to deactivate")
.required(true)))
.arg(Arg::new("image")
.help("Name of or path to RealmFS image to display information about")
.required(true))
.get_matches();
.get_matches_from(args);
let result = match matches.subcommand() {
Some(("resize", m)) => resize(m),

View File

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

View File

@@ -20,6 +20,12 @@ walkdir = "2"
dbus = "0.6"
posix-acl = "1.0.0"
procfs = "0.12.0"
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

@@ -382,8 +382,33 @@ impl ImageHeader {
self.set_signature(&zeros);
}
pub fn public_key(&self) -> Result<Option<PublicKey>> {
public_key_for_channel(self.metainfo().channel())
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())
}
pub fn verify_signature(&self, pubkey: PublicKey) -> bool {
@@ -453,7 +478,7 @@ pub struct MetaInfo {
realmfs_owner: Option<String>,
#[serde(default)]
version: u32,
version: String,
#[serde(default)]
timestamp: String,
@@ -469,6 +494,12 @@ 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 {
@@ -508,8 +539,8 @@ impl MetaInfo {
Self::str_ref(&self.realmfs_owner)
}
pub fn version(&self) -> u32 {
self.version
pub fn version(&self) -> &str {
&self.version
}
pub fn timestamp(&self) -> &str {
@@ -535,5 +566,13 @@ 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,9 +35,24 @@ impl PublicKey {
}
pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
let sig = sign::Signature::try_from(signature)
.expect("Signature::from_slice() failed");
sign::verify_detached(&sig, data, &self.0)
let sig = match sign::Signature::try_from(signature) {
Ok(s) => s,
Err(_) => {
warn!("Invalid signature length: {}", signature.len());
return false;
}
};
let is_valid = sign::verify_detached(&sig, data, &self.0);
if !is_valid {
warn!("Header signature verification FAILED!");
warn!(" Public Key: {}", self.to_hex());
warn!(" Data (header): {}", hex::encode(data));
warn!(" Signature: {}", hex::encode(signature));
} else {
info!("Header signature verification SUCCESS.");
}
is_valid
}
}

View File

@@ -2,6 +2,9 @@
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate lazy_static;
use std::fs;
use std::path::Path;
#[macro_use] pub mod error;
#[macro_use] mod log;
#[macro_use] mod exec;
@@ -20,6 +23,7 @@ pub mod symlink;
mod realm;
pub mod terminal;
mod system;
pub mod updates;
pub mod flatpak;
@@ -54,28 +58,51 @@ pub fn devkeys() -> KeyPair {
.expect("Error parsing built in dev channel keys")
}
pub fn public_key_for_channel(channel: &str) -> Result<Option<PublicKey>> {
if channel == "dev" {
return Ok(Some(devkeys().public_key()));
pub fn keypair_for_channel_signing(private_key_path: &Path) -> KeyPair {
let hex_key = fs::read_to_string(private_key_path)
.expect(&format!("Error reading secret key from {}", private_key_path.display()));
KeyPair::from_hex(hex_key.trim())
.expect(&format!("Error parsing secret key from {}", private_key_path.display()))
}
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);
}
// Look in /etc/os-release
if Some(channel) == OsRelease::citadel_channel() {
if let Some(hex) = OsRelease::citadel_image_pubkey() {
let pubkey = PublicKey::from_hex(hex)?;
return Ok(Some(pubkey));
}
}
// Does kernel command line have citadel.channel=name:[hex encoded pubkey]
// Kernel command line override for developers
if Some(channel) == CommandLine::channel_name() {
if let Some(hex) = CommandLine::channel_pubkey() {
let pubkey = PublicKey::from_hex(hex)?;
return Ok(Some(pubkey))
return Ok(pubkey);
}
}
Ok(None)
let key_path = Path::new("/usr/share/citadel/keys/").join(format!("{}_image.pub", channel));
if !key_path.exists() {
if channel == "dev" {
return Ok(devkeys().public_key());
}
bail!("Public key not found for channel '{}' at {}", channel, key_path.display());
}
let hex_key = fs::read_to_string(&key_path)
.map_err(context!("could not read public key from {}", key_path.display()))?;
let pubkey = PublicKey::from_hex(hex_key.trim())?;
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};

View File

@@ -15,8 +15,7 @@ pub struct Partition {
#[derive(Clone)]
struct HeaderInfo {
header: Arc<ImageHeader>,
// None if no public key available for channel named in metainfo
pubkey: Option<PublicKey>,
pubkey: PublicKey,
}
impl Partition {
@@ -43,13 +42,7 @@ impl Partition {
}
let metainfo = header.metainfo();
let pubkey = match public_key_for_channel(metainfo.channel()) {
Ok(result) => result,
Err(err) => {
warn!("Error parsing pubkey for channel '{}': {}", metainfo.channel(), err);
None
}
};
let pubkey = public_key_for_channel(metainfo.channel())?;
let header = Arc::new(header);
Ok(Some(HeaderInfo {
@@ -104,21 +97,15 @@ impl Partition {
pub fn is_signature_valid(&self) -> bool {
if let Some(ref hinfo) = self.hinfo {
if let Some(ref pubkey) = hinfo.pubkey {
return pubkey.verify(
&self.header().metainfo_bytes(),
&self.header().signature())
}
return hinfo.pubkey.verify(
&self.header().metainfo_bytes(),
&self.header().signature())
}
false
}
pub fn has_public_key(&self) -> bool {
if let Some(ref h) = self.hinfo {
h.pubkey.is_some()
} else {
false
}
self.hinfo.is_some()
}
pub fn write_status(&mut self, status: u8) -> Result<()> {

View File

@@ -1,7 +1,6 @@
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};
@@ -57,6 +56,8 @@ impl RealmFS {
// Name used to retrieve key by 'description' from kernel key storage
pub const USER_KEYNAME: &'static str = "realmfs-user";
const BLOCK_SIZE: u64 = 4096;
/// Locate a RealmFS image by name in the default location using the standard name convention
pub fn load_by_name(name: &str) -> Result<Self> {
Self::validate_name(name)?;
@@ -281,10 +282,7 @@ impl RealmFS {
let pubkey = if self.metainfo().channel() == RealmFS::USER_KEYNAME {
self.sealing_keys()?.public_key()
} else {
match self.header().public_key()? {
Some(pubkey) => pubkey,
None => bail!("No public key available for channel {}", self.metainfo().channel()),
}
self.header().public_key()?
};
Ok(pubkey)
}
@@ -313,7 +311,7 @@ impl RealmFS {
info!("forking RealmFS image '{}' to new name '{}'", self.name(), new_name);
let forked = match self.fork_to_path(new_name, &new_path, keys) {
let mut forked = match self.fork_to_path(new_name, &new_path, keys) {
Ok(forked) => forked,
Err(err) => {
if new_path.exists() {
@@ -323,7 +321,10 @@ impl RealmFS {
}
};
self.with_manager(|m| m.realmfs_added(&forked));
self.with_manager(|m| {
m.realmfs_added(&forked);
forked.set_manager(m);
});
Ok(forked)
}
@@ -368,11 +369,11 @@ impl RealmFS {
pub fn file_nblocks(&self) -> Result<usize> {
let meta = self.path.metadata()
.map_err(context!("failed to read metadata from realmfs image file {:?}", self.path))?;
let len = meta.len() as usize;
if len % 4096 != 0 {
let len = meta.len();
if len % Self::BLOCK_SIZE != 0 {
bail!("realmfs image file '{}' has size which is not a multiple of block size", self.path.display());
}
let nblocks = len / 4096;
let nblocks = (len / Self::BLOCK_SIZE) as usize;
if nblocks < (self.metainfo().nblocks() + 1) {
bail!("realmfs image file '{}' has shorter length than nblocks field of image header", self.path.display());
}
@@ -406,18 +407,20 @@ impl RealmFS {
}
pub fn free_size_blocks(&self) -> Result<usize> {
let sb = Superblock::load(self.path(), 4096)?;
let sb = Superblock::load(self.path(), Self::BLOCK_SIZE)?;
Ok(sb.free_block_count() as usize)
}
pub fn allocated_size_blocks(&self) -> Result<usize> {
let meta = self.path().metadata()
.map_err(context!("failed to read metadata from realmfs image file {:?}", self.path()))?;
Ok(meta.blocks() as usize / 8)
pub fn allocated_size_blocks(&self) -> usize {
self.metainfo().nblocks()
}
/// Activate this RealmFS image if not yet activated.
pub fn activate(&self) -> Result<()> {
// Ensure that mountpoint matches header information of image
if let Err(err) = self.check_stale_header(false) {
warn!("error reloading stale image header: {}", err);
}
self.mountpoint().activate(self)
}

View File

@@ -27,17 +27,6 @@ impl RealmFSSet {
}
Ok(())
})?;
/*
let entries = fs::read_dir(RealmFS::BASE_PATH)
.map_err(context!("error reading realmfs directory {}", RealmFS::BASE_PATH))?;
for entry in entries {
let entry = entry.map_err(context!("error reading directory entry"))?;
if let Some(realmfs) = Self::entry_to_realmfs(&entry) {
v.push(realmfs)
}
}
*/
Ok(v)
}
@@ -47,6 +36,8 @@ impl RealmFSSet {
let name = filename.trim_end_matches("-realmfs.img");
if RealmFS::is_valid_name(name) && RealmFS::named_image_exists(name) {
return RealmFS::load_by_name(name).ok();
} else {
warn!("Rejecting realmfs '{}' as invalid name or invalid image", name);
}
}
}

View File

@@ -108,7 +108,10 @@ impl RealmFSUpdate {
LoopDevice::with_loop(self.target(), Some(BLOCK_SIZE), false, |loopdev| {
self.resize_device(loopdev)
})
})?;
self.apply_update()?;
self.cleanup();
Ok(())
}
fn mount_update_image(&mut self) -> Result<()> {
@@ -214,22 +217,29 @@ impl RealmFSUpdate {
self.set_target_len(nblocks)
}
fn shutdown_container(&mut self) -> Result<()> {
if let Some(update) = self.container.take() {
update.stop_container()?;
fn remount_read_only(&mut self) {
if self.mountpath.exists() {
if let Err(err) = cmd!("/usr/bin/mount", "-o remount,ro {}", self.mountpath.display()) {
warn!("Failed to remount read-only directory {}: {}", self.mountpath.display(), err);
} else {
info!("Directory {} remounted as read-only", self.mountpath.display());
}
}
}
fn shutdown_container(&mut self) {
if let Some(update) = self.container.take() {
if let Err(err) = update.stop_container() {
warn!("Error shutting down update container: {}", err);
}
}
Ok(())
}
pub fn cleanup(&mut self) {
// if a container was started, stop it
if let Err(err) = self.shutdown_container() {
warn!("Error shutting down update container: {}", err);
}
self.shutdown_container();
if self.mountpath.exists() {
self.unmount_update_image();
}
self.unmount_update_image();
if self.target().exists() {
if let Err(err) = fs::remove_file(self.target()) {
@@ -304,6 +314,10 @@ impl RealmFSUpdate {
}
pub fn commit_update(&mut self) -> Result<()> {
// First shutdown container so writable mount can be removed in apply_update()
self.shutdown_container();
// Ensure no further writes
self.remount_read_only();
let result = self.apply_update();
self.cleanup();
result

View File

@@ -42,7 +42,21 @@ impl ResourceImage {
info!("Searching run directory for image {} with channel {}", image_type, channel);
if let Some(image) = search_directory(RUN_DIRECTORY, image_type, Some(&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());
return Ok(image);
}
@@ -52,8 +66,9 @@ impl ResourceImage {
let storage_path = Path::new(STORAGE_BASEDIR).join(&channel);
if let Some(image) = search_directory(storage_path, image_type, Some(&channel))? {
return Ok(image);
if let Some(image) = search_directory(&storage_path, image_type, Some(&channel), false)? {
info!("Found image in storage: {}", image.path().display());
return Ok(image);
}
bail!("failed to find resource image of type: {}", image_type)
@@ -66,7 +81,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)? {
match search_directory(RUN_DIRECTORY, "rootfs", None, false)? {
Some(image) => Ok(image),
None => bail!("failed to find rootfs resource image"),
}
@@ -199,15 +214,11 @@ impl ResourceImage {
pub fn setup_verity_device(&self) -> Result<String> {
if !CommandLine::nosignatures() {
match self.header.public_key()? {
Some(pubkey) => {
if !self.header.verify_signature(pubkey) {
bail!("header signature verification failed");
}
info!("Image header signature is valid");
}
None => bail!("cannot verify header signature because no public key for channel {} is available", self.metainfo().channel())
let pubkey = self.header.public_key()?;
if !self.header.verify_signature(pubkey) {
bail!("header signature verification failed");
}
info!("Image header signature is valid");
}
info!("Setting up dm-verity device for image");
if !self.has_verity_hashtree() {
@@ -350,6 +361,13 @@ 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);
}
@@ -371,9 +389,30 @@ impl ResourceImage {
}
fn rootfs_channel() -> &'static str {
match CommandLine::channel_name() {
Some(channel) => channel,
None => "dev",
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"
}
}
},
}
}
}
@@ -383,15 +422,24 @@ 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.
fn search_directory<P: AsRef<Path>>(dir: P, image_type: &str, channel: Option<&str>) -> Result<Option<ResourceImage>> {
// 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);
if !dir.as_ref().exists() {
return Ok(None)
}
let mut best = None;
let mut matches = all_matching_images(dir.as_ref(), image_type, channel)?;
debug!("Found {} matching images", matches.len());
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());
}
if channel.is_none() {
if matches.is_empty() {
@@ -420,8 +468,10 @@ fn compare_images(a: Option<ResourceImage>, b: ResourceImage) -> Result<Resource
None => return Ok(b),
};
let ver_a = a.metainfo().version();
let ver_b = b.metainfo().version();
let bind_a = a.metainfo();
let bind_b = b.metainfo();
let ver_a = bind_a.version();
let ver_b = bind_b.version();
if ver_a > ver_b {
Ok(a)
@@ -455,17 +505,21 @@ fn current_kernel_version() -> String {
//
// Read a directory search for ResourceImages which match the channel
// and image_type.
// and image_type. If skip_kernel_match is true, skip kernel version/id matching.
//
fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>) -> Result<Vec<ResourceImage>> {
fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>, skip_kernel_match: bool) -> Result<Vec<ResourceImage>> {
let kernel_version = current_kernel_version();
let kv = if image_type == "kernel" {
let kv = if image_type == "kernel" && !skip_kernel_match {
Some(kernel_version.as_str())
} else {
None
};
let kernel_id = OsRelease::citadel_kernel_id();
let kernel_id = if !skip_kernel_match {
OsRelease::citadel_kernel_id()
} else {
None
};
let mut v = Vec::new();
util::read_directory(dir, |dent| {
@@ -477,8 +531,9 @@ fn all_matching_images(dir: &Path, image_type: &str, channel: Option<&str>) -> R
// 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. If channel
// is None then don't consider the channel in the match.
// 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.
//
// If the entry is a match, then instantiate a ResourceImage and add it to
// the images vector.
@@ -490,9 +545,12 @@ 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() || meta.len() < ImageHeader::HEADER_SIZE as u64 {
if !meta.is_file() {
info!(" Skipping: not a regular file");
return Ok(())
}
let header = match ImageHeader::from_file(&path) {
@@ -504,25 +562,38 @@ fn maybe_add_dir_entry(entry: &DirEntry,
};
if !header.is_magic_valid() {
info!(" Skipping: invalid magic header");
return Ok(())
}
let metainfo = header.metainfo();
debug!("Found an image type={} channel={} kernel={:?}", metainfo.image_type(), metainfo.channel(), metainfo.kernel_version());
info!("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(())
}
if image_type == "kernel" && (metainfo.kernel_version() != kernel_version || metainfo.kernel_id() != kernel_id) {
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(());
}
}
images.push(ResourceImage::new(&path, header));

183
libcitadel/src/updates.rs Normal file
View File

@@ -0,0 +1,183 @@
use anyhow::Context;
use std::fmt;
use std::io::Write;
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
#[derive(Debug, Serialize, Deserialize)]
pub struct CryptoContainerFile {
pub serialized_citadel_version: Vec<u8>, // we serialize CitadelVersion
pub signature: String, // serialized CitadelVersion gets signed
pub signatory: String, // name of org or person who holds the key
}
/// This struct contains the entirety of the logical information needed to decide whether to update or not
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct CitadelVersionStruct {
pub client: String,
pub channel: String, // dev, stable ...
pub component_version: Vec<AvailableComponentVersion>,
pub publisher: String, // name of org or person who released this update
}
impl std::fmt::Display for CitadelVersionStruct {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"{} image with channel {} has components:\n",
self.client, self.channel
)?;
for i in &self.component_version {
write!(
f,
"\n{} with version {} at location {}",
i.component, i.version, i.file_path
)?;
}
Ok(())
}
}
#[derive(Debug, Serialize, Clone, Deserialize, PartialEq, Eq, Ord)]
pub struct AvailableComponentVersion {
pub component: Component, // rootfs, kernel or extra
pub version: String, // stored as semver
pub file_path: String,
pub sha256_hash: String,
}
impl PartialOrd for AvailableComponentVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
// absolutely require that the components be in the same order in all structs (rootfs, kernel, extra)
if &self.component != &other.component {
panic!("ComponentVersion comparison failed because comparing different components");
}
Some(
semver::Version::parse(&self.version)
.unwrap()
.cmp(&semver::Version::parse(&other.version).unwrap()),
)
}
}
impl std::fmt::Display for AvailableComponentVersion {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"({} image has version: {})",
self.component, self.version
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, clap::ValueEnum)]
pub enum Component {
Rootfs,
Kernel,
Extra,
}
impl Component {
pub fn iterator() -> Iter<'static, Component> {
static COMPONENTS: [Component; 3] =
[Component::Rootfs, Component::Kernel, Component::Extra];
COMPONENTS.iter()
}
}
impl fmt::Display for Component {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Component::Rootfs => write!(f, "rootfs"),
Component::Kernel => write!(f, "kernel"),
&Component::Extra => write!(f, "extra"),
}
}
}
/// Reads a specific key from an os-release formatted file.
/// The value is returned without any surrounding quotes.
pub fn get_citadel_conf(key: &str) -> anyhow::Result<Option<String>> {
// Read the entire file into a string.
let content = std::fs::read_to_string(CITADEL_CONFIG_PATH)
.context(format!("Failed to read {}", CITADEL_CONFIG_PATH))?;
// Search each line for the key.
for line in content.lines() {
// Check if the line starts with "KEY="
if let Some(value) = line.trim().strip_prefix(&format!("{}=", key)) {
// If found, trim whitespace and quotes from the value and return.
let value = value.trim().trim_matches('"').to_string();
return Ok(Some(value));
}
}
// If the loop finishes without finding the key, return None.
Ok(None)
}
pub fn get_os_release(key: &str) -> anyhow::Result<Option<String>> {
// Read the entire file into a string.
let content = std::fs::read_to_string("/etc/os-release")
.context(format!("Failed to read {}", "/etc/os-release"))?;
// Search each line for the key.
for line in content.lines() {
// Check if the line starts with "KEY="
if let Some(value) = line.trim().strip_prefix(&format!("{}=", key)) {
// If found, trim whitespace and quotes from the value and return.
let value = value.trim().trim_matches('"').to_string();
return Ok(Some(value));
}
}
// If the loop finishes without finding the key, return None.
Ok(None)
}
/// Safely modifies a key-value pair in the citadel config os-release-formated file.
/// If the key does not exist, it will be added to the end of the file.
pub fn set_citadel_conf(key: &str, value: &str) -> anyhow::Result<()> {
// Read the existing os-release file.
let content = std::fs::read_to_string(CITADEL_CONFIG_PATH)
.context(format!("Failed to read {}", CITADEL_CONFIG_PATH))?;
let mut lines: Vec<String> = Vec::new();
let mut key_updated = false;
let key_prefix = format!("{}=", key);
let new_line = format!("{}{}", key_prefix, value);
// Process each line to update the key if it exists.
for line in content.lines() {
if line.starts_with(&key_prefix) {
lines.push(new_line.clone());
key_updated = true;
} else {
lines.push(line.to_string());
}
}
// If the key was not found, add it to the end.
if !key_updated {
lines.push(new_line);
}
// Write the changes back safely using a temporary file and an atomic rename.
let mut temp_file = tempfile::Builder::new()
.prefix("citadel.conf")
.suffix(".tmp")
.tempfile_in(std::path::Path::new(CITADEL_CONFIG_PATH).parent().unwrap())?;
temp_file.write_all(lines.join("\n").as_bytes())?;
temp_file.write_all(b"\n")?; // Ensure the file ends with a newline.
temp_file.persist(CITADEL_CONFIG_PATH).context(format!(
"Failed to overwrite {}. Are you running as root?",
CITADEL_CONFIG_PATH
))?;
Ok(())
}

View File

@@ -48,6 +48,12 @@ fn search_path(filename: &str) -> Result<PathBuf> {
bail!("could not find {} in $PATH", filename)
}
pub fn append_to_path(p: &Path, s: &str) -> PathBuf {
let mut p_osstr = p.as_os_str().to_owned();
p_osstr.push(s);
p_osstr.into()
}
pub fn ensure_command_exists(cmd: &str) -> Result<()> {
let path = Path::new(cmd);
if !path.is_absolute() {
@@ -404,4 +410,4 @@ pub fn drop_privileges(uid: u32, gid: u32) -> Result<()> {
}
}
Ok(())
}
}

View File

@@ -7,13 +7,12 @@ use serde_repr::Serialize_repr;
use zbus::blocking::fdo::DBusProxy;
use zbus::blocking::Connection;
use zbus::names::BusName;
use zbus::zvariant::Type;
use zbus::zvariant::{ObjectPath, Type};
use zbus::{fdo, interface};
use libcitadel::{PidLookupResult, RealmManager};
use libcitadel::{PidLookupResult, Realm, RealmManager};
use libcitadel::terminal::Base16Scheme;
use crate::next::config::RealmConfigVars;
use crate::next::realm::RealmItemState;
use super::realmfs::RealmFSState;
use crate::next::state::RealmsManagerState;
pub fn failed<T>(message: String) -> fdo::Result<T> {
Err(fdo::Error::Failed(message))
@@ -43,10 +42,10 @@ impl From<PidLookupResult> for RealmFromCitadelPid {
}
}
#[derive(Clone)]
pub struct RealmsManagerServer2 {
realms: RealmItemState,
realmfs_state: RealmFSState,
state: RealmsManagerState,
manager: Arc<RealmManager>,
quit_event: Arc<Event>,
}
@@ -55,11 +54,9 @@ pub struct RealmsManagerServer2 {
impl RealmsManagerServer2 {
fn new(connection: Connection, manager: Arc<RealmManager>, quit_event: Arc<Event>) -> Self {
let realms = RealmItemState::new(connection.clone());
let realmfs_state = RealmFSState::new(connection.clone());
let state = RealmsManagerState::new(connection.clone());
RealmsManagerServer2 {
realms,
realmfs_state,
state,
manager,
quit_event,
}
@@ -73,13 +70,12 @@ impl RealmsManagerServer2 {
let args = sig.args()?;
match &args.name {
BusName::Unique(unique_name) if args.new_owner().is_none() => {
self.realmfs_state.client_disconnected(unique_name);
self.state.client_disconnected(unique_name);
},
_ => {},
}
}
Ok(())
}
fn listen_name_owner_changed(&self, connection: &Connection) {
@@ -94,12 +90,28 @@ impl RealmsManagerServer2 {
pub fn load(connection: &Connection, manager: Arc<RealmManager>, quit_event: Arc<Event>) -> zbus::Result<Self> {
let server = Self::new(connection.clone(), manager.clone(), quit_event);
server.realms.load_realms(&manager)?;
server.realmfs_state.load(&manager)?;
server.realms.populate_realmfs(&server.realmfs_state)?;
server.state.load(&manager)?;
server.listen_name_owner_changed(connection);
Ok(server)
}
fn setup_new_realm(manager: &RealmManager, realm: Realm, realmfs_name: &str) {
if let Some(realmfs) = manager.realmfs_by_name(&realmfs_name) {
realm.with_mut_config(|c| c.realmfs = Some(realmfs.name().to_string()));
} else {
warn!("Cannot set RealmFS '{}' on realm because it does not exist", realmfs_name);
}
let config = realm.config();
if let Err(err) = config.write() {
warn!("error writing config file for new realm: {}", err);
}
let scheme_name = config.terminal_scheme().unwrap_or("default-dark");
if let Some(scheme) = Base16Scheme::by_name(scheme_name) {
if let Err(e) = scheme.apply_to_realm(&manager, &realm) {
warn!("error writing scheme files: {}", e);
}
}
}
}
@@ -108,7 +120,7 @@ impl RealmsManagerServer2 {
async fn get_current(&self) -> u32 {
self.realms.get_current()
self.state.get_current()
.map(|r| r.index())
.unwrap_or(0)
}
@@ -120,11 +132,13 @@ impl RealmsManagerServer2 {
}).await
}
async fn create_realm(&self, name: &str) -> fdo::Result<()> {
async fn create_realm(&self, name: &str, realmfs: &str) -> fdo::Result<()> {
let manager = self.manager.clone();
let name = name.to_string();
let realmfs_name = realmfs.to_string();
unblock(move || {
let _ = manager.new_realm(&name).map_err(|err| fdo::Error::Failed(err.to_string()))?;
let realm = manager.new_realm(&name).map_err(|err| fdo::Error::Failed(err.to_string()))?;
RealmsManagerServer2::setup_new_realm(&manager, realm, &realmfs_name);
Ok(())
}).await
}
@@ -143,8 +157,16 @@ impl RealmsManagerServer2 {
}).await
}
async fn fork_realmfs(&self, name: &str, new_name: &str) -> fdo::Result<ObjectPath<'_>> {
let state = self.state.clone();
let name = name.to_string();
let new_name = new_name.to_string();
unblock(move || {
state.fork_realmfs(&name, &new_name)
}).await
}
async fn get_global_config(&self) -> RealmConfigVars {
RealmConfigVars::new_global()
}
}

View File

@@ -4,5 +4,7 @@ mod config;
mod realm;
mod realmfs;
mod state;
pub use manager::RealmsManagerServer2;
pub const REALMS2_SERVER_OBJECT_PATH: &str = "/com/subgraph/Realms2";

View File

@@ -1,18 +1,17 @@
use std::collections::HashMap;
use std::convert::TryInto;
use crate::next::config::{RealmConfig, RealmConfigVars};
use crate::next::REALMS2_SERVER_OBJECT_PATH;
use blocking::unblock;
use libcitadel::Realm;
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::sync::{Arc, Mutex, MutexGuard};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use blocking::unblock;
use zbus::zvariant::{OwnedObjectPath, Value};
use zbus::{interface, fdo};
use zbus::blocking::Connection;
use std::sync::Arc;
use async_io::block_on;
use zbus::{fdo, interface, Connection};
use zbus::names::{BusName, InterfaceName};
use libcitadel::{Realm, RealmEvent, RealmManager, Result};
use crate::next::config::{RealmConfig, RealmConfigVars};
use crate::next::realmfs::RealmFSState;
use crate::next::REALMS2_SERVER_OBJECT_PATH;
use zbus::zvariant::Value;
use crate::next::state::RealmFSNameToId;
#[derive(Clone)]
pub struct RealmItem {
@@ -22,12 +21,13 @@ pub struct RealmItem {
config: RealmConfig,
in_run_transition: Arc<AtomicBool>,
realmfs_index: Arc<AtomicU32>,
realmfs_name_to_id: RealmFSNameToId,
last_timestamp: Arc<AtomicI64>,
}
#[derive(Copy,Clone)]
#[repr(u32)]
enum RealmRunStatus {
pub enum RealmRunStatus {
Stopped = 0,
Starting,
Running,
@@ -48,13 +48,14 @@ impl RealmRunStatus {
}
impl RealmItem {
pub(crate) fn new_from_realm(index: u32, realm: Realm) -> RealmItem {
pub(crate) fn new_from_realm(index: u32, realm: Realm, realmfs_name_to_id: RealmFSNameToId) -> RealmItem {
let path = format!("{}/Realm{}", REALMS2_SERVER_OBJECT_PATH, index);
let in_run_transition = Arc::new(AtomicBool::new(false));
let config = RealmConfig::new(realm.clone());
let realmfs_index = Arc::new(AtomicU32::new(0));
let realmfs_index = realmfs_name_to_id.lookup(realm.config().realmfs());
let realmfs_index = Arc::new(AtomicU32::new(realmfs_index));
let last_timestamp = Arc::new(AtomicI64::new(realm.timestamp()));
RealmItem { path, index, realm, config, in_run_transition, realmfs_index, last_timestamp }
RealmItem { path, index, realm, config, in_run_transition, realmfs_name_to_id, realmfs_index, last_timestamp }
}
pub fn path(&self) -> &str {
@@ -69,10 +70,26 @@ impl RealmItem {
self.in_run_transition.load(Ordering::Relaxed)
}
fn get_run_status(&self) -> RealmRunStatus {
pub fn last_timestamp(&self) -> i64 {
self.last_timestamp.load(Ordering::Relaxed)
}
pub fn set_last_timestamp(&self, ts: i64) {
self.last_timestamp.store(ts, Ordering::Relaxed);
}
pub fn get_run_status(&self) -> RealmRunStatus {
RealmRunStatus::for_realm(&self.realm, self.in_run_transition())
}
pub fn realm(&self) -> &Realm {
&self.realm
}
pub fn set_in_run_transition(&self, in_run_transition: bool) {
self.in_run_transition.store(in_run_transition, Ordering::Relaxed);
}
async fn do_start(&mut self) -> fdo::Result<()> {
if !self.realm.is_active() {
let realm = self.realm.clone();
@@ -98,6 +115,20 @@ impl RealmItem {
}
Ok(())
}
pub fn emit_property_changed(&self, connection: &Connection, propname: &str, value: Value<'_>) -> fdo::Result<()> {
let iface_name = InterfaceName::from_str_unchecked("com.subgraph.realms.Realm");
let changed = HashMap::from([(propname.to_string(), value)]);
let inval: &[&str] = &[];
block_on(
connection.emit_signal(
None::<BusName<'_>>,
self.path(),
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
&(iface_name, changed, inval)))?;
Ok(())
}
}
#[interface(
@@ -141,9 +172,20 @@ impl RealmItem {
self.config.config_vars()
}
async fn set_config(&mut self, vars: Vec<(String, String)>) -> fdo::Result<()> {
async fn set_config(&mut self,
#[zbus(connection)]
connection: &Connection,
vars: Vec<(String, String)>) -> fdo::Result<()> {
for (var, val) in &vars {
self.config.set_var(var, val)?;
if var == "realmfs" {
let index = self.realmfs_name_to_id.lookup(val);
if index != self.realmfs_index.load(Ordering::Relaxed) {
self.realmfs_index.store(index, Ordering::Relaxed);
self.emit_property_changed(connection, "RealmFS", Value::U32(index))?;
}
}
}
let config = self.config.clone();
@@ -203,193 +245,4 @@ impl RealmItem {
fn timestamp(&self) -> u64 {
self.realm.timestamp() as u64
}
}
#[derive(Clone)]
pub struct RealmItemState(Arc<Mutex<Inner>>);
struct Inner {
connection: Connection,
next_index: u32,
realms: HashMap<String, RealmItem>,
current_realm: Option<RealmItem>,
}
impl Inner {
fn new(connection: Connection) -> Self {
Inner {
connection,
next_index: 1,
realms:HashMap::new(),
current_realm: None,
}
}
fn load_realms(&mut self, manager: &RealmManager) -> zbus::Result<()> {
for realm in manager.realm_list() {
self.add_realm(realm)?;
}
Ok(())
}
pub fn populate_realmfs(&mut self, realmfs_state: &RealmFSState) -> zbus::Result<()> {
for item in self.realms.values_mut() {
if let Some(realmfs) = realmfs_state.realmfs_by_name(item.realm.config().realmfs()) {
item.realmfs_index.store(realmfs.index(), Ordering::Relaxed);
}
}
Ok(())
}
fn add_realm(&mut self, realm: Realm) -> zbus::Result<()> {
if self.realms.contains_key(realm.name()) {
warn!("Attempted to add duplicate realm '{}'", realm.name());
return Ok(())
}
let key = realm.name().to_string();
let item = RealmItem::new_from_realm(self.next_index, realm);
self.connection.object_server().at(item.path(), item.clone())?;
self.realms.insert(key, item);
self.next_index += 1;
Ok(())
}
fn remove_realm(&mut self, realm: &Realm) -> zbus::Result<()> {
if let Some(item) = self.realms.remove(realm.name()) {
self.connection.object_server().remove::<RealmItem, &str>(item.path())?;
} else {
warn!("Failed to find realm to remove with name '{}'", realm.name());
}
Ok(())
}
fn emit_property_changed(&self, object_path: OwnedObjectPath, propname: &str, value: Value<'_>) -> zbus::Result<()> {
let iface_name = InterfaceName::from_str_unchecked("com.subgraph.realms.Realm");
let changed = HashMap::from([(propname.to_string(), value)]);
let inval: &[&str] = &[];
self.connection.emit_signal(
None::<BusName<'_>>,
&object_path,
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
&(iface_name, changed, inval))?;
Ok(())
}
fn realm_status_changed(&self, realm: &Realm, transition: Option<bool>) -> zbus::Result<()> {
if let Some(realm) = self.realm_by_name(realm.name()) {
if let Some(transition) = transition {
realm.in_run_transition.store(transition, Ordering::Relaxed);
}
let object_path = realm.path().try_into().unwrap();
self.emit_property_changed(object_path, "RunStatus", Value::U32(realm.get_run_status() as u32))?;
let timestamp = realm.realm.timestamp();
if realm.last_timestamp.load(Ordering::Relaxed) != realm.realm.timestamp() {
realm.last_timestamp.store(timestamp, Ordering::Relaxed);
let object_path = realm.path().try_into().unwrap();
self.emit_property_changed(object_path, "Timestamp", Value::U64(timestamp as u64))?;
}
}
Ok(())
}
fn realm_by_name(&self, name: &str) -> Option<&RealmItem> {
let res = self.realms.get(name);
if res.is_none() {
warn!("Failed to find realm with name '{}'", name);
}
res
}
fn on_starting(&self, realm: &Realm) -> zbus::Result<()>{
self.realm_status_changed(realm, Some(true))?;
Ok(())
}
fn on_started(&self, realm: &Realm) -> zbus::Result<()>{
self.realm_status_changed(realm, Some(false))
}
fn on_stopping(&self, realm: &Realm) -> zbus::Result<()> {
self.realm_status_changed(realm, Some(true))
}
fn on_stopped(&self, realm: &Realm) -> zbus::Result<()> {
self.realm_status_changed(realm, Some(false))
}
fn on_new(&mut self, realm: &Realm) -> zbus::Result<()> {
self.add_realm(realm.clone())?;
Ok(())
}
fn on_removed(&mut self, realm: &Realm) -> zbus::Result<()> {
self.remove_realm(&realm)?;
Ok(())
}
fn on_current(&mut self, realm: Option<&Realm>) -> zbus::Result<()> {
if let Some(r) = self.current_realm.take() {
self.realm_status_changed(&r.realm, None)?;
}
if let Some(realm) = realm {
self.realm_status_changed(realm, None)?;
if let Some(item) = self.realm_by_name(realm.name()) {
self.current_realm = Some(item.clone());
}
}
Ok(())
}
}
impl RealmItemState {
pub fn new(connection: Connection) -> Self {
RealmItemState(Arc::new(Mutex::new(Inner::new(connection))))
}
pub fn load_realms(&self, manager: &RealmManager) -> zbus::Result<()> {
self.inner().load_realms(manager)?;
self.add_event_handler(manager)
.map_err(|err| zbus::Error::Failure(err.to_string()))?;
Ok(())
}
pub fn populate_realmfs(&self, realmfs_state: &RealmFSState) -> zbus::Result<()> {
self.inner().populate_realmfs(realmfs_state)
}
pub fn get_current(&self) -> Option<RealmItem> {
self.inner().current_realm.clone()
}
fn inner(&self) -> MutexGuard<Inner> {
self.0.lock().unwrap()
}
fn add_event_handler(&self, manager: &RealmManager) -> Result<()> {
let state = self.clone();
manager.add_event_handler(move |ev| {
if let Err(err) = state.handle_event(ev) {
warn!("Error handling {}: {}", ev, err);
}
});
manager.start_event_task()?;
Ok(())
}
fn handle_event(&self, ev: &RealmEvent) -> zbus::Result<()> {
match ev {
RealmEvent::Started(realm) => self.inner().on_started(realm)?,
RealmEvent::Stopped(realm) => self.inner().on_stopped(realm)?,
RealmEvent::New(realm) => self.inner().on_new(realm)?,
RealmEvent::Removed(realm) => self.inner().on_removed(realm)?,
RealmEvent::Current(realm) => self.inner().on_current(realm.as_ref())?,
RealmEvent::Starting(realm) => self.inner().on_starting(realm)?,
RealmEvent::Stopping(realm) => self.inner().on_stopping(realm)?,
};
Ok(())
}
}
}

View File

@@ -1,13 +1,13 @@
use std::collections::HashMap;
use crate::next::REALMS2_SERVER_OBJECT_PATH;
use libcitadel::{RealmFS, RealmFSUpdate, ResizeSize};
use std::convert::TryInto;
use std::sync::{Arc, Mutex, MutexGuard};
use zbus::blocking::Connection;
use blocking::unblock;
use zbus::message::Header;
use zbus::names::UniqueName;
use zbus::zvariant::{ObjectPath, OwnedObjectPath};
use zbus::{fdo, interface};
use libcitadel::{RealmFS, RealmManager,RealmFSUpdate};
use crate::next::REALMS2_SERVER_OBJECT_PATH;
use zbus::object_server::SignalEmitter;
struct UpdateState(Option<(UniqueName<'static>, RealmFSUpdate)>);
@@ -40,12 +40,8 @@ impl UpdateState {
}
}
fn commit_update(&mut self) {
if let Some((_name, mut update)) = self.0.take() {
if let Err(err) = update.commit_update() {
warn!("Error committing RealmFS update: {}", err);
}
}
fn take_update(&mut self) -> Option<(RealmFSUpdate)> {
self.0.take().map(|(_,update)| update)
}
}
@@ -60,13 +56,12 @@ pub struct RealmFSItem {
impl RealmFSItem {
fn update_state(&self) -> MutexGuard<UpdateState> {
fn update_state(&self) -> MutexGuard<'_, UpdateState> {
self.update_state.lock().unwrap()
}
fn client_disconnected(&mut self, name: &UniqueName) {
//debug!("disconnect {} {}", self.object_path, name);
pub fn client_disconnected(&mut self, name: &UniqueName) {
let mut state = self.update_state();
if state.matches(name) {
@@ -84,11 +79,15 @@ impl RealmFSItem {
}
}
pub fn realmfs(&self) -> &RealmFS {
&self.realmfs
}
pub fn index(&self) -> u32 {
self.index
}
pub fn object_path(&self) -> ObjectPath {
pub fn object_path(&self) -> ObjectPath<'_> {
self.object_path.as_ref()
}
}
@@ -102,39 +101,88 @@ impl RealmFSItem {
&mut self,
#[zbus(header)]
hdr: Header<'_>,
#[zbus(signal_emitter)]
emitter: SignalEmitter<'_>,
shared_directory: bool,
) -> fdo::Result<String> {
let mut update_container = String::new();
{
let mut state = self.update_state();
let mut state = self.update_state();
if state.is_active() {
return Err(fdo::Error::Failed("An update is already in progress".to_owned()));
}
if state.is_active() {
return Err(fdo::Error::Failed("An update is already in progress".to_owned()));
let sender = match hdr.sender() {
Some(sender) => sender,
None => return Err(fdo::Error::Failed("No sender in prepare_update()".into())),
};
let mut update = self.realmfs.update()
.map_err(|err| fdo::Error::Failed(err.to_string()))?;
update.prepare_update(shared_directory)
.map_err(|err| fdo::Error::Failed(err.to_string()))?;
update_container.push_str(update.name());
debug!("Update from {}, container: {}", sender, update_container);
state.activate(sender.to_owned(), update);
}
let sender = match hdr.sender() {
Some(sender) => sender,
None => todo!(),
};
let mut update = self.realmfs.update()
.map_err(|err| fdo::Error::Failed(err.to_string()))?;
update.prepare_update(shared_directory)
.map_err(|err| fdo::Error::Failed(err.to_string()))?;
let update_container = update.name().to_string();
debug!("Update from {}, container: {}", sender, update_container);
state.activate(sender.to_owned(), update);
self.is_update_in_progress_changed(&emitter).await?;
Ok(update_container)
}
async fn commit_update(&mut self) -> fdo::Result<()> {
self.update_state().commit_update();
async fn commit_update(&mut self,
#[zbus(signal_emitter)]
emitter: SignalEmitter<'_>
) -> fdo::Result<()> {
let mut update = match self.update_state().take_update() {
None => {
warn!("CommitUpdate called when no update in progress");
return Ok(());
},
Some(update) => update,
};
unblock(move || {
if let Err(err) = update.commit_update() {
warn!("Error committing RealmFS update: {}", err);
}
}).await;
self.is_update_in_progress_changed(&emitter).await?;
self.free_space_changed(&emitter).await?;
Ok(())
}
async fn abandon_update(&mut self) -> fdo::Result<()> {
async fn abandon_update(&mut self,
#[zbus(signal_emitter)]
emitter: SignalEmitter<'_>) -> fdo::Result<()> {
self.update_state().cleanup_update();
self.is_update_in_progress_changed(&emitter).await?;
Ok(())
}
async fn resize_grow_by(&mut self,
#[zbus(signal_emitter)]
emitter: SignalEmitter<'_>,
nblocks: u64) -> fdo::Result<()> {
let nblocks = nblocks as usize;
let current = self.realmfs.allocated_size_blocks();
let new_size = current + nblocks;
let realmfs = self.realmfs.clone();
unblock(move || {
realmfs.resize_grow_to(ResizeSize::blocks(new_size))
.map_err(|err| fdo::Error::Failed(err.to_string()))
}).await?;
self.allocated_space_changed(&emitter).await?;
self.free_space_changed(&emitter).await?;
Ok(())
}
@@ -152,6 +200,12 @@ impl RealmFSItem {
fn in_use(&self) -> bool {
self.realmfs.is_activated()
}
#[zbus(property, name = "IsUpdateInProgress")]
fn is_update_in_progress(&self) -> bool {
self.update_state().is_active()
}
#[zbus(property, name = "Mountpoint")]
fn mountpoint(&self) -> String {
self.realmfs.mountpoint().to_string()
@@ -171,81 +225,7 @@ impl RealmFSItem {
#[zbus(property, name = "AllocatedSpace")]
fn allocated_space(&self) -> fdo::Result<u64> {
let blocks = self.realmfs.allocated_size_blocks()
.map_err(|err| fdo::Error::Failed(err.to_string()))?;
let blocks = self.realmfs.allocated_size_blocks();
Ok(blocks as u64 * BLOCK_SIZE)
}
}
#[derive(Clone)]
pub struct RealmFSState(Arc<Mutex<Inner>>);
impl RealmFSState {
pub fn new(connection: Connection) -> Self {
RealmFSState(Arc::new(Mutex::new(Inner::new(connection))))
}
fn inner(&self) -> MutexGuard<Inner> {
self.0.lock().unwrap()
}
pub(crate) fn load(&self, manager: &RealmManager) -> zbus::Result<()> {
self.inner().load(manager)
}
pub fn realmfs_by_name(&self, name: &str) -> Option<RealmFSItem> {
self.inner().realmfs_by_name(name)
}
pub fn client_disconnected(&self, client_name: &UniqueName) {
let mut lock = self.inner();
for (_,v) in &mut lock.items {
v.client_disconnected(client_name);
}
println!("client disconnected: {client_name}")
}
}
struct Inner {
connection: Connection,
next_index: u32,
items: HashMap<String, RealmFSItem>,
}
impl Inner {
fn new(connection: Connection) -> Self {
Inner {
connection,
next_index: 1,
items: HashMap::new(),
}
}
pub fn load(&mut self, manager: &RealmManager) -> zbus::Result<()> {
for realmfs in manager.realmfs_list() {
self.add_realmfs(realmfs)?;
}
Ok(())
}
fn add_realmfs(&mut self, realmfs: RealmFS) -> zbus::Result<()> {
if !self.items.contains_key(realmfs.name()) {
let name = realmfs.name().to_string();
let item = RealmFSItem::new_from_realmfs(self.next_index, realmfs);
self.connection.object_server().at(item.object_path(), item.clone())?;
self.items.insert(name, item);
self.next_index += 1;
} else {
warn!("Attempted to add duplicate realmfs '{}'", realmfs.name());
}
Ok(())
}
fn realmfs_by_name(&self, name: &str) -> Option<RealmFSItem> {
let res = self.items.get(name).cloned();
if res.is_none() {
warn!("Failed to find RealmFS with name '{}'", name);
}
res
}
}
}

261
realmsd/src/next/state.rs Normal file
View File

@@ -0,0 +1,261 @@
use crate::next::manager::failed;
use crate::next::realm::RealmItem;
use crate::next::realmfs::RealmFSItem;
use libcitadel::{Realm, RealmEvent, RealmFS, RealmManager, Result};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
use zbus::blocking::Connection;
use zbus::fdo;
use zbus::names::UniqueName;
use zbus::zvariant::{ObjectPath, Value};
/// Maintains a mapping of RealmFS names to the DBus object
/// index values for the corresponding RealmFS objects.
///
/// This is used in the Realm objects to look up the correct
/// realmfs object index for a realmfs name in the realm configuration.
///
#[derive(Clone)]
pub struct RealmFSNameToId(Arc<Mutex<HashMap<String, u32>>>);
impl RealmFSNameToId {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(HashMap::new())))
}
pub fn add(&mut self, name: &str, id: u32) {
self.0.lock().unwrap().insert(name.to_string(), id);
}
pub fn lookup(&self, name: &str) -> u32 {
match self.0.lock().unwrap().get(name) {
None => {
warn!("Failed to map realmfs name '{}' to an object index", name);
0
}
Some(&idx) => idx,
}
}
}
#[derive(Clone)]
pub struct RealmsManagerState(Arc<Mutex<StateInner>>);
impl RealmsManagerState {
pub fn new(connection: Connection) -> Self {
Self(Arc::new(Mutex::new(StateInner::new(connection))))
}
pub fn load(&self, manager: &RealmManager) -> zbus::Result<()> {
self.inner().load(manager)?;
self.add_event_handler(manager)
.map_err(|err| zbus::Error::Failure(err.to_string()))
}
fn inner(&self) -> MutexGuard<'_, StateInner> {
self.0.lock().unwrap()
}
fn add_event_handler(&self, manager: &RealmManager) -> Result<()> {
let state = self.clone();
manager.add_event_handler(move |ev| {
if let Err(err) = state.handle_event(ev) {
warn!("Failed to handle event {}: {:?}", ev, err);
}
});
manager.start_event_task()?;
Ok(())
}
fn on_starting(&self, realm: &Realm) -> fdo::Result<()>{
self.inner().realm_status_changed(realm, Some(true))
}
fn on_started(&self, realm: &Realm) -> fdo::Result<()>{
self.inner().realm_status_changed(realm, Some(false))
}
fn on_stopping(&self, realm: &Realm) -> fdo::Result<()> {
self.inner().realm_status_changed(realm, Some(true))
}
fn on_stopped(&self, realm: &Realm) -> fdo::Result<()>{
self.inner().realm_status_changed(realm, Some(false))
}
fn on_new(&self, realm: &Realm) -> fdo::Result<()>{
self.inner().add_realm(realm.clone())
}
fn on_removed(&self, realm: &Realm) -> fdo::Result<()>{
self.inner().remove_realm(realm)
}
fn on_current(&self, realm: Option<&Realm>) -> fdo::Result<()> {
self.inner().set_current_realm(realm)
}
fn handle_event(&self, ev: &RealmEvent) -> fdo::Result<()> {
match ev {
RealmEvent::Started(realm) => self.on_started(realm)?,
RealmEvent::Stopped(realm) => self.on_stopped(realm)?,
RealmEvent::New(realm) => self.on_new(realm)?,
RealmEvent::Removed(realm) => self.on_removed(realm)?,
RealmEvent::Current(realm) => self.on_current(realm.as_ref())?,
RealmEvent::Starting(realm) => self.on_starting(realm)?,
RealmEvent::Stopping(realm) => self.on_stopping(realm)?,
};
Ok(())
}
pub fn get_current(&self) -> Option<RealmItem> {
self.inner().current_realm.clone()
}
pub fn client_disconnected(&self, client_name: &UniqueName) {
self.inner().client_disconnected(client_name);
}
pub fn fork_realmfs(&self, name: &str, new_name: &str) -> fdo::Result<ObjectPath<'static>> {
self.inner().fork_realmfs(name, new_name)
}
}
struct StateInner {
connection: Connection,
next_realm_index: u32,
realm_items: HashMap<String, RealmItem>,
current_realm: Option<RealmItem>,
next_realmfs_index: u32,
realmfs_items: HashMap<String, RealmFSItem>,
realmfs_name_to_id: RealmFSNameToId,
}
impl StateInner {
fn new(connection: Connection) -> StateInner {
StateInner {
connection,
next_realm_index: 1,
realm_items: HashMap::new(),
current_realm: None,
next_realmfs_index: 1,
realmfs_items: HashMap::new(),
realmfs_name_to_id: RealmFSNameToId::new(),
}
}
fn load(&mut self, manager: &RealmManager) -> fdo::Result<()> {
for realmfs in manager.realmfs_list() {
self.add_realmfs(realmfs);
}
for realm in manager.realm_list() {
self.add_realm(realm)?;
}
Ok(())
}
fn add_realm(&mut self, realm: Realm) -> fdo::Result<()> {
if self.realm_items.contains_key(realm.name()) {
warn!("Attempted to add duplicate realm '{}'", realm.name());
return Ok(())
}
info!("Adding realm-{} with obj index {}", realm.name(), self.next_realm_index);
let key = realm.name().to_string();
let item = RealmItem::new_from_realm(self.next_realm_index, realm, self.realmfs_name_to_id.clone());
self.connection.object_server().at(item.path(), item.clone())?;
self.realm_items.insert(key, item);
self.next_realm_index += 1;
Ok(())
}
fn remove_realm(&mut self, realm: &Realm) -> fdo::Result<()> {
if let Some(item) = self.realm_items.remove(realm.name()) {
self.connection.object_server().remove::<RealmItem, &str>(item.path())?;
} else {
warn!("Failed to find realm to remove with name '{}'", realm.name());
}
Ok(())
}
fn add_realmfs(&mut self, realmfs: RealmFS) -> Option<RealmFSItem> {
if !self.realmfs_items.contains_key(realmfs.name()) {
info!("Adding realmfs-{} with object index {}", realmfs.name(), self.next_realmfs_index);
let name = realmfs.name().to_string();
let item = RealmFSItem::new_from_realmfs(self.next_realmfs_index, realmfs);
if let Err(err) = self.connection.object_server().at(item.object_path(), item.clone()) {
warn!("Failed to publish object at path {}: {} ", item.object_path(), err);
} else {
self.realmfs_items.insert(name.clone(), item);
self.realmfs_name_to_id.add(&name, self.next_realmfs_index);
self.next_realmfs_index += 1;
}
self.realmfs_by_name(&name)
} else {
warn!("Attempted to add duplicate realmfs '{}'", realmfs.name());
None
}
}
fn realmfs_by_name(&self, name: &str) -> Option<RealmFSItem> {
let res = self.realmfs_items.get(name).cloned();
if res.is_none() {
warn!("Failed to find RealmFS with name '{}'", name);
}
res
}
fn fork_realmfs(&mut self, name: &str, new_name: &str) -> fdo::Result<ObjectPath<'static>> {
let item = match self.realmfs_by_name(name) {
None => return Err(fdo::Error::Failed(format!("Could not fork {}-realmfs, realmfs not found", name))),
Some(item) => item,
};
let new_realmfs = item.realmfs().fork(new_name).
map_err(|err| fdo::Error::Failed(format!("Failed to fork realmfs-{} to '{}': {}", name, new_name, err)))?;
match self.add_realmfs(new_realmfs) {
None => Err(fdo::Error::Failed(format!("Failed adding new realmfs while forking realmfs-{} to {}", name, new_name))),
Some(new_item) => {
let path = new_item.object_path().to_owned();
Ok(path)
},
}
}
fn realm_by_name(&self, name: &str) -> Option<&RealmItem> {
let res = self.realm_items.get(name);
if res.is_none() {
warn!("Failed to find realm with name '{}'", name);
}
res
}
fn client_disconnected(&mut self, client_name: &UniqueName) {
for v in self.realmfs_items.values_mut() {
v.client_disconnected(client_name);
}
}
fn realm_status_changed(&self, realm: &Realm, transition: Option<bool>) -> fdo::Result<()> {
if let Some(realm) = self.realm_by_name(realm.name()) {
if let Some(transition) = transition {
realm.set_in_run_transition(transition);
}
realm.emit_property_changed(self.connection.inner(), "RunStatus", Value::U32(realm.get_run_status() as u32))?;
let timestamp = realm.realm().timestamp();
if timestamp != realm.last_timestamp() {
realm.set_last_timestamp(timestamp);
realm.emit_property_changed(self.connection.inner(), "Timestamp", Value::U64(timestamp as u64))?;
}
Ok(())
} else {
failed(format!("Unknown realm {}", realm.name()))
}
}
fn set_current_realm(&mut self, realm: Option<&Realm>) -> fdo::Result<()> {
if let Some(r) = self.current_realm.take() {
self.realm_status_changed(&r.realm(), None)?;
}
if let Some(realm) = realm {
self.realm_status_changed(realm, None)?;
if let Some(item) = self.realm_by_name(realm.name()) {
self.current_realm = Some(item.clone());
}
}
Ok(())
}
}