From b7d4f1e570f40e2c019f4f248300f20b654a6e83 Mon Sep 17 00:00:00 2001 From: Bruce Leidl Date: Tue, 2 Apr 2019 15:09:41 -0400 Subject: [PATCH] refactor of RealmFS into several components --- libcitadel/src/realmfs.rs | 224 ---------- libcitadel/src/realmfs/activator.rs | 365 ++++++++++++++++ libcitadel/src/realmfs/mod.rs | 10 + libcitadel/src/realmfs/mountpoint.rs | 129 ++++++ libcitadel/src/realmfs/realmfs.rs | 604 ++++++++++++++++++++++++++ libcitadel/src/realmfs/realmfs_set.rs | 70 +++ libcitadel/src/realmfs/resizer.rs | 183 ++++++++ libcitadel/src/realmfs/update.rs | 145 +++++++ 8 files changed, 1506 insertions(+), 224 deletions(-) delete mode 100644 libcitadel/src/realmfs.rs create mode 100644 libcitadel/src/realmfs/activator.rs create mode 100644 libcitadel/src/realmfs/mod.rs create mode 100644 libcitadel/src/realmfs/mountpoint.rs create mode 100644 libcitadel/src/realmfs/realmfs.rs create mode 100644 libcitadel/src/realmfs/realmfs_set.rs create mode 100644 libcitadel/src/realmfs/resizer.rs create mode 100644 libcitadel/src/realmfs/update.rs diff --git a/libcitadel/src/realmfs.rs b/libcitadel/src/realmfs.rs deleted file mode 100644 index b45d52d..0000000 --- a/libcitadel/src/realmfs.rs +++ /dev/null @@ -1,224 +0,0 @@ - -use std::path::{Path,PathBuf}; -use std::fs; -use std::io::Write; - -use crate::{ImageHeader,MetaInfo,Mount,Result,util,verity}; - -const BASE_PATH: &'static str = "/storage/realms/realmfs-images"; -const RUN_DIRECTORY: &str = "/run/citadel/images"; -const MAX_REALMFS_NAME_LEN: usize = 40; - - -pub struct RealmFS { - path: PathBuf, - mountpoint: PathBuf, - header: ImageHeader, - metainfo: MetaInfo, -} - -impl RealmFS { - - /// Locate a RealmFS image by name in the default location using the standard name convention - pub fn load_by_name(name: &str) -> Result { - if !util::is_valid_name(name, MAX_REALMFS_NAME_LEN) { - bail!("Invalid realmfs name '{}'", name); - } - let path = Path::new(BASE_PATH).join(format!("{}-realmfs.img", name)); - if !path.exists() { - bail!("No image found at {}", path.display()); - } - - RealmFS::load_from_path(path, name) - } - - pub fn named_image_exists(name: &str) -> bool { - if !util::is_valid_name(name, MAX_REALMFS_NAME_LEN) { - return false; - } - let path = Path::new(BASE_PATH).join(format!("{}-realmfs.img", name)); - path.exists() - } - - /// Load RealmFS image from an exact path. - pub fn load_from_path>(path: P, name: &str) -> Result { - let path = path.as_ref().to_owned(); - let header = ImageHeader::from_file(&path)?; - if !header.is_magic_valid() { - bail!("Image file {} does not have a valid header", path.display()); - } - let metainfo = header.metainfo()?; - let mountpoint = PathBuf::from(format!("{}/{}-realmfs.mountpoint", RUN_DIRECTORY, name)); - - Ok(RealmFS{ - path, - mountpoint, - header, - metainfo, - }) - } - - pub fn mount_rw(&mut self) -> Result<()> { - // XXX fail if already verity mounted? - // XXX strip dm-verity tree if present? - // XXX just remount if not verity mounted, but currently ro mounted? - self.mount(false) - } - - pub fn mount_ro(&mut self) -> Result<()> { - self.mount(true) - } - - fn mount(&mut self, read_only: bool) -> Result<()> { - let flags = if read_only { - Some("-oro") - } else { - Some("-orw") - }; - if !self.mountpoint.exists() { - fs::create_dir_all(self.mountpoint())?; - } - let loopdev = self.create_loopdev()?; - util::mount(&loopdev.to_string_lossy(), self.mountpoint(), flags) - } - - pub fn mount_verity(&self) -> Result<()> { - if self.is_mounted() { - bail!("RealmFS image is already mounted"); - } - if !self.is_sealed() { - bail!("Cannot verity mount RealmFS image because it's not sealed"); - } - if !self.mountpoint.exists() { - fs::create_dir_all(self.mountpoint())?; - } - let dev = self.setup_verity_device()?; - util::mount(&dev.to_string_lossy(), &self.mountpoint, Some("-oro")) - } - - fn setup_verity_device(&self) -> Result { - - // TODO verify signature - - if !self.header.has_flag(ImageHeader::FLAG_HASH_TREE) { - self.generate_verity()?; - } - verity::setup_image_device(&self.path, &self.metainfo) - } - - pub fn create_loopdev(&self) -> Result { - let args = format!("--offset 4096 -f --show {}", self.path.display()); - let output = util::exec_cmdline_with_output("/sbin/losetup", args)?; - Ok(PathBuf::from(output)) - } - - pub fn is_mounted(&self) -> bool { - match Mount::is_target_mounted(self.mountpoint()) { - Ok(val) => val, - Err(e) => { - warn!("Error reading /proc/mounts: {}", e); - false - } - } - } - - pub fn fork(&self, new_name: &str) -> Result { - if !util::is_valid_name(new_name, MAX_REALMFS_NAME_LEN) { - bail!("Invalid realmfs name '{}'", new_name); - } - - // during install the images have a different base directory - let mut new_path = self.path.clone(); - new_path.pop(); - new_path.push(format!("{}-realmfs.img", new_name)); - - if new_path.exists() { - bail!("RealmFS image for name {} already exists", new_name); - } - - let args = format!("--reflink=auto {} {}", self.path.display(), new_path.display()); - util::exec_cmdline("/usr/bin/cp", args)?; - - let header = ImageHeader::new(); - header.set_metainfo_bytes(&self.generate_fork_metainfo(new_name)); - header.write_header_to(&new_path)?; - - let realmfs = RealmFS::load_from_path(&new_path, new_name)?; - - // forking unseals since presumably the image is being forked to modify it - realmfs.truncate_verity()?; - Ok(realmfs) - } - - - fn generate_fork_metainfo(&self, name: &str) -> Vec { - let mut v = Vec::new(); - writeln!(v, "image-type = \"realmfs\"").unwrap(); - writeln!(v, "realmfs-name = \"{}\"", name).unwrap(); - writeln!(v, "nblocks = {}", self.metainfo.nblocks()).unwrap(); - v - } - - // Remove verity tree from image file by truncating file to the number of blocks in metainfo - fn truncate_verity(&self) -> Result<()> { - let verity_flag = self.header.has_flag(ImageHeader::FLAG_HASH_TREE); - if verity_flag { - self.header.clear_flag(ImageHeader::FLAG_HASH_TREE); - self.header.write_header_to(&self.path)?; - } - - let meta = self.path.metadata()?; - let expected = (self.metainfo.nblocks() + 1) * 4096; - let actual = meta.len() as usize; - - if actual > expected { - if !verity_flag { - warn!("RealmFS length was greater than length indicated by metainfo but FLAG_HASH_TREE not set"); - } - let f = fs::OpenOptions::new().write(true).open(&self.path)?; - f.set_len(expected as u64)?; - } else if actual < expected { - bail!("RealmFS image {} is shorter than length indicated by metainfo", self.path.display()); - } - if verity_flag { - warn!("FLAG_HASH_TREE was set but RealmFS image file length matched metainfo length"); - } - Ok(()) - } - - pub fn is_sealed(&self) -> bool { - !self.metainfo.verity_root().is_empty() - } - - fn generate_verity(&self) -> Result<()> { - self.truncate_verity()?; - verity::generate_image_hashtree(&self.path, &self.metainfo)?; - self.header.set_flag(ImageHeader::FLAG_HASH_TREE); - self.header.write_header_to(&self.path)?; - Ok(()) - } - - pub fn create_overlay(&self, basedir: &Path) -> Result { - if !self.is_mounted() { - bail!("Cannot create overlay until realmfs is mounted"); - } - let workdir = basedir.join("workdir"); - let upperdir = basedir.join("upperdir"); - let mountpoint = basedir.join("mountpoint"); - fs::create_dir_all(&workdir)?; - fs::create_dir_all(&upperdir)?; - fs::create_dir_all(&mountpoint)?; - let args = format!("-t overlay realmfs-overlay -olowerdir={},upperdir={},workdir={} {}", - self.mountpoint.display(), - upperdir.display(), - workdir.display(), - mountpoint.display()); - - util::exec_cmdline("/usr/bin/mount", args)?; - Ok(mountpoint) - } - - pub fn mountpoint(&self) -> &Path { - &self.mountpoint - } -} diff --git a/libcitadel/src/realmfs/activator.rs b/libcitadel/src/realmfs/activator.rs new file mode 100644 index 0000000..71fed64 --- /dev/null +++ b/libcitadel/src/realmfs/activator.rs @@ -0,0 +1,365 @@ +use std::collections::HashSet; +use std::path::Path; + +use crate::{RealmFS, Result, ImageHeader, CommandLine, PublicKey, LoopDevice}; +use crate::realmfs::mountpoint::Mountpoint; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use crate::verity::Verity; + +/// Holds the activation status for a RealmFS and provides a thread-safe +/// interface to it. +/// +/// If `state` is `None` then the RealmFS is not currently activated. +/// +pub struct ActivationState { + state: RwLock>>, +} + +impl ActivationState { + + pub fn new() -> Self { + let state = RwLock::new(None); + ActivationState { state } + } + + /// Load an unknown activation state for `realmfs` by examining + /// the state of the system to determine if the RealmFS is activated. + pub fn load(&self, realmfs: &RealmFS) { + let activation = if realmfs.is_sealed() { + let header = realmfs.header(); + let activator = VerityActivator::new(realmfs, header); + activator.activation() + } else { + let activator = LoopActivator::new(realmfs); + activator.activation() + }; + *self.state_mut() = activation.map(|a| Arc::new(a)) + } + + /// If currently activated return the corresponding `Activation` instance + /// otherwise return `None` + pub fn get(&self) -> Option> { + self.state().clone() + } + + /// Return `true` if currently activated. + pub fn is_activated(&self) -> bool { + self.state().is_some() + } + + /// Activate `realmfs` or if already activated return current `Activation`. + pub fn activate(&self, realmfs: &RealmFS) -> Result> { + let header = realmfs.header(); + let mut lock = self.state_mut(); + if let Some(ref activation) = *lock { + return Ok(activation.clone()); + } else { + let activation = self._activate(realmfs, header)?; + let activation = Arc::new(activation); + *lock = Some(activation.clone()); + Ok(activation) + } + } + + fn _activate(&self, realmfs: &RealmFS, header: &ImageHeader) -> Result { + if realmfs.is_sealed() { + let activator = VerityActivator::new(realmfs, header); + activator.activate() + } else { + let activator = LoopActivator::new(realmfs); + activator.activate() + } + } + + /// Deactivate `Activation` only if not in use. + /// + /// Returns `true` if state changes from activated to not-activated. + /// + pub fn deactivate(&self, active_set: &HashSet) -> Result { + let mut lock = self.state_mut(); + if let Some(ref activation) = *lock { + if activation.deactivate(active_set)? { + *lock = None; + return Ok(true); + } + } + Ok(false) + } + + /// Return `true` if an `Activation` exists and is currently in-use by some `Realm` + pub fn is_in_use(&self, active_set: &HashSet) -> bool { + self.state() + .as_ref() + .map(|a| a.in_use(active_set)) + .unwrap_or(false) + } + + fn state(&self) -> RwLockReadGuard>> { + self.state.read().unwrap() + } + + fn state_mut(&self) -> RwLockWriteGuard>>{ + self.state.write().unwrap() + } +} + +/// Represents a RealmFS in an activated state. The activation can be one of: +/// +/// `Activation::Loop` if the RealmFS is unsealed +/// `Activation::Verity` if the RealmFS is sealed +/// +#[derive(Debug)] +pub enum Activation { + /// + /// A RealmFS in the unsealed state is activated by creating a /dev/loop + /// device and mounting it twice as both a read-only and read-write tree. + /// + Loop { + ro_mountpoint: Mountpoint, + rw_mountpoint: Mountpoint, + device: LoopDevice, + }, + /// + /// A RealmFS in the sealed state is activated by configuring a dm-verity + /// device and mounting it. + /// `mountpoint` is the filesystem location at which the device is mounted. + /// `device` is a path to a device in /dev/mapper/ + /// + Verity { + mountpoint: Mountpoint, + device: String, + }, +} + +impl Activation { + + fn new_loop(ro_mountpoint: Mountpoint, rw_mountpoint: Mountpoint, device: LoopDevice) -> Self { + Activation::Loop { ro_mountpoint, rw_mountpoint, device } + } + + fn new_verity(mountpoint: Mountpoint, device: String) -> Self { + Activation::Verity{ mountpoint, device } + } + + /// Converts an entry read from RealmFS:RUN_DIRECTORY into an `Activation` instance. + /// + /// Return an `Activation` corresponding to `mountpoint` if valid activation exists. + /// + pub fn for_mountpoint(mountpoint: &Mountpoint) -> Option { + if mountpoint.tag() == "rw" || mountpoint.tag() == "ro" { + LoopDevice::find_mounted_loop(mountpoint.path()).map(|loopdev| { + let (ro,rw) = Mountpoint::new_loop_pair(mountpoint.realmfs()); + Activation::new_loop(ro, rw, loopdev) + }) + } else { + let device = Verity::device_name_for_mountpoint(mountpoint); + if Path::new("/dev/mapper").join(&device).exists() { + Some(Activation::new_verity(mountpoint.clone(), device)) + } else { + None + } + } + } + + /// Deactivate `Activation` only if not in use. + /// + /// Returns `true` if state changes from activated to not-activated. + /// + pub fn deactivate(&self, active_set: &HashSet) -> Result { + if !self.in_use(active_set) { + self._deactivate()?; + Ok(true) + } else { + Ok(false) + } + } + + fn _deactivate(&self) -> Result<()> { + match self { + Activation::Loop { ro_mountpoint, rw_mountpoint, device } => { + ro_mountpoint.deactivate()?; + rw_mountpoint.deactivate()?; + info!("Removing loop device {}", device); + device.detach() + }, + Activation::Verity { mountpoint, device } => { + mountpoint.deactivate()?; + Verity::close_device(&device) + }, + } + } + + /// Return `true` if `mp` is a `Mountpoint` belonging to this `Activation`. + pub fn is_mountpoint(&self, mp: &Mountpoint) -> bool { + match self { + Activation::Loop { ro_mountpoint, rw_mountpoint, ..} => { + mp == ro_mountpoint || mp == rw_mountpoint + }, + Activation::Verity { mountpoint, .. } => { + mp == mountpoint + } + } + } + + /// Return read-only `Mountpoint` for this `Activation` + pub fn mountpoint(&self) -> &Mountpoint { + match self { + Activation::Loop { ro_mountpoint, ..} => &ro_mountpoint, + Activation::Verity { mountpoint, ..} => &mountpoint, + } + } + + /// Return read-write `Mountpoint` if present for this `Activation` type. + pub fn mountpoint_rw(&self) -> Option<&Mountpoint> { + match self { + Activation::Loop { rw_mountpoint, ..} => Some(&rw_mountpoint), + Activation::Verity { .. } => None, + } + } + + + pub fn device(&self) -> &str{ + match self { + Activation::Loop { device, ..} => device.device_str(), + Activation::Verity { device, ..} => &device, + } + } + + /// Return `true` if `Activation` is currently in-use by some `Realm` + /// + /// `active_set` is a set of mountpoints needed to determine if an activation is + /// in use. This set is obtained by calling `active_mountpoints()` on a `RealmManager` + /// instance. + /// + pub fn in_use(&self, active_set: &HashSet) -> bool { + match self { + Activation::Loop {ro_mountpoint: ro, rw_mountpoint: rw, ..} => { + active_set.contains(ro) || active_set.contains(rw) + }, + Activation::Verity { mountpoint, ..} => { + active_set.contains(mountpoint) + }, + } + } +} + + +struct VerityActivator<'a> { + realmfs: &'a RealmFS, + header: &'a ImageHeader, +} + + +impl <'a> VerityActivator <'a> { + fn new(realmfs: &'a RealmFS, header: &'a ImageHeader) -> Self { + VerityActivator { realmfs, header } + } + + // Determine if `self.realmfs` is already activated by searching for verity mountpoint and + // device name. If found return an `Activation::Verity` + fn activation(&self) -> Option { + let mountpoint = self.mountpoint(); + if mountpoint.exists() { + let devname = Verity::device_name(&self.realmfs.metainfo()); + Some(Activation::new_verity(self.mountpoint(), devname)) + } else { + None + } + } + + // Perform a verity activation of `self.realmfs` and return an `Activation::Verity` + fn activate(&self) -> Result { + info!("Starting verity activation for {}", self.realmfs.name()); + let mountpoint = self.mountpoint(); + if !mountpoint.exists() { + mountpoint.create_dir()?; + } + let device_name = self.setup_verity_device()?; + info!("verity device created.."); + cmd!("/usr/bin/mount", "-oro /dev/mapper/{} {}", device_name, mountpoint)?; + + Ok(Activation::new_verity(mountpoint, device_name)) + } + + fn mountpoint(&self) -> Mountpoint { + Mountpoint::new(self.realmfs.name(), &self.realmfs.metainfo().verity_tag()) + } + + fn setup_verity_device(&self) -> Result { + if !CommandLine::nosignatures() { + self.verify_signature()?; + } + + if !self.header.has_flag(ImageHeader::FLAG_HASH_TREE) { + self.generate_verity()?; + } + Verity::new(self.realmfs.path()).setup(&self.header.metainfo()) + } + + fn generate_verity(&self) -> Result<()> { + info!("Generating verity hash tree"); + Verity::new(self.realmfs.path()).generate_image_hashtree(&self.header.metainfo())?; + info!("Writing header..."); + self.header.set_flag(ImageHeader::FLAG_HASH_TREE); + self.header.write_header_to(self.realmfs.path())?; + info!("Done generating verity hash tree"); + Ok(()) + } + + fn verify_signature(&self) -> Result<()> { + let pubkey = self.public_key()?; + if !self.realmfs.header().verify_signature(pubkey) { + bail!("header signature verification failed on realmfs image '{}'", self.realmfs.name()); + } + info!("header signature verified on realmfs image '{}'", self.realmfs.name()); + Ok(()) + } + + fn public_key(&self) -> Result { + let pubkey = if self.realmfs.metainfo().channel() == RealmFS::USER_KEYNAME { + self.realmfs.sealing_keys()?.public_key() + } else { + match self.realmfs.header().public_key()? { + Some(pubkey) => pubkey, + None => bail!("No public key available for channel {}", self.realmfs.metainfo().channel()), + } + }; + Ok(pubkey) + } +} + +struct LoopActivator<'a> { + realmfs: &'a RealmFS, +} + +impl <'a> LoopActivator<'a> { + fn new(realmfs: &'a RealmFS) -> Self { + LoopActivator{ realmfs } + } + + // Determine if `self.realmfs` is presently activated by searching for mountpoints. If + // loop activation mountpoints are present return an `Activation::Loop` + fn activation(&self) -> Option { + let (ro,rw) = Mountpoint::new_loop_pair(self.realmfs.name()); + if ro.exists() && rw.exists() { + Activation::for_mountpoint(&ro) + } else { + None + } + } + + // Perform a loop activation of `self.realmfs` and return an `Activation::Loop` + fn activate(&self) -> Result { + + let (ro,rw) = Mountpoint::new_loop_pair(self.realmfs.name()); + ro.create_dir()?; + rw.create_dir()?; + + let loopdev = LoopDevice::create(self.realmfs.path(), Some(4096), false)?; + + loopdev.mount_pair(rw.path(), ro.path())?; + + Ok(Activation::new_loop(ro, rw, loopdev)) + } +} + diff --git a/libcitadel/src/realmfs/mod.rs b/libcitadel/src/realmfs/mod.rs new file mode 100644 index 0000000..3b27cf3 --- /dev/null +++ b/libcitadel/src/realmfs/mod.rs @@ -0,0 +1,10 @@ +pub(crate) mod resizer; +mod activator; +mod mountpoint; +mod update; +pub(crate) mod realmfs_set; +mod realmfs; + +pub use self::realmfs::RealmFS; +pub use self::mountpoint::Mountpoint; +pub use self::activator::Activation; diff --git a/libcitadel/src/realmfs/mountpoint.rs b/libcitadel/src/realmfs/mountpoint.rs new file mode 100644 index 0000000..4634555 --- /dev/null +++ b/libcitadel/src/realmfs/mountpoint.rs @@ -0,0 +1,129 @@ +use std::fs::{self, DirEntry}; +use std::fmt; +use std::path::{PathBuf, Path}; + +use crate::{Result, RealmFS}; +use std::ffi::OsStr; + + +/// A RealmFS activation mountpoint +#[derive(Clone,Eq,PartialEq,Hash,Debug)] +pub struct Mountpoint(PathBuf); + +impl Mountpoint { + const UMOUNT: &'static str = "/usr/bin/umount"; + + /// Read `RealmFS::RUN_DIRECTORY` to collect all current mountpoints + /// and return them. + pub fn all_mountpoints() -> Result> { + let all = fs::read_dir(RealmFS::RUN_DIRECTORY)? + .flat_map(|e| e.ok()) + .map(Into::into) + .filter(Mountpoint::is_valid) + .collect(); + Ok(all) + } + + /// Return a read-only/read-write mountpoint pair. + pub fn new_loop_pair(realmfs: &str) -> (Self,Self) { + let ro = Self::new(realmfs, "ro"); + let rw = Self::new(realmfs, "rw"); + (ro, rw) + } + + /// Build a new `Mountpoint` from the provided realmfs `name` and `tag`. + /// + /// The directory name of the mountpoint will have the structure: + /// + /// realmfs-$name-$tag.mountpoint + /// + pub fn new(name: &str, tag: &str) -> Self { + let filename = format!("realmfs-{}-{}.mountpoint", name, tag); + Mountpoint(Path::new(RealmFS::RUN_DIRECTORY).join(filename)) + } + + pub fn exists(&self) -> bool { + self.0.exists() + } + + pub fn create_dir(&self) -> Result<()> { + fs::create_dir_all(self.path())?; + Ok(()) + } + + /// Deactivate this mountpoint by unmounting it and removing the directory. + pub fn deactivate(&self) -> Result<()> { + if self.exists() { + info!("Unmounting {} and removing directory", self); + cmd!(Self::UMOUNT, "{}", self)?; + fs::remove_dir(self.path())?; + } + Ok(()) + } + + /// Full `&Path` of mountpoint. + pub fn path(&self) -> &Path { + self.0.as_path() + } + + /// Name of RealmFS extracted from structure of directory filename. + pub fn realmfs(&self) -> &str { + self.field(1) + } + + /// Tag field extracted from structure of directory filename. + pub fn tag(&self) -> &str { + self.field(2) + } + + fn field(&self, n: usize) -> &str { + Self::filename_fields(self.path()) + .and_then(|mut fields| fields.nth(n)) + .expect(&format!("Failed to access field {} of mountpoint {}", n, self)) + } + + /// Return `true` if this instance is a `&Path` in `RealmFS::RUN_DIRECTORY` and + /// the filename has the expected structure. + pub fn is_valid(&self) -> bool { + self.path().starts_with(RealmFS::RUN_DIRECTORY) && self.has_valid_extention() && + Self::filename_fields(self.path()).map(|it| it.count() == 3).unwrap_or(false) + } + + fn has_valid_extention(&self) -> bool { + self.path().extension().map(|e| e == "mountpoint").unwrap_or(false) + } + + fn filename_fields(path: &Path) -> Option> { + Self::filename(path).map(|name| name.split("-")) + } + + fn filename(path: &Path) -> Option<&str> { + path.file_name() + .and_then(OsStr::to_str) + .map(|s| s.trim_end_matches(".mountpoint")) + } +} + +impl fmt::Display for Mountpoint { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0.to_str().unwrap()) + } +} + +impl From<&Path> for Mountpoint { + fn from(p: &Path) -> Self { + Mountpoint(p.to_path_buf()) + } +} + +impl From for Mountpoint { + fn from(p: PathBuf) -> Self { + Mountpoint(p) + } +} + +impl From for Mountpoint { + fn from(entry: DirEntry) -> Self { + Mountpoint(entry.path()) + } +} diff --git a/libcitadel/src/realmfs/realmfs.rs b/libcitadel/src/realmfs/realmfs.rs new file mode 100644 index 0000000..10309d1 --- /dev/null +++ b/libcitadel/src/realmfs/realmfs.rs @@ -0,0 +1,604 @@ +use std::ffi::OsStr; +use std::fs; +use std::io::Write; +use std::os::unix::fs::MetadataExt; +use std::path::{Path,PathBuf}; + +use sodiumoxide::randombytes::randombytes; +use hex; + +use crate::{CommandLine, ImageHeader, MetaInfo, Result, KeyRing, KeyPair, Signature, util, RealmManager}; + +use super::resizer::{ImageResizer,ResizeSize}; +use super::update::Update; +use crate::realmfs::resizer::Superblock; +use std::sync::{Arc, Weak}; +use super::activator::Activation; +use super::mountpoint::Mountpoint; +use crate::realmfs::activator::ActivationState; +use crate::verity::Verity; + +// Maximum length of a RealmFS name +const MAX_REALMFS_NAME_LEN: usize = 40; + +// The maximum number of backup copies the rotate() method will create +const NUM_BACKUPS: usize = 2; + +/// +/// Representation of a RealmFS disk image file. +/// +/// RealmFS images contain the root filesystem for one or more realms. A single RealmFS +/// image may be shared by multiple running realm instances. +/// +/// A RealmFS image can be in a state where it includes all the metadata needed to mount the +/// image with dm-verity to securely enforce read-only access to the image. An image in this state +/// is called 'sealed' and it may be signed either with regular channel keys or with a special +/// key generated upon installation and stored in the kernel keyring. +/// +/// An image which is not sealed is called 'unsealed'. In this state, the image can be mounted into +/// a realm with write access, but only one realm can write to the image. All other realms +/// use read-only views of the image. +/// +/// RealmFS images are normally stored in the directory `BASE_PATH` (/storage/realms/realmfs-images), +/// and images stored in this directory can be loaded by name rather than needing the exact path +/// to the image. +/// +#[derive(Clone)] +pub struct RealmFS { + // RealmFS name + name: Arc, + // path to RealmFS image file + path: Arc, + // current RealmFS image file header + header: Arc, + + activation_state: Arc, + + manager: Weak, +} + +impl RealmFS { + // Directory where RealmFS images are stored + pub const BASE_PATH: &'static str = "/storage/realms/realmfs-images"; + + // Directory where RealmFS mountpoints are created + pub const RUN_DIRECTORY: &'static str = "/run/citadel/realmfs"; + + // Name used to retrieve key by 'description' from kernel key storage + pub const USER_KEYNAME: &'static str = "realmfs-user"; + + /// Locate a RealmFS image by name in the default location using the standard name convention + pub fn load_by_name(name: &str) -> Result { + RealmFS::validate_name(name)?; + let path = RealmFS::image_path(name); + if !path.exists() { + bail!("No image found at {}", path.display()); + } + + RealmFS::load_from_path(path) + } + + /// Load RealmFS image from an exact path. + pub fn load_from_path(path: impl AsRef) -> Result { + Self::_load_from_path(path.as_ref(), true) + } + + fn _load_from_path(path: &Path, load_activation: bool) -> Result { + let path = Arc::new(path.to_owned()); + let header = RealmFS::load_realmfs_header(&path)?; + let name = header.metainfo().realmfs_name() + .expect("RealmFS does not have a name") + .to_owned(); + let name = Arc::new(name); + let header = Arc::new(header); + let manager = Weak::new(); + + let activation_state = Arc::new(ActivationState::new()); + + let realmfs = RealmFS { + name, path, header, activation_state, manager + }; + + if load_activation { + realmfs.load_activation(); + } + Ok(realmfs) + } + + pub fn set_manager(&mut self, manager: Arc) { + self.manager = Arc::downgrade(&manager); + } + + fn load_activation(&self) { + self.activation_state.load(self); + } + + pub fn manager(&self) -> Arc { + if let Some(manager) = self.manager.upgrade() { + manager + } else { + panic!("No manager set on realmfs {}", self.name); + } + } + + fn with_manager(&self, f: F) + where F: FnOnce(Arc) + { + if let Some(manager) = self.manager.upgrade() { + f(manager); + } + } + + pub fn is_valid_realmfs_image(path: impl AsRef) -> bool { + RealmFS::load_realmfs_header(path.as_ref()).is_ok() + } + + fn load_realmfs_header(path: &Path) -> Result { + let header = ImageHeader::from_file(path)?; + if !header.is_magic_valid() { + bail!("Image file {} does not have a valid header", path.display()); + } + let metainfo = header.metainfo(); + if metainfo.image_type() != "realmfs" { + bail!("Image file {} is not a realmfs image", path.display()); + } + match metainfo.realmfs_name() { + Some(name) => RealmFS::validate_name(name)?, + None => bail!("RealmFS image file {} does not have a 'realmfs-name' field", path.display()), + }; + Ok(header) + } + + /// Return an Error result if name is not valid. + fn validate_name(name: &str) -> Result<()> { + if RealmFS::is_valid_name(name) { + Ok(()) + } else { + Err(format_err!("Invalid realm name '{}'", name)) + } + } + + /// Return `true` if `name` is a valid name for a RealmFS. + /// + /// Valid names: + /// * Are 40 characters or less in length + /// * Have an alphabetic ascii letter as first character + /// * Contain only alphanumeric ascii characters or '-' (dash) + /// + pub fn is_valid_name(name: &str) -> bool { + util::is_valid_name(name, MAX_REALMFS_NAME_LEN) + } + + pub fn named_image_exists(name: &str) -> bool { + if !util::is_valid_name(name, MAX_REALMFS_NAME_LEN) { + return false; + } + RealmFS::is_valid_realmfs_image(RealmFS::image_path(name)) + } + + fn image_path(name: &str) -> PathBuf { + Path::new(RealmFS::BASE_PATH).join(format!("{}-realmfs.img", name)) + } + + /// Return the `Path` to this RealmFS image file. + pub fn path(&self) -> &Path { + self.path.as_ref() + } + + /// Return a new `PathBuf` based on the path of the current image by appending + /// the string `ext` as an extension to the filename. If the current filename + /// ends with '.img' then the specified extension is appended to this as '.img.ext' + /// otherwise it replaces any existing extension. + fn path_with_extension(&self, ext: &str) -> PathBuf { + if self.path.extension() == Some(OsStr::new("img")) { + self.path.with_extension(format!("img.{}", ext)) + } else { + self.path.with_extension(ext) + } + } + + /// Return a new `PathBuf` based on the path of the current image by replacing + /// the image filename with the specified name. + pub fn path_with_filename(&self, filename: impl AsRef) -> PathBuf { + let mut path = (*self.path).clone(); + path.pop(); + path.push(filename.as_ref()); + path + } + + /// Return the 'realmfs-name' metainfo field of this image. + pub fn name(&self) -> &str { + self.name.as_str() + } + + pub fn notes(&self) -> Option { + let path = self.path_with_extension("notes"); + if path.exists() { + return fs::read_to_string(path).ok(); + } + None + } + + pub fn save_notes(&self, notes: impl AsRef) -> Result<()> { + let path = self.path_with_extension("notes"); + let notes = notes.as_ref(); + if path.exists() && notes.is_empty() { + fs::remove_file(path)?; + } else { + fs::write(path, notes)?; + } + Ok(()) + } + + /// Return `MetaInfo` from image header of this RealmFS. + pub fn metainfo(&self) -> Arc { + self.header().metainfo() + } + + pub fn header(&self) -> &ImageHeader { + match self.header.reload_if_stale(self.path()) { + Ok(true) => self.load_activation(), + Err(e) => warn!("error reloading stale image header: {}", e), + _ => {}, + }; + &self.header + } + + pub fn is_user_realmfs(&self) -> bool { + !self.is_sealed() || self.metainfo().channel() == Self::USER_KEYNAME + } + + /// Return `true` if this RealmFS is 'activated'. + /// + /// A RealmFS is activated if the device for the image has been created and mounted. + /// Sealed images create dm-verity devices in /dev/mapper and unsealed images create + /// /dev/loop devices. + pub fn is_activated(&self) -> bool { + self.activation_state.is_activated() + } + + /// If this RealmFS is activated return `Activation` instance + pub fn activation(&self) -> Option> { + self.activation_state.get() + } + + /// Return `true` if RealmFS is activated and some Realm is currently using + /// it. A RealmFS which is in use cannot be deactivated. + pub fn is_in_use(&self) -> bool { + let active = self.manager().active_mountpoints(); + self.activation_state.is_in_use(&active) + } + + /// Activate this RealmFS image if not yet activated. + pub fn activate(&self) -> Result> { + if CommandLine::sealed() && !self.is_sealed() && !self.is_update_copy() { + bail!("Cannot activate unsealed realmfs '{}' because citadel.sealed is enabled", self.name()); + } + self.activation_state.activate(self) + } + + /// Deactivate this RealmFS image if currently activated, but not in use. + /// Return `true` if deactivation occurs. + pub fn deactivate(&self) -> Result { + let active = self.manager().active_mountpoints(); + self.activation_state.deactivate(&active) + } + + pub fn fork(&self, new_name: &str) -> Result { + self._fork(new_name, true) + } + + /// Create an unsealed copy of this RealmFS image with a new image name. + /// + pub fn fork_unsealed(&self, new_name: &str) -> Result { + RealmFS::validate_name(new_name)?; + info!("forking RealmFS image '{}' to new name '{}'", self.name(), new_name); + + let new_path = self.path_with_filename(format!("{}-realmfs.img", new_name)); + + if new_path.exists() { + bail!("RealmFS image for name {} already exists", new_name); + } + + let new_realmfs = self.copy_image(&new_path, new_name, false)?; + self.with_manager(|m| m.realmfs_added(&new_realmfs)); + Ok(new_realmfs) + } + + fn _fork(&self, new_name: &str, sealed_fork: bool) -> Result { + RealmFS::validate_name(new_name)?; + info!("forking RealmFS image '{}' to new name '{}'", self.name(), new_name); + let new_path = self.path_with_filename(format!("{}-realmfs.img", new_name)); + if new_path.exists() { + bail!("RealmFS image for name {} already exists", new_name); + } + + let new_realmfs = self.copy_image(&new_path, new_name, sealed_fork)?; + + self.with_manager(|m| m.realmfs_added(&new_realmfs)); + Ok(new_realmfs) + + } + + pub fn update(&self) -> Update { + Update::new(self) + } + + fn is_update_copy(&self) -> bool { + self.path().extension() == Some(OsStr::new("update")) + } + + pub(crate) fn update_copy(&self) -> Result { + let path = self.path_with_extension("update"); + let name = self.name().to_string() + "-update"; + self.copy_image(&path, &name, false) + } + + fn copy_image(&self, path: &Path, name: &str, sealed_copy: bool) -> Result { + if path.exists() { + bail!("Cannot create sealed copy because target path '{}' already exists", path.display()); + } + cmd!("/usr/bin/cp", "--reflink=auto {} {}", self.path.display(), path.display())?; + let mut realmfs = RealmFS::_load_from_path(path, false)?; + self.with_manager(|m| realmfs.set_manager(m)); + realmfs.name = Arc::new(name.to_owned()); + + let result = if sealed_copy { + realmfs.write_sealed_copy_header() + } else { + realmfs.unseal() + }; + + result.map_err(|e| + if let Err(e) = fs::remove_file(path) { + format_err!("failed to remove {} after realmfs fork/copy failed with: {}", path.display(), e) + } else { e })?; + + Ok(realmfs) + } + + fn write_sealed_copy_header(&self) -> Result<()> { + let keys = match self.sealing_keys() { + Ok(keys) => keys, + Err(err) => bail!("Cannot seal realmfs image, no sealing keys available: {}", err), + }; + let metainfo = self.metainfo(); + let metainfo_bytes = self.generate_sealed_metainfo(self.name(), metainfo.verity_salt(), metainfo.verity_root()); + let sig = keys.sign(&metainfo_bytes); + self.write_new_metainfo(metainfo_bytes, Some(sig)) + } + + /// Convert to unsealed RealmFS image by removing dm-verity metadata and hash tree + pub fn unseal(&self) -> Result<()> { + let bytes = RealmFS::generate_unsealed_metainfo(self.name(), self.metainfo().nblocks(), None); + self.write_new_metainfo(bytes, None)?; + if self.has_verity_tree() { + self.truncate_verity()?; + } + Ok(()) + } + + pub fn set_owner_realm(&self, owner_realm: &str) -> Result<()> { + if self.is_sealed() { + bail!("Cannot set owner realm because RealmFS is sealed"); + } + if let Some(activation) = self.activation() { + let rw_mountpoint = activation.mountpoint_rw() + .ok_or(format_err!("unsealed activation expected"))?; + if self.manager().active_mountpoints().contains(rw_mountpoint) { + bail!("Cannot set owner realm because RW mountpoint is in use (by current owner?)"); + } + } + let nblocks = self.metainfo().nblocks(); + self.update_unsealed_metainfo(self.name(), nblocks, Some(owner_realm.to_owned())) + } + + pub fn update_unsealed_metainfo(&self, name: &str, nblocks: usize, owner_realm: Option) -> Result<()> { + if self.is_sealed() { + bail!("Cannot update metainfo on sealed realmfs image"); + } + let metainfo_bytes = RealmFS::generate_unsealed_metainfo(name, nblocks, owner_realm); + self.write_new_metainfo(metainfo_bytes, None) + } + + fn write_new_metainfo(&self, bytes: Vec, sig: Option) -> Result<()> { + self.header.set_metainfo_bytes(&bytes)?; + if let Some(sig) = sig { + self.header.set_signature(sig.to_bytes())?; + } + self.header.write_header_to(self.path()) + } + + fn generate_unsealed_metainfo(name: &str, nblocks: usize, owner_realm: Option) -> Vec { + let mut v = Vec::new(); + writeln!(v, "image-type = \"realmfs\"").unwrap(); + writeln!(v, "realmfs-name = \"{}\"", name).unwrap(); + writeln!(v, "nblocks = {}", nblocks).unwrap(); + if let Some(owner) = owner_realm { + writeln!(v, "realmfs-owner = \"{}\"", owner).unwrap(); + } + v + } + + fn generate_sealed_metainfo(&self, name: &str, verity_salt: &str, verity_root: &str) -> Vec { + let mut v = RealmFS::generate_unsealed_metainfo(name, self.metainfo().nblocks(), None); + writeln!(v, "channel = \"{}\"", Self::USER_KEYNAME).unwrap(); + writeln!(v, "verity-salt = \"{}\"", verity_salt).unwrap(); + writeln!(v, "verity-root = \"{}\"", verity_root).unwrap(); + v + } + + // Remove verity tree from image file by truncating file to the number of blocks in metainfo + fn truncate_verity(&self) -> Result<()> { + let file_nblocks = self.file_nblocks()?; + let expected = self.metainfo_nblocks(); + + if self.has_verity_tree() { + let f = fs::OpenOptions::new().write(true).open(self.path())?; + let lock = self.header(); + lock.clear_flag(ImageHeader::FLAG_HASH_TREE); + lock.write_header(&f)?; + debug!("Removing appended dm-verity hash tree by truncating image from {} blocks to {} blocks", file_nblocks, expected); + f.set_len((expected * 4096) as u64)?; + } else if file_nblocks > expected { + warn!("RealmFS image size was greater than length indicated by metainfo.nblocks but FLAG_HASH_TREE not set"); + } + Ok(()) + } + + // Return the length in blocks of the actual image file on disk + fn file_nblocks(&self) -> Result { + let meta = self.path.metadata()?; + let len = meta.len() as usize; + if len % 4096 != 0 { + bail!("realmfs image file '{}' has size which is not a multiple of block size", self.path.display()); + } + let nblocks = len / 4096; + if nblocks < (self.metainfo().nblocks() + 1) { + bail!("realmfs image file '{}' has shorter length than nblocks field of image header", self.path.display()); + } + Ok(nblocks) + } + + fn has_verity_tree(&self) -> bool { + self.header().has_flag(ImageHeader::FLAG_HASH_TREE) + } + + pub fn is_sealed(&self) -> bool { + !self.metainfo().verity_root().is_empty() + } + + pub fn seal(&self, new_name: Option<&str>) -> Result<()> { + if self.is_sealed() { + info!("RealmFS {} is already sealed. Doing nothing.", self.name()); + return Ok(()) + } + + let keys = match self.sealing_keys() { + Ok(keys) => keys, + Err(err) => bail!("Cannot seal realmfs image, no sealing keys available: {}", err), + }; + + if self.is_activated() { + bail!("Cannot seal RealmFS because it is currently activated"); + } + + if self.has_verity_tree() { + warn!("unsealed RealmFS already has a verity hash tree, removing it"); + self.truncate_verity()?; + } + + let tmp = self.path_with_extension("sealing"); + if tmp.exists() { + info!("Temporary copy of realmfs image {} already exists, removing it.", self.name()); + fs::remove_file(&tmp)?; + } + + info!("Creating temporary copy of realmfs image"); + cmd!("/usr/bin/cp", "--reflink=auto {} {}", self.path.display(), tmp.display())?; + + let name = new_name.unwrap_or_else(|| self.name()); + + let mut realmfs = RealmFS::load_from_path(&tmp)?; + realmfs.set_manager(self.manager()); + + let finish = || { + realmfs.generate_sealing_verity(&keys, name)?; + verbose!("Rename {} to {}", self.path().display(), self.path_with_extension("old").display()); + fs::rename(self.path(), self.path_with_extension("old"))?; + verbose!("Rename {} to {}", realmfs.path().display(), self.path().display()); + fs::rename(realmfs.path(), self.path())?; + Ok(()) + }; + + if let Err(err) = finish() { + if tmp.exists() { + let _ = fs::remove_file(tmp); + } + return Err(err); + } + Ok(()) + } + + fn generate_sealing_verity(&self, keys: &KeyPair, name: &str) -> Result<()> { + info!("Generating verity hash tree for sealed realmfs ({})", self.path().display()); + let salt = hex::encode(randombytes(32)); + let output = Verity::new(self.path()).generate_image_hashtree_with_salt(&self.metainfo(), &salt)?; + let root_hash = output.root_hash() + .ok_or(format_err!("no root hash returned from verity format operation"))?; + info!("root hash is {}", output.root_hash().unwrap()); + + info!("Signing new image with user realmfs keys"); + let metainfo_bytes = self.generate_sealed_metainfo(name, &salt, &root_hash); + let sig = keys.sign(&metainfo_bytes); + + self.header().set_flag(ImageHeader::FLAG_HASH_TREE); + self.write_new_metainfo(metainfo_bytes, Some(sig)) + } + + pub fn has_sealing_keys(&self) -> bool { + self.sealing_keys().is_ok() + } + + pub fn sealing_keys(&self) -> Result { + KeyRing::get_kernel_keypair(Self::USER_KEYNAME) + } + + pub fn rotate(&self, new_file: &Path) -> Result<()> { + let backup = |n: usize| Path::new(RealmFS::BASE_PATH).join(format!("{}-realmfs.img.{}", self.name(), n)); + + for i in (1..NUM_BACKUPS).rev() { + let from = backup(i - 1); + if from.exists() { + fs::rename(from, backup(i))?; + } + } + fs::rename(self.path(), backup(0))?; + fs::rename(new_file, self.path())?; + Ok(()) + } + + pub fn auto_resize_size(&self) -> Option { + ImageResizer::auto_resize_size(self) + } + + pub fn resize_grow_to(&self, size: ResizeSize) -> Result<()> { + info!("Resizing to {} blocks", size.nblocks()); + ImageResizer::new(self).grow_to(size) + } + + pub fn resize_grow_by(&self, size: ResizeSize) -> Result<()> { + ImageResizer::new(self).grow_by(size) + } + + pub fn free_size_blocks(&self) -> Result { + let sb = Superblock::load(self.path(), 4096)?; + Ok(sb.free_block_count() as usize) + } + + pub fn allocated_size_blocks(&self) -> Result { + let meta = self.path().metadata()?; + Ok(meta.blocks() as usize / 8) + } + + /// Size of image file in blocks (including header block) based on metainfo `nblocks` field. + pub fn metainfo_nblocks(&self) -> usize { + self.metainfo().nblocks() + 1 + } + + /// Return `true` if mountpoint belongs to current `Activation` state of + /// this `RealmFS` + pub fn release_mountpoint(&self, mountpoint: &Mountpoint) -> bool { + let is_ours = self.activation() + .map(|a| a.is_mountpoint(mountpoint)) + .unwrap_or(false); + + if is_ours { + if let Err(e) = self.deactivate() { + warn!("error deactivating mountpoint: {}", e); + } + } + is_ours + } + +} diff --git a/libcitadel/src/realmfs/realmfs_set.rs b/libcitadel/src/realmfs/realmfs_set.rs new file mode 100644 index 0000000..07c6f5a --- /dev/null +++ b/libcitadel/src/realmfs/realmfs_set.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; +use crate::{RealmFS, RealmManager, Result}; +use std::sync::Arc; +use std::fs; + +pub struct RealmFSSet { + realmfs_map: HashMap, +} + +impl RealmFSSet { + + pub fn load() -> Result { + let mut realmfs_map = HashMap::new(); + for realmfs in Self::load_all()? { + let name = realmfs.name().to_string(); + realmfs_map.insert(name, realmfs); + } + Ok( RealmFSSet { realmfs_map }) + } + + fn load_all() -> Result> { + let mut v = Vec::new(); + for entry in fs::read_dir(RealmFS::BASE_PATH)? { + if let Some(realmfs) = Self::entry_to_realmfs(entry?) { + v.push(realmfs) + } + } + Ok(v) + } + + fn entry_to_realmfs(entry: fs::DirEntry) -> Option { + if let Ok(filename) = entry.file_name().into_string() { + if filename.ends_with("-realmfs.img") { + 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(); + } + } + } + None + } + + pub fn set_manager(&mut self, manager: Arc) { + self.realmfs_map.iter_mut().for_each(|(_,v)| v.set_manager(manager.clone())) + } + + pub fn by_name(&self, name: &str) -> Option { + self.realmfs_map.get(name).cloned() + } + + pub fn add(&mut self, realmfs: &RealmFS) { + if !self.realmfs_map.contains_key(realmfs.name()) { + self.realmfs_map.insert(realmfs.name().to_string(), realmfs.clone()); + } + } + + pub fn remove(&mut self, name: &str) -> Option { + self.realmfs_map.remove(name) + } + + pub fn name_exists(&self, name: &str) -> bool { + self.realmfs_map.contains_key(name) + } + + pub fn realmfs_list(&self) -> Vec { + let mut v = self.realmfs_map.values().cloned().collect::>(); + v.sort_unstable_by(|a,b| a.name().cmp(&b.name())); + v + } +} \ No newline at end of file diff --git a/libcitadel/src/realmfs/resizer.rs b/libcitadel/src/realmfs/resizer.rs new file mode 100644 index 0000000..1449075 --- /dev/null +++ b/libcitadel/src/realmfs/resizer.rs @@ -0,0 +1,183 @@ +use std::fs::{File,OpenOptions}; +use std::io::{Read,Seek,SeekFrom}; +use std::path::Path; + +use byteorder::{ByteOrder,LittleEndian}; + +use crate::{RealmFS,Result,LoopDevice}; + +const BLOCK_SIZE: usize = 4096; +const BLOCKS_PER_MEG: usize = (1024 * 1024) / BLOCK_SIZE; +const BLOCKS_PER_GIG: usize = 1024 * BLOCKS_PER_MEG; + +const RESIZE2FS: &str = "resize2fs"; + +// If less than 1gb remaining space +const AUTO_RESIZE_MINIMUM_FREE: ResizeSize = ResizeSize(1 * BLOCKS_PER_GIG); +// ... add 4gb to size of image +const AUTO_RESIZE_INCREASE_SIZE: ResizeSize = ResizeSize(4 * BLOCKS_PER_GIG); + +pub struct ImageResizer<'a> { + image: &'a RealmFS, +} + +pub struct ResizeSize(usize); + +impl ResizeSize { + + pub fn gigs(n: usize) -> ResizeSize { + ResizeSize(BLOCKS_PER_GIG * n) + + } + pub fn megs(n: usize) -> ResizeSize { + ResizeSize(BLOCKS_PER_MEG * n) + } + + pub fn blocks(n: usize) -> ResizeSize { + ResizeSize(n) + } + + pub fn nblocks(&self) -> usize { + self.0 + } + + pub fn size_in_gb(&self) -> usize { + self.0 / BLOCKS_PER_GIG + } + + pub fn size_in_mb(&self) -> usize { + self.0 / BLOCKS_PER_MEG + } +} + +impl <'a> ImageResizer<'a> { + + pub fn new(image: &'a RealmFS) -> ImageResizer<'a> { + ImageResizer { image } + } + + pub fn grow_to(&mut self, size: ResizeSize) -> Result<()> { + let target_nblocks = size.nblocks(); + let current_nblocks = self.image.metainfo_nblocks(); + if current_nblocks >= target_nblocks { + info!("RealmFS image is already larger than requested size, doing nothing"); + } else { + let size = ResizeSize::blocks(target_nblocks - current_nblocks); + self.grow_by(size)?; + } + Ok(()) + } + + pub fn grow_by(&mut self, size: ResizeSize) -> Result<()> { + let nblocks = size.nblocks(); + let new_nblocks = self.image.metainfo_nblocks() + nblocks; + if self.image.is_sealed() { + bail!("Cannot resize sealed image '{}'. unseal first", self.image.name()); + } + self.resize(new_nblocks) + } + + fn resize(&self, new_nblocks: usize) -> Result<()> { + if new_nblocks < self.image.metainfo_nblocks() { + bail!("Cannot shrink image") + } + + if (new_nblocks - self.image.metainfo_nblocks()) > ResizeSize::gigs(8).nblocks() { + bail!("Can only increase size of RealmFS image by a maximum of 8gb at one time"); + } + + ImageResizer::resize_image_file(self.image.path(), new_nblocks)?; + + if let Some(open_loop) = self.notify_open_loops()? { + info!("Running resize2fs {:?}", open_loop); + cmd!(RESIZE2FS, "{}", open_loop.device().display())?; + } else { + LoopDevice::with_loop(self.image.path(), Some(4096), false, |loopdev| { + info!("Running resize2fs {:?}", loopdev); + cmd!(RESIZE2FS, "{}", loopdev.device().display())?; + Ok(()) + })?; + } + let owner = self.image.metainfo().realmfs_owner().map(|s| s.to_owned()); + self.image.update_unsealed_metainfo(self.image.name(), new_nblocks - 1, owner)?; + Ok(()) + } + + fn resize_image_file(file: &Path, nblocks: usize) -> Result<()> { + let len = nblocks * BLOCK_SIZE; + info!("Resizing image file to {}", len); + OpenOptions::new() + .write(true) + .open(file)? + .set_len(len as u64)?; + Ok(()) + } + + fn notify_open_loops(&self) -> Result> { + let mut open_loop = None; + for loopdev in LoopDevice::find_devices_for(self.image.path())? { + loopdev.resize() + .unwrap_or_else(|err| warn!("Error running losetup -c {:?}: {}", loopdev, err)); + open_loop = Some(loopdev); + } + Ok(open_loop) + } + + + /// If the RealmFS needs to be resized to a larger size, returns the + /// recommended size. Pass this value to `ImageResizer.grow_to()` to + /// complete the resize. + pub fn auto_resize_size(realmfs: &RealmFS) -> Option { + let sb = match Superblock::load(realmfs.path(), 4096) { + Ok(sb) => sb, + Err(e) => { + warn!("Error reading superblock from {}: {}", realmfs.path().display(), e); + return None; + }, + }; + + sb.free_block_count(); + let free_blocks = sb.free_block_count() as usize; + if free_blocks < AUTO_RESIZE_MINIMUM_FREE.nblocks() { + let mask = AUTO_RESIZE_INCREASE_SIZE.nblocks() - 1; + let grow_blocks = (free_blocks + mask) & !mask; + Some(ResizeSize::blocks(grow_blocks)) + } else { + None + } + } +} + +const SUPERBLOCK_SIZE: usize = 1024; +pub struct Superblock([u8; SUPERBLOCK_SIZE]); +impl Superblock { + fn new() -> Superblock { + Superblock([0u8; SUPERBLOCK_SIZE]) + } + + pub fn load(path: impl AsRef, offset: u64) -> Result { + let mut sb = Superblock::new(); + let mut file = File::open(path.as_ref())?; + file.seek(SeekFrom::Start(1024 + offset))?; + file.read_exact(&mut sb.0)?; + Ok(sb) + } + + pub fn free_block_count(&self) -> u64 { + self.split_u64(0x0C, 0x158) + } + + fn u32(&self, offset: usize) -> u32 { + LittleEndian::read_u32(self.at(offset)) + } + + fn split_u64(&self, offset_lo: usize, offset_hi: usize) -> u64 { + let lo = self.u32(offset_lo) as u64; + let hi = self.u32(offset_hi) as u64; + (hi << 32) | lo + } + + fn at(&self, offset: usize) -> &[u8] { + &self.0[offset..] + } +} diff --git a/libcitadel/src/realmfs/update.rs b/libcitadel/src/realmfs/update.rs new file mode 100644 index 0000000..29b5f7e --- /dev/null +++ b/libcitadel/src/realmfs/update.rs @@ -0,0 +1,145 @@ +use std::fs; +use std::process::Command; + +use crate::{Result, RealmFS }; +use crate::realmfs::Mountpoint; +use crate::realm::BridgeAllocator; +use crate::ResizeSize; + +enum UpdateType { + NotSetup, + Sealed(RealmFS), + Unsealed, +} + +pub struct Update<'a> { + realmfs: &'a RealmFS, + network_allocated: bool, + update_type: UpdateType, +} + +impl <'a> Update<'a> { + pub fn new(realmfs: &'a RealmFS) -> Self { + Update { realmfs, network_allocated: false, update_type: UpdateType::NotSetup } + } + + pub fn setup(&mut self) -> Result<()> { + self.update_type = self.create_update_type()?; + Ok(()) + } + + pub fn auto_resize_size(&self) -> Option { + self.target_image().auto_resize_size() + } + + pub fn apply_resize(&self, size: ResizeSize) -> Result<()> { + self.target_image().resize_grow_to(size) + } + + fn target_image(&self) -> &RealmFS { + if let UpdateType::Sealed(ref image) = self.update_type { + image + } else { + &self.realmfs + } + } + + fn create_update_type(&self) -> Result { + if self.realmfs.is_sealed() { + let update_image = self.realmfs.update_copy()?; + Ok(UpdateType::Sealed(update_image)) + } else { + Ok(UpdateType::Unsealed) + } + } + + pub fn open_update_shell(&mut self) -> Result<()> { + self.run_update_shell("/usr/libexec/configure-host0.sh && exec /bin/bash") + } + + fn mountpoint(&self) -> Result { + let target = self.target_image(); + let activation = target.activate() + .map_err(|e| format_err!("failed to activate update image: {}", e))?; + + activation.mountpoint_rw().cloned() + .ok_or(format_err!("Update image activation does not have a writeable mountpoint")) + } + + pub fn run_update_shell(&mut self, command: &str) -> Result<()> { + + let mountpoint = self.mountpoint().map_err(|e| { + let _ = self.cleanup(); + format_err!("Could not run update shell: {}", e) + })?; + + let mut alloc = BridgeAllocator::default_bridge()?; + let addr = alloc.allocate_address_for(&self.name())?; + let gw = alloc.gateway(); + self.network_allocated = true; + Command::new("/usr/bin/systemd-nspawn") + .arg(format!("--setenv=IFCONFIG_IP={}", addr)) + .arg(format!("--setenv=IFCONFIG_GW={}", gw)) + .arg("--quiet") + .arg(format!("--machine={}", self.name())) + .arg(format!("--directory={}", mountpoint)) + .arg(format!("--network-zone=clear")) + .arg("/bin/bash") + .arg("-c") + .arg(command) + .status() + .map_err(|e| { + let _ = self.cleanup(); + e + })?; + self.deactivate_update()?; + Ok(()) + } + + fn deactivate_update(&self) -> Result<()> { + match self.update_type { + UpdateType::Sealed(ref update_image) => update_image.deactivate()?, + UpdateType::Unsealed => self.realmfs.deactivate()?, + UpdateType::NotSetup => return Ok(()), + }; + Ok(()) + } + pub fn apply_update(&mut self) -> Result<()> { + match self.update_type { + UpdateType::Sealed(ref update_image) => { + update_image.seal(Some(self.realmfs.name()))?; + fs::rename(update_image.path(), self.realmfs.path())?; + self.cleanup() + }, + UpdateType::Unsealed => self.cleanup(), + UpdateType::NotSetup => Ok(()), + } + } + + fn name(&self) -> String { + format!("{}-update", self.realmfs.name()) + } + + pub fn cleanup(&mut self) -> Result<()> { + match self.update_type { + UpdateType::Sealed(ref update_image) => { + update_image.deactivate()?; + if update_image.path().exists() { + fs::remove_file(update_image.path())?; + } + }, + UpdateType::Unsealed => { + self.realmfs.deactivate()?; + } + _ => {}, + } + self.update_type = UpdateType::NotSetup; + + if self.network_allocated { + BridgeAllocator::default_bridge()? + .free_allocation_for(&self.name())?; + self.network_allocated = false; + } + Ok(()) + } +}