diff --git a/libcitadel/src/realm/config.rs b/libcitadel/src/realm/config.rs new file mode 100644 index 0000000..0a4b978 --- /dev/null +++ b/libcitadel/src/realm/config.rs @@ -0,0 +1,451 @@ +use std::path::{Path, PathBuf}; +use std::fs; +use std::os::unix::fs::MetadataExt; +use toml; +use crate::{Result, Realms}; + +lazy_static! { + pub static ref GLOBAL_CONFIG: RealmConfig = RealmConfig::load_global_config(); +} + +const DEFAULT_ZONE: &str = "clear"; +const DEFAULT_REALMFS: &str = "base"; +const DEFAULT_OVERLAY: &str = "storage"; + +/// Type of rootfs overlay a Realm is configured to use +#[derive(PartialEq,Debug,Copy,Clone)] +pub enum OverlayType { + /// Don't use a rootfs overlay + None, + /// Use a rootfs overlay stored on tmpfs + TmpFS, + /// Use a rootfs overlay stored in a btrfs subvolume + Storage, +} + +impl OverlayType { + pub fn from_str_value(value: &str) -> OverlayType { + if value == "tmpfs" { + OverlayType::TmpFS + } else if value == "storage" { + OverlayType::Storage + } else { + warn!("Invalid overlay type: '{}'", value); + OverlayType::None + } + } + + pub fn to_str_value(&self) -> Option<&'static str> { + match self { + OverlayType::None => None, + OverlayType::TmpFS => Some("tmpfs"), + OverlayType::Storage => Some("storage"), + } + } +} + +/// Content of a Realm configuration file +#[derive (Serialize,Deserialize,Clone)] +pub struct RealmConfig { + #[serde(rename="use-shared-dir")] + pub use_shared_dir: Option, + + #[serde(rename="use-ephemeral-home")] + pub use_ephemeral_home: Option, + + #[serde(rename="ephemeral-persistent-dirs")] + pub ephemeral_persistent_dirs: Option>, + + #[serde(rename="use-sound")] + pub use_sound: Option, + + #[serde(rename="use-x11")] + pub use_x11: Option, + + #[serde(rename="use-wayland")] + pub use_wayland: Option, + + #[serde(rename="use-kvm")] + pub use_kvm: Option, + + #[serde(rename="use-gpu")] + pub use_gpu: Option, + + #[serde(rename="use-gpu-card0")] + pub use_gpu_card0: Option, + + #[serde(rename="use-network")] + pub use_network: Option, + + #[serde(rename="network-zone")] + pub network_zone: Option, + + #[serde(rename="reserved-ip")] + pub reserved_ip: Option, + + #[serde(rename="system-realm")] + pub system_realm: Option, + + pub autostart: Option, + + #[serde(rename="extra-bindmounts")] + pub extra_bindmounts: Option>, + + #[serde(rename="extra-bindmounts-ro")] + pub extra_bindmounts_ro: Option>, + + #[serde(rename="realm-depends")] + pub realm_depends: Option>, + + pub realmfs: Option, + + #[serde(rename="realmfs-write")] + pub realmfs_write: Option, + + #[serde(rename="terminal-scheme")] + pub terminal_scheme: Option, + + pub overlay: Option, + + pub netns: Option, + + #[serde(skip)] + pub parent: Option>, + + #[serde(skip)] + loaded: Option, + + #[serde(skip)] + path: PathBuf, +} + +impl RealmConfig { + + /// Return an 'unloaded' realm config instance. + pub fn unloaded_realm_config(realm_name: &str) -> RealmConfig { + let path = Path::new(Realms::BASE_PATH) + .join(format!("realm-{}", realm_name)) + .join("config"); + + let mut config = RealmConfig::empty(); + config.path = path; + config + } + + fn load_global_config() -> RealmConfig { + if let Some(mut global) = RealmConfig::load_config("/storage/realms/config") { + global.parent = Some(Box::new(RealmConfig::default())); + return global; + } + RealmConfig::default() + } + + fn load_config>(path: P) -> Option { + if path.as_ref().exists() { + match fs::read_to_string(path.as_ref()) { + Ok(s) => return toml::from_str::(&s).ok(), + Err(e) => warn!("Error reading config file: {}", e), + } + } + None + } + + pub fn write_config>(&self, path: P) -> Result<()> { + let serialized = toml::to_string(self)?; + fs::write(path.as_ref(), serialized)?; + Ok(()) + } + + pub fn write(&self) -> Result<()> { + let serialized = toml::to_string(self)?; + fs::write(&self.path, serialized)?; + Ok(()) + } + + fn read_mtime(&self) -> i64 { + self.path.metadata().map(|meta| meta.mtime()).unwrap_or(0) + } + + pub fn is_stale(&self) -> bool { + Some(self.read_mtime()) != self.loaded + } + + pub fn reload(&mut self) -> Result<()> { + let path = self.path.clone(); + + if self.path.exists() { + let s = fs::read_to_string(&self.path)?; + *self = toml::from_str(&s)?; + } else { + *self = RealmConfig::empty(); + } + self.path = path; + self.loaded = Some(self.read_mtime()); + self.parent = Some(Box::new(GLOBAL_CONFIG.clone())); + Ok(()) + } + + pub fn default() -> RealmConfig { + RealmConfig { + use_shared_dir: Some(true), + use_ephemeral_home: Some(false), + use_sound: Some(true), + use_x11: Some(true), + use_wayland: Some(true), + use_kvm: Some(false), + use_gpu: Some(false), + use_gpu_card0: Some(false), + use_network: Some(true), + ephemeral_persistent_dirs: Some(vec!["Documents".to_string()]), + network_zone: Some(DEFAULT_ZONE.into()), + reserved_ip: None, + system_realm: Some(false), + autostart: Some(false), + extra_bindmounts: None, + extra_bindmounts_ro: None, + realm_depends: None, + realmfs: Some(DEFAULT_REALMFS.into()), + realmfs_write: Some(false), + overlay: Some(DEFAULT_OVERLAY.into()), + terminal_scheme: None, + netns: None, + parent: None, + loaded: None, + path: PathBuf::new(), + } + } + + pub fn empty() -> RealmConfig { + RealmConfig { + use_shared_dir: None, + use_ephemeral_home: None, + use_sound: None, + use_x11: None, + use_wayland: None, + use_kvm: None, + use_gpu: None, + use_gpu_card0: None, + use_network: None, + network_zone: None, + reserved_ip: None, + system_realm: None, + autostart: None, + extra_bindmounts: None, + extra_bindmounts_ro: None, + realm_depends: None, + ephemeral_persistent_dirs: None, + realmfs: None, + realmfs_write: None, + overlay: None, + terminal_scheme: None, + netns: None, + parent: None, + loaded: None, + path: PathBuf::new(), + } + } + + /// If `true` device /dev/kvm will be added to realm + /// + /// This allows use of tools such as Qemu. + pub fn kvm(&self) -> bool { + self.bool_value(|c| c.use_kvm) + } + + + + /// If `true` render node device /dev/dri/renderD128 will be added to realm. + /// + /// This enables hardware graphics acceleration in realm. + pub fn gpu(&self) -> bool { + self.bool_value(|c| c.use_gpu) + } + + /// If `true` and `self.gpu()` is also true, privileged device /dev/dri/card0 will be + /// added to realm. + pub fn gpu_card0(&self) -> bool { + self.bool_value(|c| c.use_gpu_card0) + } + + /// If `true` the /Shared directory will be mounted in home directory of realm. + /// + /// This directory is shared between all running realms and is an easy way to move files + /// between realms. + pub fn shared_dir(&self) -> bool { + self.bool_value(|c| c.use_shared_dir) + } + + /// If `true` the home directory of this realm will be set up in ephemeral mode. + /// + /// The ephemeral home directory is set up with the following steps: + /// + /// 1. Home directory is mounted as tmpfs + /// 2. Any files in /realms/skel are copied into home directory + /// 3. Any files in /realms/realm-${name}/skel are copied into home directory + /// 4. Any directories listed in `self.ephemeral_psersistent_dirs()` are bind + /// mounted from /realms/realm-${name}/home into ephemeral home directory. + /// + pub fn ephemeral_home(&self) -> bool { + self.bool_value(|c| c.use_ephemeral_home) + } + + /// A list of subdirectories of /realms/realm-${name}/home to bind mount into realm + /// home directory when ephemeral-home is enabled. + pub fn ephemeral_persistent_dirs(&self) -> Vec { + if let Some(ref dirs) = self.ephemeral_persistent_dirs { + return dirs.clone() + } + if let Some(ref parent) = self.parent { + return parent.ephemeral_persistent_dirs(); + } + Vec::new() + } + + /// If `true` allows use of sound inside realm. The following items will be + /// added to realm: + /// + /// /dev/snd + /// /dev/shm + /// /run/user/1000/pulse + pub fn sound(&self) -> bool { + self.bool_value(|c| c.use_sound) + } + + /// If `true` access to the X11 server will be added to realm by bind mounting + /// directory /tmp/.X11-unix + pub fn x11(&self) -> bool { + self.bool_value(|c| { + c.use_x11 + }) + } + + /// If `true` access to Wayland display will be permitted in realm by adding + /// wayland socket /run/user/1000/wayland-0 + pub fn wayland(&self) -> bool { + self.bool_value(|c| c.use_wayland) + } + + /// If `true` the realm will have access to the network through the zone specified + /// by `self.network_zone()` + pub fn network(&self) -> bool { + self.bool_value(|c| c.use_network) + } + + /// The name of the network zone this realm will use if `self.network()` is `true`. + pub fn network_zone(&self) -> &str { + self.str_value(|c| c.network_zone.as_ref()).unwrap_or(DEFAULT_ZONE) + } + + + /// If configured, this realm uses a fixed IP address on the zone subnet. The last + /// octet of the network address for this realm will be set to the provided value. + pub fn reserved_ip(&self) -> Option { + if let Some(n) = self.reserved_ip { + Some(n as u8) + } else if let Some(ref parent) = self.parent { + parent.reserved_ip() + } else { + None + } + } + + /// If `true` this realm is a system utility realm and should not be displayed + /// in the usual list of user realms. + pub fn system_realm(&self) -> bool { + self.bool_value(|c| c.system_realm) + } + + /// If `true` this realm will be automatically started at boot. + pub fn autostart(&self) -> bool { + self.bool_value(|c| c.autostart) + } + + /// A list of additional directories to read-write bind mount into realm. + pub fn extra_bindmounts(&self) -> Vec<&str> { + self.str_vec_value(|c| c.extra_bindmounts.as_ref()) + } + + /// A list of additional directories to read-only bind mount into realm. + pub fn extra_bindmounts_ro(&self) -> Vec<&str> { + self.str_vec_value(|c| c.extra_bindmounts_ro.as_ref()) + } + + /// A list of names of realms this realm depends on. When this realm is started + /// these realms will also be started if not already running. + pub fn realm_depends(&self) -> Vec<&str> { + self.str_vec_value(|c| c.realm_depends.as_ref()) + } + + /// The name of a RealmFS to use as the root filesystem for this realm. + pub fn realmfs(&self) -> &str { + self.str_value(|c| c.realmfs.as_ref()).unwrap_or(DEFAULT_REALMFS) + } + + pub fn realmfs_write(&self) -> bool { + self.bool_value(|c| c.realmfs_write) + } + + + /// Name of a terminal color scheme to use in this realm. + pub fn terminal_scheme(&self) -> Option<&str> { + self.str_value(|c| c.terminal_scheme.as_ref()) + } + + /// The type of overlay on root filesystem to set up for this realm. + pub fn overlay(&self) -> OverlayType { + self.str_value(|c| c.overlay.as_ref()) + .map(OverlayType::from_str_value) + .unwrap_or(OverlayType::None) + } + + /// Set the overlay string variable according to the `OverlayType` argument. + pub fn set_overlay(&mut self, overlay: OverlayType) { + self.overlay = overlay.to_str_value().map(String::from) + } + + + pub fn netns(&self) -> Option<&str> { + self.str_value(|c| c.netns.as_ref()) + } + + pub fn has_netns(&self) -> bool { + self.netns().is_some() + } + + fn str_vec_value(&self, get: F) -> Vec<&str> + where F: Fn(&RealmConfig) -> Option<&Vec> + { + if let Some(val) = get(self) { + val.iter().map(|s| s.as_str()).collect() + } else if let Some(ref parent) = self.parent { + parent.str_vec_value(get) + } else { + Vec::new() + } + } + + fn str_value(&self, get: F) -> Option<&str> + where F: Fn(&RealmConfig) -> Option<&String> + { + if let Some(val) = get(self) { + return Some(val) + } + if let Some(ref parent) = self.parent { + return parent.str_value(get); + } + None + } + + fn bool_value(&self, get: F) -> bool + where F: Fn(&RealmConfig) -> Option + { + if let Some(val) = get(self) { + return val + } + + if let Some(ref parent) = self.parent { + return parent.bool_value(get) + } + false + } +} diff --git a/libcitadel/src/realm/create.rs b/libcitadel/src/realm/create.rs new file mode 100644 index 0000000..6e49be7 --- /dev/null +++ b/libcitadel/src/realm/create.rs @@ -0,0 +1,139 @@ +use std::path::{PathBuf, Path}; +use crate::{Realms, Result, util}; +use std::fs; + +/// Creation and removal of a Realm +pub struct RealmCreateDestroy { + name: String, +} + +impl RealmCreateDestroy { + + pub fn new(name: &str) -> Self { + let name = name.to_string(); + RealmCreateDestroy { name } + } + + fn tmpdir() -> PathBuf { + Path::new(Realms::BASE_PATH).join(".tmp") + } + + pub fn temp_basepath(&self) -> PathBuf { + Self::tmpdir().join(self.dirname()) + } + + pub fn basepath(&self) -> PathBuf { + Path::new(Realms::BASE_PATH) + .join(self.dirname()) + } + + fn dirname(&self) -> String { + format!("realm-{}", self.name) + } + + /// Create a new realm with the name `self.name` + pub fn create(&self) -> Result<()> { + if self.basepath().exists() { + bail!("realm directory {} already exists", self.basepath().display()); + } + + if let Err(e) = self.create_realm_directory() { + let tmpdir = self.temp_basepath(); + if tmpdir.exists() { + let _ = fs::remove_dir_all(tmpdir); + } + return Err(e); + } + Ok(()) + } + + fn create_realm_directory(&self) -> Result<()> { + self.create_home()?; + self.move_from_temp()?; + Ok(()) + } + + fn create_home(&self) -> Result<()> { + let home = self.temp_basepath().join("home"); + + fs::create_dir_all(&home) + .map_err(|e| format_err!("failed to create directory {}: {}", home.display(), e))?; + util::chown(&home, 1000, 1000) + .map_err(|e| format_err!("failed to change ownership of {} to 1000:1000: {}", home.display(), e))?; + + let skel = Path::new(Realms::BASE_PATH).join("skel"); + + if skel.exists() { + info!("Populating realm home directory with files from {}", skel.display()); + util::copy_tree(&skel, &home) + .map_err(|e| format_err!("failed to copy tree of files from {} to {}: {}", skel.display(), home.display(), e))?; + } + + Ok(()) + } + + fn move_from_temp(&self) -> Result<()> { + let from = self.temp_basepath(); + let to = self.basepath(); + if to.exists() { + bail!("Cannot move temporary directory {} to {} because the target already exists", from.display(), to.display()); + } + fs::rename(from, to)?; + Ok(()) + } + + fn move_to_temp(&self) -> Result<()> { + let from = self.basepath(); + let to = self.temp_basepath(); + if to.exists() { + bail!("Cannot move realm directory {} to {} because the target already exists", from.display(), to.display()); + } + + if !Self::tmpdir().exists() { + fs::create_dir_all(Self::tmpdir())?; + } + + fs::rename(from, to)?; + + Ok(()) + + } + + pub fn delete_realm(&self, save_home: bool) -> Result<()> { + + self.move_to_temp()?; + if save_home { + self.save_home_for_delete()?; + } + + info!("removing realm directory {}", self.temp_basepath().display()); + fs::remove_dir_all(self.temp_basepath())?; + Ok(()) + + } + + fn save_home_for_delete(&self) -> Result<()> { + if !Path::new("/realms/removed").exists() { + fs::create_dir("/realms/removed")?; + } + + let target = self.home_save_directory(); + let home = self.temp_basepath().join("home"); + fs::rename(&home, &target) + .map_err(|e| format_err!("unable to move realm home directory to {}: {}", target.display(), e))?; + + info!("home directory been moved to {}, delete it at your leisure", target.display()); + Ok(()) + } + + fn home_save_directory(&self) -> PathBuf { + let mut n = 1; + let mut save_dir= PathBuf::from(&format!("/realms/removed/home-{}", self.name)); + while save_dir.exists() { + save_dir.set_extension(n.to_string()); + n += 1; + } + save_dir + } + +} \ No newline at end of file diff --git a/libcitadel/src/realm/events.rs b/libcitadel/src/realm/events.rs new file mode 100644 index 0000000..0bbb1f7 --- /dev/null +++ b/libcitadel/src/realm/events.rs @@ -0,0 +1,369 @@ +use std::fs; +use std::ffi::OsStr; +use std::fmt::{Display,self}; +use std::sync::{Arc, RwLock, Weak, RwLockWriteGuard, RwLockReadGuard}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self,JoinHandle}; +use std::path; + +use crate::{RealmManager, Result, Realm}; +use dbus::{Connection, BusType, ConnectionItem, Message, Path}; +use inotify::{Inotify, WatchMask, WatchDescriptor, Event}; + +pub enum RealmEvent { + Started(Realm), + Stopped(Realm), + New(Realm), + Removed(Realm), + Current(Option), +} + +impl Display for RealmEvent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + RealmEvent::Started(ref realm) => write!(f, "RealmStarted({})", realm.name()), + RealmEvent::Stopped(ref realm) => write!(f, "RealmStopped({})", realm.name()), + RealmEvent::New(ref realm) => write!(f, "RealmNew({})", realm.name()), + RealmEvent::Removed(ref realm) => write!(f, "RealmRemoved({})", realm.name()), + RealmEvent::Current(Some(realm)) => write!(f, "RealmCurrent({})", realm.name()), + RealmEvent::Current(None) => write!(f, "RealmCurrent(None)"), + } + } +} + +pub type RealmEventHandler = Fn(&RealmEvent)+Send+Sync; + +pub struct RealmEventListener { + inner: Arc>, + running: Arc, + join: Vec>>, +} + +struct Inner { + manager: Weak, + handlers: Vec>, + quit: Arc, +} + +impl Inner { + fn new() -> Self { + Inner { + manager: Weak::new(), + handlers: Vec::new(), + quit: Arc::new(AtomicBool::new(false)), + } + } + + fn set_manager(&mut self, manager: Arc) { + self.manager = Arc::downgrade(&manager); + } + + pub fn add_handler(&mut self, handler: F) + where F: Fn(&RealmEvent), + F: 'static + Send + Sync + { + self.handlers.push(Box::new(handler)); + } + + fn send_event(&self, event: RealmEvent) { + self.handlers.iter().for_each(|cb| (cb)(&event)); + } + + fn quit_flag(&self) -> bool { + self.quit.load(Ordering::SeqCst) + } + + fn set_quit_flag(&self, val: bool) { + self.quit.store(val, Ordering::SeqCst) + } + + fn with_manager(&self, f: F) + where F: Fn(&RealmManager) + { + if let Some(manager) = self.manager.upgrade() { + f(&manager) + } + } +} + +impl RealmEventListener { + + pub fn new() -> Self { + RealmEventListener { + inner: Arc::new(RwLock::new(Inner::new())), + running: Arc::new(AtomicBool::new(false)), + join: Vec::new(), + } + } + + pub fn set_manager(&self, manager: Arc) { + self.inner_mut().set_manager(manager); + } + + fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + fn set_running(&self, val: bool) -> bool { + self.running.swap(val, Ordering::SeqCst) + } + + pub fn add_handler(&self, handler: F) + where F: Fn(&RealmEvent), + F: 'static + Send + Sync + { + self.inner_mut().add_handler(handler); + } + + fn inner_mut(&self) -> RwLockWriteGuard { + self.inner.write().unwrap() + } + + fn inner(&self) -> RwLockReadGuard { + self.inner.read().unwrap() + } + + pub fn start_event_task(&mut self) -> Result<()> { + if self.set_running(true) { + warn!("RealmEventListener already running"); + return Ok(()); + } + + let inotify_handle = match InotifyEventListener::create(self.inner.clone()) { + Ok(inotify) => inotify.spawn(), + Err(e) => { + self.set_running(false); + return Err(e); + } + }; + let dbus_handle = DbusEventListener::new(self.inner.clone()).spawn(); + + self.join.clear(); + self.join.push(inotify_handle); + self.join.push(dbus_handle); + + Ok(()) + } + + fn notify_stop(&self) -> bool { + let lock = self.inner(); + + let can_stop = self.is_running() && !lock.quit_flag(); + + if can_stop { + lock.set_quit_flag(true); + } + can_stop + } + + pub fn stop(&mut self) { + if !self.notify_stop() { + return; + } + + info!("Stopping event listening task"); + + if let Err(e) = InotifyEventListener::wake_inotify() { + warn!("error signaling inotify task by creating a file: {}", e); + } + + thread::spawn({ + let handles: Vec<_> = self.join.drain(..).collect(); + let running = self.running.clone(); + let quit = self.inner().quit.clone(); + move || { + for join in handles { + if let Err(err) = join.join().unwrap() { + warn!("error from event task: {}", err); + } + } + running.store(false, Ordering::SeqCst); + quit.store(false, Ordering::SeqCst); + info!("Event listening task stopped"); + } + }); + } +} + +impl Drop for RealmEventListener { + fn drop(&mut self) { + self.inner().set_quit_flag(true); + } +} + +#[derive(Clone)] +struct DbusEventListener { + inner: Arc>, +} + +impl DbusEventListener { + fn new(inner: Arc>) -> Self { + DbusEventListener { inner } + } + + fn spawn(self) -> JoinHandle> { + thread::spawn(move || { + if let Err(err) = self.dbus_event_loop() { + warn!("dbus_event_loop(): {}", err); + } + Ok(()) + }) + } + + fn dbus_event_loop(&self) -> Result<()> { + let connection = Connection::get_private(BusType::System)?; + connection.add_match("interface='org.freedesktop.machine1.Manager',type='signal'")?; + for item in connection.iter(1000) { + if self.inner().quit_flag() { + break; + } + self.handle_item(item); + } + info!("Exiting dbus event loop"); + Ok(()) + } + + fn inner(&self) -> RwLockReadGuard { + self.inner.read().unwrap() + } + + fn handle_item(&self, item: ConnectionItem) { + if let ConnectionItem::Signal(message) = item { + if let Some(interface) = message.interface() { + if &(*interface) == "org.freedesktop.machine1.Manager" { + if let Err(e) = self.handle_signal(message) { + warn!("Error handling signal: {}", e); + } + } + } + } + } + + fn handle_signal(&self, message: Message) -> Result<()> { + + let member = message.member() + .ok_or(format_err!("invalid signal"))?; + let (name, _path): (String, Path) = message.read2()?; + if let (Some(interface),Some(member)) = (message.interface(),message.member()) { + verbose!("DBUS: {}:[{}({})]", interface, member,name); + } + match &*member { + "MachineNew" => self.on_machine_new(&name), + "MachineRemoved" => self.on_machine_removed(&name), + _ => {}, + }; + Ok(()) + } + + fn on_machine_new(&self, name: &str) { + self.inner().with_manager(|m| { + if let Some(realm) = m.realm_by_name(name) { + realm.set_active(true); + self.inner().send_event(RealmEvent::Started(realm)) + } + }); + } + + fn on_machine_removed(&self, name: &str) { + self.inner().with_manager(|m| { + if let Some(realm) = m.on_machine_removed(name) { + self.inner().send_event(RealmEvent::Stopped(realm)) + } + + }); + } +} + +struct InotifyEventListener { + inner: Arc>, + inotify: Inotify, + realms_watch: WatchDescriptor, + current_watch: WatchDescriptor, + +} + +impl InotifyEventListener { + + fn create(inner: Arc>) -> Result { + let mut inotify = Inotify::init()?; + let realms_watch = inotify.add_watch("/realms", WatchMask::MOVED_FROM|WatchMask::MOVED_TO)?; + let current_watch = inotify.add_watch("/run/citadel/realms/current", WatchMask::CREATE|WatchMask::MOVED_TO)?; + + Ok(InotifyEventListener { inner, inotify, realms_watch, current_watch, }) + } + + fn wake_inotify() -> Result<()> { + let path = "/run/citadel/realms/current/stop-events"; + fs::File::create(path)?; + fs::remove_file(path)?; + Ok(()) + } + + fn spawn(mut self) -> JoinHandle> { + thread::spawn(move || self.inotify_event_loop()) + } + + fn inotify_event_loop(&mut self) -> Result<()> { + let mut buffer = [0; 1024]; + while !self.inner().quit_flag() { + let events = self.inotify.read_events_blocking(&mut buffer)?; + + if !self.inner().quit_flag() { + for event in events { + self.handle_event(event); + } + } + } + info!("Exiting inotify event loop"); + Ok(()) + } + + fn handle_event(&self, event: Event<&OsStr>) { + self.log_event(&event); + if event.wd == self.current_watch { + self.handle_current_event(); + } else if event.wd == self.realms_watch { + self.handle_realm_event(); + } + } + + fn log_event(&self, event: &Event<&OsStr>) { + if let Some(name) = event.name { + let path = path::Path::new("/realms").join(name); + verbose!("INOTIFY: {} ({:?})", path.display(), event.mask); + } else { + verbose!("INOTIFY: ({:?})", event.mask); + + } + } + + fn inner(&self) -> RwLockReadGuard { + self.inner.read().unwrap() + } + + fn handle_current_event(&self) { + self.inner().with_manager(|m| { + if let Some(current) = m.has_current_changed() { + self.inner().send_event(RealmEvent::Current(current)); + } + }) + } + + fn handle_realm_event(&self) { + self.inner().with_manager(|m| { + let (added,removed) = match m.rescan_realms() { + Ok(result) => result, + Err(e) => { + warn!("error rescanning realms: {}", e); + return; + } + }; + for realm in added { + self.inner().send_event(RealmEvent::New(realm)); + } + for realm in removed { + self.inner().send_event(RealmEvent::Removed(realm)); + } + }) + } +} diff --git a/libcitadel/src/realm/manager.rs b/libcitadel/src/realm/manager.rs new file mode 100644 index 0000000..c46d1c9 --- /dev/null +++ b/libcitadel/src/realm/manager.rs @@ -0,0 +1,371 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::{Mountpoint, Activation,Result, Realms, RealmFS, Realm, util}; +use crate::realmfs::realmfs_set::RealmFSSet; + +use super::systemd::Systemd; +use super::network::NetworkConfig; +use super::events::{RealmEventListener, RealmEvent}; + +pub struct RealmManager { + inner: RwLock, + systemd: Systemd, +} + +struct Inner { + events: RealmEventListener, + realms: Realms, + realmfs_set: RealmFSSet, +} + +impl Inner { + fn new() -> Result { + let events = RealmEventListener::new(); + let realms = Realms::load()?; + let realmfs_set = RealmFSSet::load()?; + Ok(Inner { events, realms, realmfs_set }) + } +} + +impl RealmManager { + + fn create_network_config() -> Result { + let mut network = NetworkConfig::new(); + network.add_bridge("clear", "172.17.0.0/24")?; + Ok(network) + } + + pub fn load() -> Result> { + let inner = Inner::new()?; + let inner = RwLock::new(inner); + + let network = RealmManager::create_network_config()?; + let systemd = Systemd::new(network); + + let manager = RealmManager{ inner, systemd }; + let manager = Arc::new(manager); + + manager.set_manager(manager.clone()); + + Ok(manager) + } + + fn set_manager(&self, manager: Arc) { + let mut inner = self.inner_mut(); + inner.events.set_manager(manager.clone()); + inner.realms.set_manager(manager.clone()); + inner.realmfs_set.set_manager(manager); + } + + pub fn add_event_handler(&self, handler: F) + where F: Fn(&RealmEvent), + F: 'static + Send + Sync + { + self.inner_mut().events.add_handler(handler); + } + + pub fn start_event_task(&self) -> Result<()> { + self.inner_mut().events.start_event_task() + } + + pub fn stop_event_task(&self) { + self.inner_mut().events.stop(); + } + + /// + /// Execute shell in a realm. If `realm_name` is `None` then exec + /// shell in current realm, otherwise look up realm by name. + /// + /// If `root_shell` is true, open a root shell, otherwise open + /// a user (uid = 1000) shell. + /// + pub fn launch_shell(&self, realm: &Realm, root_shell: bool) -> Result<()> { + Systemd::machinectl_exec_shell(realm, root_shell, true)?; + info!("exiting shell in realm '{}'", realm.name()); + Ok(()) + } + + pub fn launch_terminal(&self, realm: &Realm) -> Result<()> { + info!("opening terminal in realm '{}'", realm.name()); + let title_arg = format!("Realm: {}", realm.name()); + let args = &["/usr/bin/gnome-terminal".to_owned(), "--title".to_owned(), title_arg]; + Systemd::machinectl_shell(realm, args, "user", true, true)?; + Ok(()) + } + + pub fn run_in_realm>(&self, realm: &Realm, args: &[S], use_launcher: bool) -> Result<()> { + Systemd::machinectl_shell(realm, args, "user", use_launcher, false) + } + + pub fn run_in_current>(args: &[S], use_launcher: bool) -> Result<()> { + let realm = Realms::load_current_realm() + .ok_or(format_err!("Could not find current realm"))?; + + if !realm.is_active() { + bail!("Current realm {} is not active?", realm.name()); + } + Systemd::machinectl_shell(&realm, args, "user", use_launcher, false) + } + + pub fn copy_to_realm, Q:AsRef>(&self, realm: &Realm, from: P, to: Q) -> Result<()> { + let from = from.as_ref().to_string_lossy(); + let to = to.as_ref().to_string_lossy(); + self.systemd.machinectl_copy_to(realm, from.as_ref(), to.as_ref()) + } + + pub fn realm_list(&self) -> Vec { + self.inner_mut().realms.sorted() + } + + pub fn active_realms(&self, ignore_system: bool) -> Vec { + self.inner().realms.active(ignore_system) + } + + /// Return a list of Realms that are using the `activation` + pub fn realms_for_activation(&self, activation: &Activation) -> Vec { + self.active_realms(false) + .into_iter() + .filter(|r| { + r.realmfs_mountpoint() + .map(|mp| activation.is_mountpoint(&mp)) + .unwrap_or(false) + }) + .collect() + } + + pub fn realmfs_list(&self) -> Vec { + self.inner().realmfs_set.realmfs_list() + } + + pub fn realmfs_name_exists(&self, name: &str) -> bool { + self.inner().realmfs_set.name_exists(name) + } + + pub fn realmfs_by_name(&self, name: &str) -> Option { + self.inner().realmfs_set.by_name(name) + } + + /// Notify `RealmManager` that `mountpoint` has been released by a + /// `Realm`. + pub fn release_mountpoint(&self, mountpoint: Mountpoint) { + info!("releasing mountpoint: {}", mountpoint); + if !mountpoint.is_valid() { + warn!("bad mountpoint {} passed to release_mountpoint()", mountpoint); + return; + } + + if let Some(realmfs) = self.realmfs_by_name(mountpoint.realmfs()) { + if realmfs.release_mountpoint(&mountpoint) { + return; + } + } + + if let Some(activation) = Activation::for_mountpoint(&mountpoint) { + let active = self.active_mountpoints(); + if let Err(e) = activation.deactivate(&active) { + warn!("error on detached deactivation for {}: {}",activation.device(), e); + } else { + info!("Deactivated detached activation for device {}", activation.device()); + } + } else { + warn!("No activation found for released mountpoint {}", mountpoint); + } + } + + /// Return a `Mountpoint` set containing all the mountpoints which are being used + /// by some running `Realm`. + pub fn active_mountpoints(&self) -> HashSet { + self.active_realms(false) + .iter() + .flat_map(Realm::realmfs_mountpoint) + .collect() + } + + pub fn start_boot_realms(&self) -> Result<()> { + if let Some(realm) = self.default_realm() { + if let Err(e) = self.start_realm(&realm) { + bail!("Failed to start default realm '{}': {}", realm.name(), e); + } + } else { + bail!("No default realm to start"); + } + Ok(()) + } + + pub fn start_realm(&self, realm: &Realm) -> Result<()> { + if realm.is_active() { + info!("ignoring start request on already running realm '{}'", realm.name()); + } + info!("Starting realm {}", realm.name()); + self._start_realm(realm, &mut HashSet::new())?; + + if !Realms::is_some_realm_current() { + self.inner_mut().realms.set_realm_current(realm) + .unwrap_or_else(|e| warn!("Failed to set realm as current: {}", e)); + } + Ok(()) + } + + fn _start_realm(&self, realm: &Realm, starting: &mut HashSet) -> Result<()> { + + self.start_realm_dependencies(realm, starting)?; + + let home = realm.base_path_file("home"); + if !home.exists() { + warn!("No home directory exists at {}, creating an empty directory", home.display()); + fs::create_dir_all(&home)?; + util::chown_user(&home)?; + } + + let rootfs = realm.setup_rootfs()?; + + realm.update_timestamp()?; + + self.systemd.start_realm(realm, &rootfs)?; + + self.create_realm_namefile(realm)?; + + if realm.config().wayland() { + self.link_wayland_socket(realm) + .unwrap_or_else(|e| warn!("Error linking wayland socket: {}", e)); + } + Ok(()) + } + + fn create_realm_namefile(&self, realm: &Realm) -> Result<()> { + let namefile = realm.run_path_file("realm-name"); + fs::write(&namefile, realm.name())?; + self.systemd.machinectl_copy_to(realm, &namefile, "/run/realm-name")?; + fs::remove_file(&namefile)?; + Ok(()) + } + + fn start_realm_dependencies(&self, realm: &Realm, starting: &mut HashSet) -> Result<()> { + starting.insert(realm.name().to_string()); + + for realm_name in realm.config().realm_depends() { + if let Some(r) = self.realm_by_name(realm_name) { + if !r.is_active() && !starting.contains(r.name()) { + info!("Starting realm dependency realm-{}", realm.name()); + self._start_realm(&r, starting)?; + } + } else { + warn!("Realm dependency '{}' not found", realm_name); + } + } + Ok(()) + } + + fn link_wayland_socket(&self, realm: &Realm) -> Result<()> { + self.run_in_realm(realm, &["/usr/bin/ln", "-s", "/run/user/host/wayland-0", "/run/user/1000/wayland-0"], false) + } + + pub fn stop_realm(&self, realm: &Realm) -> Result<()> { + if !realm.is_active() { + info!("ignoring stop request on realm '{}' which is not running", realm.name()); + } + + info!("Stopping realm {}", realm.name()); + + realm.set_active(false); + self.systemd.stop_realm(realm)?; + realm.cleanup_rootfs(); + + if realm.is_current() { + self.choose_some_current_realm(); + } + Ok(()) + } + + fn inner(&self) -> RwLockReadGuard { + self.inner.read().unwrap() + } + fn inner_mut(&self) -> RwLockWriteGuard { + self.inner.write().unwrap() + } + + pub(crate) fn on_machine_removed(&self, name: &str) -> Option { + let realm = match self.inner().realms.by_name(name) { + Some(ref realm) if realm.is_active() => realm.clone(), + _ => return None, + }; + + // XXX do something to detect realmfs/overlay that is not cleaned up + realm.set_active(false); + + if realm.is_current() { + self.choose_some_current_realm(); + } + Some(realm) + } + + fn choose_some_current_realm(&self) { + if let Err(e) = self.inner_mut().realms.choose_some_current() { + warn!("error choosing new current realm: {}", e); + } + } + + pub fn has_current_changed(&self) -> Option> { + self.inner_mut().realms.has_current_changed() + } + + pub fn default_realm(&self) -> Option { + self.inner().realms.default() + } + + pub fn set_default_realm(&self, realm: &Realm) -> Result<()> { + self.inner().realms.set_realm_default(realm) + } + + pub fn realm_by_name(&self, name: &str) -> Option { + self.inner().realms.by_name(name) + } + + pub fn rescan_realms(&self) -> Result<(Vec,Vec)> { + self.inner_mut().realms.rescan_realms() + } + + pub fn set_current_realm(&self, realm: &Realm) -> Result<()> { + if realm.is_current() { + return Ok(()) + } + if !realm.is_active() { + self.start_realm(realm)?; + } + self.inner_mut().realms.set_realm_current(realm)?; + info!("Realm '{}' set as current realm", realm.name()); + Ok(()) + } + + pub fn new_realm(&self, name: &str) -> Result { + self.inner_mut().realms.create_realm(name) + } + + pub fn delete_realm(&self, realm: &Realm, save_home: bool) -> Result<()> { + if realm.is_active() { + self.stop_realm(realm)?; + } + self.inner_mut().realms.delete_realm(realm.name(), save_home) + } + + pub fn realmfs_added(&self, realmfs: &RealmFS) { + self.inner_mut().realmfs_set.add(realmfs); + } + + pub fn delete_realmfs(&self, realmfs: &RealmFS) -> Result<()> { + if realmfs.is_in_use() { + bail!("Cannot delete realmfs because it is in use"); + } + realmfs.deactivate()?; + if realmfs.is_activated() { + bail!("Unable to deactive Realmfs, cannot delete"); + } + self.inner_mut().realmfs_set.remove(realmfs.name()); + info!("Removing RealmFS image file {}", realmfs.path().display()); + fs::remove_file(realmfs.path())?; + Ok(()) + } +} diff --git a/libcitadel/src/realm/mod.rs b/libcitadel/src/realm/mod.rs new file mode 100644 index 0000000..200fa63 --- /dev/null +++ b/libcitadel/src/realm/mod.rs @@ -0,0 +1,13 @@ + +pub(crate) mod overlay; +pub(crate) mod config; +pub(crate) mod realms; +pub(crate) mod manager; +pub(crate) mod realm; +pub (crate) mod network; +pub(crate) mod create; +pub(crate) mod events; +mod systemd; + +pub(crate) use self::network::BridgeAllocator; + diff --git a/libcitadel/src/realm/network.rs b/libcitadel/src/realm/network.rs new file mode 100644 index 0000000..a1f2a21 --- /dev/null +++ b/libcitadel/src/realm/network.rs @@ -0,0 +1,241 @@ +use std::path::{Path,PathBuf}; +use std::net::Ipv4Addr; +use std::collections::{HashSet,HashMap}; +use std::io::{BufReader,BufRead,Write}; +use std::fs::{self,File}; + +use crate::Result; + +const REALMS_RUN_PATH: &str = "/run/citadel/realms"; + +const CLEAR_BRIDGE_NETWORK: &str = "172.17.0.0/24"; + +const MIN_MASK: usize = 16; +const MAX_MASK: usize = 24; +const RESERVED_START: u8 = 200; + +/// Manage ip address assignment for bridges +pub struct NetworkConfig { + allocators: HashMap, +} + +impl NetworkConfig { + pub fn new() -> NetworkConfig { + NetworkConfig { + allocators: HashMap::new(), + } + } + + pub fn add_bridge(&mut self, name: &str, network: &str) -> Result<()> { + let allocator = BridgeAllocator::for_bridge(name, network) + .map_err(|e| format_err!("Failed to create bridge allocator: {}", e))?; + self.allocators.insert(name.to_owned(), allocator); + Ok(()) + } + + pub fn gateway(&self, bridge: &str) -> Result { + match self.allocators.get(bridge) { + Some(allocator) => Ok(allocator.gateway()), + None => bail!("Failed to return gateway address for bridge {} because it does not exist", bridge), + } + } + + pub fn allocate_address_for(&mut self, bridge: &str, realm_name: &str) -> Result { + match self.allocators.get_mut(bridge) { + Some(allocator) => allocator.allocate_address_for(realm_name), + None => bail!("Failed to allocate address for bridge {} because it does not exist", bridge), + } + } + + pub fn free_allocation_for(&mut self, bridge: &str, realm_name: &str) -> Result<()> { + match self.allocators.get_mut(bridge) { + Some(allocator) => allocator.free_allocation_for(realm_name), + None => bail!("Failed to free address on bridge {} because it does not exist", bridge), + } + } + + pub fn allocate_reserved(&mut self, bridge: &str, realm_name: &str, octet: u8) -> Result { + match self.allocators.get_mut(bridge) { + Some(allocator) => allocator.allocate_reserved(realm_name, octet), + None => bail!("Failed to allocate address for bridge {} because it does not exist", bridge), + } + } +} + +/// +/// Allocates IP addresses for a bridge shared by multiple realms. +/// +/// State information is stored in /run/citadel/realms/network-$bridge as +/// colon ':' separated pairs of realm name and allocated ip address +/// +/// realm-a:172.17.0.2 +/// realm-b:172.17.0.3 +/// +pub struct BridgeAllocator { + bridge: String, + network: Ipv4Addr, + mask_size: usize, + allocated: HashSet, + allocations: HashMap, +} + +impl BridgeAllocator { + + + pub fn default_bridge() -> Result { + BridgeAllocator::for_bridge("clear", CLEAR_BRIDGE_NETWORK) + } + + pub fn for_bridge(bridge: &str, network: &str) -> Result { + let (addr_str, mask_size) = match network.find('/') { + Some(idx) => { + let (net,bits) = network.split_at(idx); + (net.to_owned(), bits[1..].parse()?) + }, + None => (network.to_owned(), 24), + }; + if mask_size > MAX_MASK || mask_size < MIN_MASK { + bail!("Unsupported network mask size of {}", mask_size); + } + + let mask = (1u32 << (32 - mask_size)) - 1; + let ip = addr_str.parse::()?; + + if (u32::from(ip) & mask) != 0 { + bail!("network {} has masked bits with netmask /{}", addr_str, mask_size); + } + + let mut conf = BridgeAllocator::new(bridge, ip, mask_size); + conf.load_state()?; + Ok(conf) + } + + fn new(bridge: &str, network: Ipv4Addr, mask_size: usize) -> BridgeAllocator { + let allocator = BridgeAllocator { + bridge: bridge.to_owned(), + allocated: HashSet::new(), + allocations: HashMap::new(), + network, mask_size, + }; + allocator + } + + pub fn allocate_address_for(&mut self, realm_name: &str) -> Result { + match self.find_free_address() { + Some(addr) => { + self.allocated.insert(addr.clone()); + if let Some(old) = self.allocations.insert(realm_name.to_owned(), addr.clone()) { + self.allocated.remove(&old); + } + self.write_state()?; + return Ok(format!("{}/{}", addr, self.mask_size)); + }, + None => bail!("No free IP address could be found to assign to {}", realm_name), + } + + } + + fn store_allocation(&mut self, realm_name: &str, address: Ipv4Addr) -> Result<()> { + self.allocated.insert(address.clone()); + if let Some(old) = self.allocations.insert(realm_name.to_string(), address) { + self.allocated.remove(&old); + } + self.write_state() + } + + fn find_free_address(&self) -> Option { + let mask = (1u32 << (32 - self.mask_size)) - 1; + let net = u32::from(self.network); + for i in 2..mask { + let addr = Ipv4Addr::from(net + i); + if !Self::is_reserved(addr) && !self.allocated.contains(&addr) { + return Some(addr); + } + } + None + } + + fn is_reserved(addr: Ipv4Addr) -> bool { + addr.octets()[3] >= RESERVED_START + } + + pub fn gateway(&self) -> String { + let gw = u32::from(self.network) + 1; + let addr = Ipv4Addr::from(gw); + addr.to_string() + } + + fn allocate_reserved(&mut self, realm_name: &str, octet: u8) -> Result { + if octet < RESERVED_START { + bail!("Not a reserved octet: {}", octet); + } + let rsv = u32::from(self.network) | octet as u32; + let addr = Ipv4Addr::from(rsv); + let s = format!("{}/{}", addr, self.mask_size); + if self.allocated.contains(&addr) { + bail!("Already in use: {}", s); + } + self.store_allocation(realm_name, addr)?; + Ok(s) + } + + pub fn free_allocation_for(&mut self, realm_name: &str) -> Result<()> { + match self.allocations.remove(realm_name) { + Some(ip) => { + self.allocated.remove(&ip); + self.write_state()?; + } + None => warn!("No address allocation found for realm {}", realm_name), + }; + Ok(()) + } + + fn state_file_path(&self) -> PathBuf { + Path::new(REALMS_RUN_PATH).with_file_name(format!("network-{}", self.bridge)) + } + + + fn load_state(&mut self) -> Result<()> { + let path = self.state_file_path(); + if !path.exists() { + return Ok(()) + } + let f = File::open(path)?; + let reader = BufReader::new(f); + for line in reader.lines() { + let line = &line?; + self.parse_state_line(line)?; + } + + Ok(()) + } + + fn parse_state_line(&mut self, line: &str) -> Result<()> { + match line.find(":") { + Some(idx) => { + let (name,addr) = line.split_at(idx); + let ip = addr[1..].parse::()?; + self.allocated.insert(ip.clone()); + self.allocations.insert(name.to_owned(), ip); + }, + None => bail!("Could not parse line from network state file: {}", line), + } + Ok(()) + } + + fn write_state(&mut self) -> Result<()> { + let path = self.state_file_path(); + let dir = path.parent().unwrap(); + if !dir.exists() { + fs::create_dir_all(dir) + .map_err(|e| format_err!("failed to create directory {} for network allocation state file: {}", dir.display(), e))?; + } + let mut f = File::create(&path) + .map_err(|e| format_err!("failed to open network state file {} for writing: {}", path.display(), e))?; + + for (realm,addr) in &self.allocations { + writeln!(f, "{}:{}", realm, addr)?; + } + Ok(()) + } +} diff --git a/libcitadel/src/realm/overlay.rs b/libcitadel/src/realm/overlay.rs new file mode 100644 index 0000000..d5449ea --- /dev/null +++ b/libcitadel/src/realm/overlay.rs @@ -0,0 +1,170 @@ +use std::fs; +use std::os::unix; +use std::path::{Path,PathBuf}; + +use crate::{Realm,Result}; +use crate::Exec; +use crate::realm::config::OverlayType; + +const REALMS_BASE_PATH: &str = "/realms"; +const REALMS_RUN_PATH: &str = "/run/citadel/realms"; + +pub struct RealmOverlay { + realm: String, + overlay: OverlayType, +} + +impl RealmOverlay { + + pub fn remove_any_overlay(realm: &Realm) { + Self::try_remove(realm, OverlayType::Storage); + Self::try_remove(realm, OverlayType::TmpFS); + } + + fn try_remove(realm: &Realm, overlay: OverlayType) { + let ov = Self::new(realm.name(), overlay); + if !ov.exists() { + return; + } + + if let Err(e) = ov.remove() { + warn!("Error removing {:?} overlay for realm '{}': {}", overlay, realm.name(), e); + } + } + + pub fn for_realm(realm: &Realm) -> Option { + match realm.config().overlay() { + OverlayType::None => None, + overlay => Some(RealmOverlay::new(realm.name(), overlay)), + } + } + + fn new(realm: &str, overlay: OverlayType) -> RealmOverlay { + let realm = realm.to_string(); + RealmOverlay { realm, overlay } + } + + + /// Set up an overlayfs for a realm root filesystem either on tmpfs + /// or in a btrfs subvolume. Create the overlay over `lower` and + /// return the overlay mountpoint. + pub fn create(&self, lower: impl AsRef) -> Result { + let lower = lower.as_ref(); + info!("Creating overlay [{:?}] over rootfs mounted at {}", self.overlay, lower.display()); + match self.overlay { + OverlayType::TmpFS => self.create_tmpfs(lower), + OverlayType::Storage => self.create_btrfs(lower), + _ => unreachable!(), + } + } + + /// Remove a previously created realm overlay and return the + /// initial `lower` directory. + pub fn remove(&self) -> Result { + let base = self.overlay_directory(); + let mountpoint = base.join("mountpoint"); + if !self.umount_overlay() { + warn!("Failed to unmount overlay mountpoint {}",mountpoint.display()); + } + + let lower = base.join("lower").read_link() + .map_err(|e| format_err!("Unable to read link to 'lower' directory of overlay: {}", e)); + + match self.overlay { + OverlayType::TmpFS => self.remove_tmpfs(&base)?, + OverlayType::Storage => self.remove_btrfs(&base)?, + _ => unreachable!(), + }; + Ok(lower?) + } + + pub fn exists(&self) -> bool { + self.overlay_directory().exists() + } + + pub fn lower(&self) -> Option { + let path = self.overlay_directory().join("lower"); + if path.exists() { + fs::read_link(path).ok() + } else { + None + } + } + + fn remove_tmpfs(&self, base: &Path) -> Result<()> { + fs::remove_dir_all(base) + .map_err(|e| format_err!("Could not remove overlay directory {}: {}", base.display(), e)) + } + + fn remove_btrfs(&self, base: &Path) -> Result<()> { + Exec::new("/usr/bin/btrfs") + .quiet() + .run(format!("subvolume delete {}", base.display())) + .map_err(|e| format_err!("Could not remove btrfs subvolume {}: {}", base.display(), e)) + } + + fn create_tmpfs(&self, lower: &Path) -> Result { + let base = self.overlay_directory(); + if base.exists() { + info!("tmpfs overlay directory already exists, removing it before setting up overlay"); + self.umount_overlay(); + self.remove_tmpfs(&base)?; + } + self.setup_overlay(&base, lower) + } + + fn umount_overlay(&self) -> bool { + let mountpoint = self.overlay_directory().join("mountpoint"); + match cmd_ok!("/usr/bin/umount", "{}", mountpoint.display()) { + Ok(v) => v, + Err(e) => { + warn!("Could not run /usr/bin/umount on {}: {}", mountpoint.display(), e); + false + } + } + } + + fn create_btrfs(&self, lower: &Path) -> Result { + let subvolume = self.overlay_directory(); + if subvolume.exists() { + info!("btrfs overlay subvolume already exists, removing it before setting up overlay"); + self.umount_overlay(); + self.remove_btrfs(&subvolume)?; + } + Exec::new("/usr/bin/btrfs").quiet().run(format!("subvolume create {}", subvolume.display()))?; + self.setup_overlay(&subvolume, lower) + } + + fn setup_overlay(&self, base: &Path, lower: &Path) -> Result { + let upper = self.mkdir(base, "upperdir")?; + let work = self.mkdir(base, "workdir")?; + let mountpoint = self.mkdir(base, "mountpoint")?; + unix::fs::symlink(lower, base.join("lower"))?; + cmd!("/usr/bin/mount", + "-t overlay realm-{}-overlay -olowerdir={},upperdir={},workdir={} {}", + self.realm, + lower.display(), + upper.display(), + work.display(), + mountpoint.display())?; + Ok(mountpoint) + } + + fn mkdir(&self, base: &Path, dirname: &str) -> Result { + let path = base.join(dirname); + fs::create_dir_all(&path) + .map_err(|e| format_err!("failed to create directory {}: {}", path.display(), e))?; + Ok(path) + } + + fn overlay_directory(&self) -> PathBuf { + let base = match self.overlay { + OverlayType::TmpFS => REALMS_RUN_PATH, + OverlayType::Storage => REALMS_BASE_PATH, + _ => unreachable!(), + }; + Path::new(base) + .join(format!("realm-{}", self.realm)) + .join("overlay") + } +} \ No newline at end of file diff --git a/libcitadel/src/realm/realm.rs b/libcitadel/src/realm/realm.rs new file mode 100644 index 0000000..9fe71cf --- /dev/null +++ b/libcitadel/src/realm/realm.rs @@ -0,0 +1,512 @@ +use std::cmp::Ordering; +use std::fs; +use std::path::{PathBuf, Path}; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; +use std::os::unix::fs::MetadataExt; + + +use super::overlay::RealmOverlay; +use super::config::{RealmConfig,GLOBAL_CONFIG,OverlayType}; +use super::realms::Realms; +use super::systemd::Systemd; + +use crate::realmfs::{Mountpoint, Activation}; +use crate::{symlink, util, Result, RealmFS, CommandLine, RealmManager}; + + +const MAX_REALM_NAME_LEN:usize = 128; +const ALWAYS_LOAD_TIMESTAMP: bool = true; + +#[derive(Clone,Copy,PartialEq)] +enum RealmActiveState { + Active, + Inactive, + Unknown, + Failed, +} + +impl RealmActiveState { + fn from_sysctl_output(line: &str) -> RealmActiveState { + match line { + "active" => RealmActiveState::Active, + "inactive" => RealmActiveState::Inactive, + _ => RealmActiveState::Unknown, + } + } +} + +struct Inner { + config: Arc, + timestamp: i64, + leader_pid: Option, + active: RealmActiveState, +} + +impl Inner { + fn new(config: RealmConfig) -> Inner { + Inner { + config: Arc::new(config), + timestamp: 0, + leader_pid: None, + active: RealmActiveState::Unknown, + } + } +} + +#[derive(Clone)] +pub struct Realm { + name: Arc, + manager: Weak, + inner: Arc>, +} + + +impl Realm { + + pub(crate) fn new(name: &str) -> Realm { + let config = RealmConfig::unloaded_realm_config(name); + let inner = Inner::new(config); + let inner = Arc::new(RwLock::new(inner)); + let name = Arc::new(name.to_string()); + let manager = Weak::new(); + Realm { name, manager, inner } + } + + pub(crate) fn set_manager(&mut self, manager: Arc) { + self.manager = Arc::downgrade(&manager); + } + + pub fn manager(&self) -> Arc { + if let Some(manager) = self.manager.upgrade() { + manager + } else { + panic!("No manager set on realm {}", self.name); + } + } + + fn inner(&self) -> RwLockReadGuard { + self.inner.read().unwrap() + } + + fn inner_mut(&self) -> RwLockWriteGuard { + self.inner.write().unwrap() + } + + pub fn is_active(&self) -> bool { + if self.inner().active == RealmActiveState::Unknown { + self.reload_active_state(); + } + self.inner().active == RealmActiveState::Active + } + + pub fn set_active(&self, is_active: bool) { + let state = if is_active { + RealmActiveState::Active + } else { + RealmActiveState::Inactive + }; + self.set_active_state(state); + } + + pub fn is_system(&self) -> bool { + self.config().system_realm() + } + + fn set_active_state(&self, state: RealmActiveState) { + let mut inner = self.inner_mut(); + if state != RealmActiveState::Active { + inner.leader_pid = None; + } + inner.active = state; + } + + fn reload_active_state(&self) { + match Systemd::is_active(self) { + Ok(active) => self.set_active(active), + Err(err) => { + warn!("Failed to run systemctl to determine realm is-active state: {}", err); + self.set_active_state(RealmActiveState::Failed) + }, + } + } + + pub fn set_active_from_systemctl(&self, output: &str) { + self.set_active_state(RealmActiveState::from_sysctl_output(output)); + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn realmfs_mountpoint(&self) -> Option { + symlink::read(self.realmfs_mountpoint_symlink()) + .map(Into::into) + .filter(Mountpoint::is_valid) + } + + pub fn rootfs(&self) -> Option { + symlink::read(self.rootfs_symlink()) + } + + pub fn timestamp(&self) -> i64 { + if ALWAYS_LOAD_TIMESTAMP { + return self.load_timestamp(); + } + let ts = self._timestamp(); + if ts == 0 { + self.inner_mut().timestamp = self.load_timestamp(); + } + self._timestamp() + } + + fn _timestamp(&self) -> i64 { + self.inner().timestamp + } + + fn load_timestamp(&self) -> i64 { + let tstamp = self.base_path().join(".tstamp"); + if tstamp.exists() { + if let Ok(meta) = tstamp.metadata() { + return meta.mtime(); + } + } + 0 + } + + /// create an empty file which is used to track the time at which + /// this realm was last made 'current'. These times are used + /// to order the output when listing realms. + pub fn update_timestamp(&self) -> Result<()> { + let tstamp = self.base_path().join(".tstamp"); + if tstamp.exists() { + fs::remove_file(&tstamp)?; + } + fs::File::create(&tstamp) + .map_err(|e| format_err!("failed to create timestamp file {}: {}", tstamp.display(), e))?; + // also load the new value + self.inner_mut().timestamp = self.load_timestamp(); + Ok(()) + } + + pub fn has_realmlock(&self) -> bool { + self.base_path_file(".realmlock").exists() + } + + fn rootfs_symlink(&self) -> PathBuf { + self.run_path().join("rootfs") + } + + fn realmfs_mountpoint_symlink(&self) -> PathBuf { + self.run_path().join("realmfs-mountpoint") + } + + /// Set up rootfs for a realm that is about to be started. + /// + /// 1) Find the RealmFS for this realm and activate it if not yet activated. + /// 2) If this realm is configured to use an overlay, set it up. + /// 3) If the RealmFS is unsealed, choose between ro/rw mountpoints + /// 4) create 'rootfs' symlink in realm run path pointing to rootfs base + /// 5) create 'realmfs-mountpoint' symlink pointing to realmfs mount + /// + pub fn setup_rootfs(&self) -> Result { + let realmfs = self.get_named_realmfs(self.config().realmfs())?; + + let activation = realmfs.activate()?; + let writeable = self.use_writable_mountpoint(&realmfs); + let mountpoint = self.choose_mountpoint(writeable, &activation)?; + + let rootfs = match RealmOverlay::for_realm(self) { + Some(ref overlay) if !writeable => overlay.create(mountpoint.path())?, + _ => mountpoint.path().to_owned(), + }; + + symlink::write(&rootfs, self.rootfs_symlink(), false)?; + symlink::write(mountpoint.path(), self.realmfs_mountpoint_symlink(), false)?; + symlink::write(self.base_path().join("home"), self.run_path().join("home"), false)?; + + Ok(rootfs) + } + + fn choose_mountpoint<'a>(&self, writeable: bool, activation: &'a Activation) -> Result<&'a Mountpoint> { + if !writeable { + Ok(activation.mountpoint()) + } else if let Some(mountpoint) = activation.mountpoint_rw() { + Ok(mountpoint) + } else { + Err(format_err!("RealmFS activation does not have writable mountpoint as expected")) + } + } + + /// Clean up the rootfs created when starting this realm. + /// + /// 1) If an overlay was created, remove it. + /// 2) Notify RealmFS that mountpoint has been released + /// 2) Remove the run path rootfs and mountpoint symlinks + /// 3) Remove realm run path directory + /// + pub fn cleanup_rootfs(&self) { + RealmOverlay::remove_any_overlay(self); + + if let Some(mountpoint) = self.realmfs_mountpoint() { + self.manager().release_mountpoint(mountpoint); + } + + Self::remove_symlink(self.realmfs_mountpoint_symlink()); + Self::remove_symlink(self.rootfs_symlink()); + Self::remove_symlink(self.run_path().join("home")); + + if let Err(e) = fs::remove_dir(self.run_path()) { + warn!("failed to remove run directory {}: {}", self.run_path().display(), e); + } + } + + fn remove_symlink(path: PathBuf) { + if let Err(e) = symlink::remove(&path) { + warn!("failed to remove symlink {}: {}", path.display(), e); + } + } + + fn use_writable_mountpoint(&self, realmfs: &RealmFS) -> bool { + match realmfs.metainfo().realmfs_owner() { + Some(name) => !realmfs.is_sealed() && name == self.name(), + None => false, + } + } + + /// Return named RealmFS instance if it already exists. + /// + /// Otherwise, create it as a fork of the 'default' image. + /// The default image is either 'base' or some other name + /// from the global realm config file. + /// + fn get_named_realmfs(&self, name: &str) -> Result { + + if let Some(realmfs) = self.manager().realmfs_by_name(name) { + return Ok(realmfs) + } + + if CommandLine::sealed() { + bail!("Realm {} needs RealmFS {} which does not exist and cannot be created in sealed realmfs mode", self.name(), name); + } + + self.fork_default_realmfs(name) + } + + /// Create named RealmFS instance as a fork of 'default' RealmFS instance + fn fork_default_realmfs(&self, name: &str) -> Result { + let default = self.get_default_realmfs()?; + // Requested name might be the default image, if so return it. + if name == default.name() { + Ok(default) + } else { + default.fork(name) + } + } + + /// Return 'default' RealmFS instance as listed in global realms config file. + /// + /// If the default image does not exist, then create it as a fork + /// of 'base' image. + fn get_default_realmfs(&self) -> Result { + let default = GLOBAL_CONFIG.realmfs(); + + let manager = self.manager(); + + if let Some(realmfs) = manager.realmfs_by_name(default) { + Ok(realmfs) + } else if let Some(base) = manager.realmfs_by_name("base") { + // If default image name is something other than 'base' and does + // not exist, create it as a fork of 'base' + base.fork(default) + } else { + Err(format_err!("Default RealmFS '{}' does not exist and neither does 'base'", default)) + } + } + + fn dir_name(&self) -> String { + format!("realm-{}", self.name) + } + + /// Return the base path directory of this realm. + /// + /// The base path of a realm with name 'main' would be: + /// + /// /realms/realm-main + /// + pub fn base_path(&self) -> PathBuf { + Path::new(Realms::BASE_PATH).join(self.dir_name()) + } + + /// Join a filename to the base path of this realm and return it + pub fn base_path_file(&self, name: &str) -> PathBuf { + self.base_path().join(name) + } + + /// Return the run path directory of this realm. + /// + /// The run path of a realm with name 'main' would be: + /// + /// /run/citadel/realms/realm-main + /// + pub fn run_path(&self) -> PathBuf { + Path::new(Realms::RUN_PATH).join(self.dir_name()) + } + + /// Join a filename to the run path of this realm and return it. + pub fn run_path_file(&self, name: &str) -> PathBuf { + self.run_path().join(name) + } + + /// Return `Arc` containing the configuration of this realm. + /// If the config file has not yet been loaded from disk, it is lazy loaded + /// the first time this method is called. + pub fn config(&self) -> Arc { + if self.inner_config().is_stale() { + if let Err(err) = self.with_mut_config(|config| config.reload()) { + warn!("error loading config file for realm {}: {}", self.name(), err); + } + } + self.inner_config() + } + + fn inner_config(&self) -> Arc { + self.inner().config.clone() + } + + pub fn with_mut_config(&self, f: F) -> R + where F: FnOnce(&mut RealmConfig) -> R + { + + let mut lock = self.inner_mut(); + + let mut config = lock.config.as_ref().clone(); + let result = f(&mut config); + lock.config = Arc::new(config); + result + } + + /// Return `true` if this realm is configured to use a read-only RealmFS mount. + pub fn readonly_rootfs(&self) -> bool { + if self.config().overlay() != OverlayType::None { + false + } else if CommandLine::sealed() { + true + } else { + !self.config().realmfs_write() + } + } + + /// Return path to root directory as seen by mount namespace inside the realm container + /// This is the path to /proc/PID/root where PID is the 'outside' process id of the + /// systemd instance (pid 1) inside the realm pid namespace. + pub fn proc_rootfs(&self) -> Option { + self.leader_pid().map(|pid| PathBuf::from(format!("/proc/{}/root", pid))) + } + + + /// Query for 'leader pid' of realm nspawn instance with machinectl. + /// The leader pid is the 'pid 1' of the realm container as seen from + /// outside the PID namespace. + pub fn leader_pid(&self) -> Option { + if !self.is_active() { + return None; + } + + let mut lock = self.inner_mut(); + + if let Some(pid) = lock.leader_pid { + return Some(pid); + } + match self.query_leader_pid() { + Ok(pid) => lock.leader_pid = Some(pid), + Err(e) => warn!("error retrieving leader pid for realm: {}", e) + } + lock.leader_pid + } + + fn query_leader_pid(&self) -> Result { + let output = cmd_with_output!("/usr/bin/machinectl", "show --value {} -p Leader", self.name())?; + let pid = output.parse::() + .map_err(|_| format_err!("Failed to parse leader pid output from machinectl: {}", output))?; + Ok(pid) + } + + /// Return `true` if `name` is a valid name for a realm. + /// + /// Valid realm names: + /// + /// * must start with an alphabetic ascii letter character + /// * may only contain ascii characters which are letters, numbers, or the dash '-' symbol + /// * must not be empty or have a length exceeding 128 characters + /// + pub fn is_valid_name(name: &str) -> bool { + util::is_valid_name(name, MAX_REALM_NAME_LEN) + } + + /// Return `true` if this realm is the current realm. + /// + /// A realm is current if the target of the current.realm symlink + /// is the run path of the realm. + pub fn is_current(&self) -> bool { + Realms::read_current_realm_symlink() == Some(self.run_path()) + //Realms::current_realm_name().as_ref() == Some(&self.name) + } + + /// Return `true` if this realm is the default realm. + /// + /// A realm is the default realm if the target of the + /// default.realm symlink is the base path of the realm. + pub fn is_default(&self) -> bool { + Realms::default_realm_name().as_ref() == Some(&self.name) + } + + pub fn notes(&self) -> Option { + let path = self.base_path_file("notes"); + if path.exists() { + return fs::read_to_string(path).ok(); + } + None + } + + pub fn save_notes(&self, notes: impl AsRef) -> Result<()> { + let path = self.base_path_file("notes"); + let notes = notes.as_ref(); + if path.exists() && notes.is_empty() { + fs::remove_file(path)?; + } else { + fs::write(path, notes)?; + } + Ok(()) + } +} + +impl Eq for Realm {} +impl PartialOrd for Realm { + fn partial_cmp(&self, other: &Realm) -> Option { + Some(self.cmp(other)) + } +} +impl PartialEq for Realm { + fn eq(&self, other: &Realm) -> bool { + self.partial_cmp(other) == Some(Ordering::Equal) + } +} +impl Ord for Realm { + fn cmp(&self, other: &Self) -> Ordering { + if !self.is_system() && other.is_system() { + Ordering::Less + } else if self.is_system() && !other.is_system() { + Ordering::Greater + } else if self.is_active() && !other.is_active() { + Ordering::Less + } else if !self.is_active() && other.is_active() { + Ordering::Greater + } else if self.timestamp() == other.timestamp() { + self.name().cmp(other.name()) + } else { + other.timestamp().cmp(&self.timestamp()) + } + } +} + + diff --git a/libcitadel/src/realm/realms.rs b/libcitadel/src/realm/realms.rs new file mode 100644 index 0000000..0560db3 --- /dev/null +++ b/libcitadel/src/realm/realms.rs @@ -0,0 +1,416 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path,PathBuf}; +use std::fs; + +use crate::{Realm, Result, symlink, RealmManager,FileLock}; +use std::sync::{Arc, Weak}; +use super::create::RealmCreateDestroy; +use crate::realm::systemd::Systemd; + +struct RealmMapList { + manager: Weak, + map: HashMap, + list: Vec, +} + +impl RealmMapList { + fn new() -> Self { + let map = HashMap::new(); + let list = Vec::new(); + let manager = Weak::new(); + RealmMapList { manager, map, list } + } + + fn set_manager(&mut self, manager: Arc) { + self.manager = Arc::downgrade(&manager); + self.list.iter_mut().for_each(|r| r.set_manager(manager.clone())); + self.map.iter_mut().for_each(|(_,r)| r.set_manager(manager.clone())); + } + + fn insert(&mut self, realm: Realm) -> Realm { + let mut realm = realm; + if let Some(manager) = self.manager.upgrade() { + realm.set_manager(manager); + } + let key = realm.name().to_string(); + self.map.insert(key, realm.clone()); + self.list.push(realm.clone()); + self.sort(); + realm + } + + fn take(&mut self, name: &str) -> Option { + self.list.retain(|r| r.name() != name); + let result = self.map.remove(name); + assert_eq!(self.list.len(), self.map.len()); + result + } + + fn sort(&mut self) { + self.list.sort_unstable(); + } + + fn len(&self) -> usize { + self.list.len() + } +} + +pub struct Realms { + manager: Weak, + realms: RealmMapList, + last_current: Option, +} + +impl Realms { + + pub const BASE_PATH: &'static str = "/realms"; + pub const RUN_PATH: &'static str = "/run/citadel/realms"; + + pub fn load() -> Result { + let _lock = Self::realmslock()?; + + let mut realms = RealmMapList::new(); + + for realm in Self::all_realms(true)? { + realms.insert(realm); + } + + let manager = Weak::new(); + + Ok( Realms { realms, manager, last_current: None }) + } + + + fn all_realms(mark_active: bool) -> Result> { + let mut v = Vec::new(); + for entry in fs::read_dir(Realms::BASE_PATH)? { + if let Some(realm) = Realms::entry_to_realm(entry?) { + v.push(realm); + } + } + if mark_active { + Realms::mark_active_realms(&mut v)?; + } + Ok(v) + } + + pub fn set_manager(&mut self, manager: Arc) { + self.manager = Arc::downgrade(&manager); + self.realms.set_manager(manager); + } + + // Examine a directory entry and if it looks like a legit realm directory + // extract realm name and return a `Realm` instance. + fn entry_to_realm(entry: fs::DirEntry) -> Option { + match entry.path().symlink_metadata() { + Ok(ref meta) if meta.is_dir() => {}, + _ => return None, + }; + + if let Ok(filename) = entry.file_name().into_string() { + if filename.starts_with("realm-") { + let (_, name) = filename.split_at(6); + if Realm::is_valid_name(name) { + return Some(Realm::new(name)) + } + } + } + None + } + + // Determine which realms are running with a single 'systemctl is-active' call. + fn mark_active_realms(realms: &mut Vec) -> Result<()> { + + let output = Systemd::are_realms_active(realms)?; + + // process the lines of output together with the list of realms with .zip() + realms.iter_mut() + .zip(output.lines()) + .for_each(|(r,line)| r.set_active_from_systemctl(line)); + + Ok(()) + } + + pub fn list(&self) -> Vec { + self.realms.list.clone() + } + + pub fn sorted(&mut self) -> Vec { + self.realms.sort(); + self.list() + } + + + pub fn realm_count(&self) -> usize { + self.realms.len() + } + + pub fn active(&self, ignore_system: bool) -> Vec { + self.realms.list.iter() + .filter(|r| r.is_active() && !(ignore_system && r.is_system())) + .cloned() + .collect() + } + + fn add_realm(&mut self, name: &str) -> Realm { + self.realms.insert(Realm::new(name)) + } + + fn name_set<'a, T>(realms: T) -> HashSet + where T: IntoIterator + { + realms.into_iter().map(|r| r.name().to_string()).collect() + + } + + /// + /// Read the /realms directory for the current set of Realms + /// that exist on disk. Compare this to the collection of + /// realms in `self.realms` to determine if realms have been + /// added or removed and update the collection with current + /// information. + /// + /// Returns a pair of vectors `(added,removed)` containing + /// realms that have been added or removed by the operation. + /// + pub fn rescan_realms(&mut self) -> Result<(Vec,Vec)> { + let _lock = Self::realmslock()?; + + let mut added = Vec::new(); + let mut removed = Vec::new(); + + let current_realms = Self::all_realms(false)?; + let new_names = Self::name_set(¤t_realms); + let old_names = Self::name_set(&self.realms.list); + + // + // names that used to exist and now don't exist have + // been removed. Pull those realms out of collection of + // realms known to exist (self.realms). + // + // Set(old_names) - Set(new_names) = Set(removed) + // + for name in old_names.difference(&new_names) { + if let Some(realm) = self.realms.take(name) { + removed.push(realm); + } + } + + // + // Set(new_names) - Set(old_names) = Set(added) + // + for name in new_names.difference(&old_names) { + added.push(self.add_realm(name)); + } + + Ok((added, removed)) + } + + // + // Create a locking file /realms/.realmslock and lock it with + // with flock(2). FileLock will drop the lock when it goes + // out of scope. + // + // Lock is held when iterating over realm instance directories + // or when adding or removing a realm directory. + // + fn realmslock() -> Result { + let lockpath = Path::new(Realms::BASE_PATH) + .join(".realmslock"); + + FileLock::acquire(lockpath) + } + + pub fn create_realm(&mut self, name: &str) -> Result { + let _lock = Self::realmslock()?; + + if !Realm::is_valid_name(name) { + bail!("'{}' is not a valid realm name. Only letters, numbers and dash '-' symbol allowed in name. First character must be a letter", name); + } else if self.by_name(name).is_some() { + bail!("A realm with name '{}' already exists", name); + } + + RealmCreateDestroy::new(name).create()?; + + Ok(self.add_realm(name)) + } + + pub fn delete_realm(&mut self, name: &str, save_home: bool) -> Result<()> { + let _lock = Self::realmslock()?; + + let realm = match self.realms.take(name) { + Some(realm) => realm, + None => bail!("Cannot remove realm '{}' because it doesn't seem to exist", name), + }; + + if realm.is_active() { + bail!("Cannot remove active realm. Stop realm {} before deleting", name); + } + + RealmCreateDestroy::new(name).delete_realm(save_home)?; + + if realm.is_default() { + Self::clear_default_realm()?; + self.set_arbitrary_default()?; + } + Ok(()) + } + + pub fn set_none_current(&mut self) -> Result<()> { + Self::clear_current_realm()?; + self.last_current = None; + Ok(()) + } + + pub fn set_realm_current(&mut self, realm: &Realm) -> Result<()> { + symlink::write(realm.run_path(), Realms::current_realm_symlink(), true)?; + self.last_current = Some(realm.clone()); + Ok(()) + } + + pub fn set_realm_default(&self, realm: &Realm) -> Result<()> { + symlink::write(realm.base_path(), Realms::default_symlink(), false) + } + + fn set_arbitrary_default(&mut self) -> Result<()> { + // Prefer a recently used realm and don't choose a system realm + let choice = self.sorted() + .into_iter() + .filter(|r| !r.is_system()) + .next(); + + if let Some(realm) = choice { + info!("Setting '{}' as new default realm", realm.name()); + self.set_realm_default(&realm)?; + } + Ok(()) + } + + fn set_arbitrary_current(&mut self) -> Result<()> { + self.realms.sort(); + if let Some(realm) = self.active(true).first() { + self.set_realm_current(realm)?; + } else { + self.set_none_current()?; + } + Ok(()) + } + + pub fn choose_some_current(&mut self) -> Result<()> { + self.set_arbitrary_current() + } + + pub fn by_name(&self, name: &str) -> Option { + self.realms.map.get(name).cloned() + } + + pub fn current(&mut self) -> Option { + let current = Realms::current_realm_name().and_then(|name| self.by_name(&name)); + self.last_current = current.clone(); + current + } + + // None : no it's the same + // Some() : yes and here's the new value + pub fn has_current_changed(&mut self) -> Option> { + let old = self.last_current.clone(); + let current = self.current(); + if current == old { + None + } else { + Some(current) + } + } + + pub fn default(&self) -> Option { + Realms::default_realm_name().and_then(|name| self.by_name(&name)) + } + + /// Return the `Realm` marked as current, or `None` if no realm is current. + /// + /// This should only be used when not instantiating a `RealmManager` + /// otherwise the current realm should be accessed through the manager. + /// + /// The current realm is determined by reading symlink at path: + /// + /// /run/citadel/realms/current/current.realm + /// + /// If the symlink exists it will point to run path of the current realm. + /// + pub fn load_current_realm() -> Option { + Realms::current_realm_name().map(|ref name| Realm::new(name)) + } + + /// Return `true` if some realm has been marked as current. + /// + /// Whether or not a realm has been marked as current is determined + /// by checking for the existence of the symlink at path: + /// + /// /run/citadel/realms/current/current.realm + /// + pub fn is_some_realm_current() -> bool { + Realms::current_realm_symlink().exists() + } + + /// Set no realm as current by removing the current.realm symlink. + fn clear_current_realm() -> Result<()> { + symlink::remove(Realms::current_realm_symlink()) + } + + /// Set no realm as default by removing the default.realm symlink. + pub fn clear_default_realm() -> Result<()> { + symlink::remove(Realms::default_symlink()) + } + + // Path of 'current.realm' symlink + pub fn current_realm_symlink() -> PathBuf { + Path::new(Realms::RUN_PATH) + .join("current") + .join("current.realm") + } + + pub fn current_realm_name() -> Option { + Realms::read_current_realm_symlink().as_ref().and_then(Realms::path_to_realm_name) + } + + pub fn read_current_realm_symlink() -> Option { + symlink::read(Realms::current_realm_symlink()) + } + + // Path of 'default.realm' symlink + pub fn default_symlink() -> PathBuf { + Path::new(Realms::BASE_PATH) + .join("default.realm") + } + + pub fn default_realm_name() -> Option { + Realms::read_default_symlink().as_ref().and_then(Realms::path_to_realm_name) + } + + fn read_default_symlink() -> Option { + symlink::read(Realms::default_symlink()) + } + + fn path_to_realm_name(path: impl AsRef) -> Option { + let path = path.as_ref(); + if path.starts_with(Realms::BASE_PATH) { + path.strip_prefix(Realms::BASE_PATH).ok() + } else if path.starts_with(Realms::RUN_PATH) { + path.strip_prefix(Realms::RUN_PATH).ok() + } else { + None + }.and_then(Realms::dir_to_realm_name) + } + + fn dir_to_realm_name(dir: &Path) -> Option { + let dirname = dir.to_string_lossy(); + if dirname.starts_with("realm-") { + let (_,name) = dirname.split_at(6); + if Realm::is_valid_name(name) { + return Some(name.to_string()); + } + } + None + } + +} diff --git a/libcitadel/src/realm/systemd.rs b/libcitadel/src/realm/systemd.rs new file mode 100644 index 0000000..481a56c --- /dev/null +++ b/libcitadel/src/realm/systemd.rs @@ -0,0 +1,374 @@ +use std::process::Command; +use std::path::{Path,PathBuf}; +use std::fs; +use std::fmt::Write; +use std::env; + +const SYSTEMCTL_PATH: &str = "/usr/bin/systemctl"; +const MACHINECTL_PATH: &str = "/usr/bin/machinectl"; +const SYSTEMD_NSPAWN_PATH: &str = "/run/systemd/nspawn"; +const SYSTEMD_UNIT_PATH: &str = "/run/systemd/system"; + +use crate::Result; + +use crate::Realm; +use std::sync::Mutex; +use std::process::Stdio; +use crate::realm::network::NetworkConfig; + +pub struct Systemd { + network: Mutex, +} + +impl Systemd { + + pub fn new(network: NetworkConfig) -> Systemd { + let network = Mutex::new(network); + Systemd { network } + } + + pub fn start_realm(&self, realm: &Realm, rootfs: &Path) -> Result<()> { + self.write_realm_launch_config(realm, rootfs)?; + self.systemctl_start(&self.realm_service_name(realm))?; + if realm.config().ephemeral_home() { + self.setup_ephemeral_home(realm)?; + } + Ok(()) + } + + fn setup_ephemeral_home(&self, realm: &Realm) -> Result<()> { + + // 1) if exists: machinectl copy-to /realms/skel /home/user + if Path::new("/realms/skel").exists() { + self.machinectl_copy_to(realm, "/realms/skel", "/home/user")?; + } + + // 2) if exists: machinectl copy-to /realms/realm-$name /home/user + let realm_skel = realm.base_path_file("skel"); + if realm_skel.exists() { + self.machinectl_copy_to(realm, realm_skel.to_str().unwrap(), "/home/user")?; + } + + let home = realm.base_path_file("home"); + if !home.exists() { + return Ok(()); + } + + for dir in realm.config().ephemeral_persistent_dirs() { + let src = home.join(&dir); + if src.exists() { + let src = src.canonicalize()?; + if src.starts_with(&home) && src.exists() { + let dst = Path::new("/home/user").join(&dir); + self.machinectl_bind(realm, &src, &dst)?; + } + } + } + + Ok(()) + } + + pub fn stop_realm(&self, realm: &Realm) -> Result<()> { + self.systemctl_stop(&self.realm_service_name(realm))?; + self.remove_realm_launch_config(realm)?; + + let mut network = self.network.lock().unwrap(); + network.free_allocation_for(realm.config().network_zone(), realm.name())?; + Ok(()) + } + + fn realm_service_name(&self, realm: &Realm) -> String { + format!("realm-{}.service", realm.name()) + } + + fn systemctl_start(&self, name: &str) -> Result { + self.run_systemctl("start", name) + } + + fn systemctl_stop(&self, name: &str) -> Result { + self.run_systemctl("stop", name) + } + + fn run_systemctl(&self, op: &str, name: &str) -> Result { + Command::new(SYSTEMCTL_PATH) + .arg(op) + .arg(name) + .status() + .map(|status| status.success()) + .map_err(|e| format_err!("failed to execute {}: {}", MACHINECTL_PATH, e)) + } + + pub fn machinectl_copy_to(&self, realm: &Realm, from: impl AsRef, to: &str) -> Result<()> { + let from = from.as_ref().to_str().unwrap(); + info!("calling machinectl copy-to {} {} {}", realm.name(), from, to); + Command::new(MACHINECTL_PATH) + .args(&["copy-to", realm.name(), from, to ]) + .status() + .map_err(|e| format_err!("failed to machinectl copy-to {} {} {}: {}", realm.name(), from, to, e))?; + Ok(()) + } + + fn machinectl_bind(&self, realm: &Realm, from: &Path, to: &Path) -> Result<()> { + let from = from.display().to_string(); + let to = to.display().to_string(); + Command::new(MACHINECTL_PATH) + .args(&["--mkdir", "bind", realm.name(), from.as_str(), to.as_str() ]) + .status() + .map_err(|e| format_err!("failed to machinectl bind {} {} {}: {}", realm.name(), from, to, e))?; + Ok(()) + } + + pub fn is_active(realm: &Realm) -> Result { + Command::new(SYSTEMCTL_PATH) + .args(&["--quiet", "is-active"]) + .arg(format!("realm-{}", realm.name())) + .status() + .map(|status| status.success()) + .map_err(|e| format_err!("failed to execute {}: {}", SYSTEMCTL_PATH, e)) + } + + pub fn are_realms_active(realms: &mut Vec) -> Result { + let args: Vec = realms.iter() + .map(|r| format!("realm-{}", r.name())) + .collect(); + + let result = Command::new("/usr/bin/systemctl") + .arg("is-active") + .args(args) + .stderr(Stdio::inherit()) + .output()?; + + Ok(String::from_utf8(result.stdout).unwrap().trim().to_owned()) + } + + pub fn machinectl_exec_shell(realm: &Realm, as_root: bool, launcher: bool) -> Result<()> { + let username = if as_root { "root" } else { "user" }; + let args = ["/bin/bash".to_string()]; + Self::machinectl_shell(realm, &args, username, launcher, false) + } + + pub fn machinectl_shell>(realm: &Realm, args: &[S], user: &str, launcher: bool, quiet: bool) -> Result<()> { + let mut cmd = Command::new(MACHINECTL_PATH); + cmd.arg("--quiet"); + + cmd.arg(format!("--setenv=REALM_NAME={}", realm.name())); + + if let Ok(val) = env::var("DESKTOP_STARTUP_ID") { + cmd.arg(format!("--setenv=DESKTOP_STARTUP_ID={}", val)); + } + + let config = realm.config(); + if config.wayland() && !config.x11() { + cmd.arg("--setenv=GDK_BACKEND=wayland"); + } + + cmd.arg("shell"); + cmd.arg(format!("{}@{}", user, realm.name())); + + if launcher { + cmd.arg("/usr/libexec/launch"); + } + + if quiet { + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + + for arg in args { + cmd.arg(arg.as_ref()); + } + + cmd.status().map_err(|e| format_err!("failed to execute{}: {}", MACHINECTL_PATH, e))?; + Ok(()) + } + + + fn realm_service_path(&self, realm: &Realm) -> PathBuf { + PathBuf::from(SYSTEMD_UNIT_PATH).join(self.realm_service_name(realm)) + } + + fn realm_nspawn_path(&self, realm: &Realm) -> PathBuf { + PathBuf::from(SYSTEMD_NSPAWN_PATH).join(format!("{}.nspawn", realm.name())) + } + + fn remove_realm_launch_config(&self, realm: &Realm) -> Result<()> { + let nspawn_path = self.realm_nspawn_path(realm); + if nspawn_path.exists() { + fs::remove_file(&nspawn_path)?; + } + let service_path = self.realm_service_path(realm); + if service_path.exists() { + fs::remove_file(&service_path)?; + } + Ok(()) + } + + fn write_realm_launch_config(&self, realm: &Realm, rootfs: &Path) -> Result<()> { + let nspawn_path = self.realm_nspawn_path(realm); + let nspawn_content = self.generate_nspawn_file(realm)?; + self.write_launch_config_file(&nspawn_path, &nspawn_content) + .map_err(|e| format_err!("failed to write nspawn config file {}: {}", nspawn_path.display(), e))?; + + let service_path = self.realm_service_path(realm); + let service_content = self.generate_service_file(realm, rootfs); + self.write_launch_config_file(&service_path, &service_content) + .map_err(|e| format_err!("failed to write service config file {}: {}", service_path.display(), e))?; + + Ok(()) + } + + /// Write the string `content` to file `path`. If the directory does + /// not already exist, create it. + fn write_launch_config_file(&self, path: &Path, content: &str) -> Result<()> { + match path.parent() { + Some(parent) => { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + }, + None => bail!("config file path {} has no parent?", path.display()), + }; + fs::write(path, content)?; + Ok(()) + } + + fn generate_nspawn_file(&self, realm: &Realm) -> Result { + Ok(NSPAWN_FILE_TEMPLATE + .replace("$EXTRA_BIND_MOUNTS", &self.generate_extra_bind_mounts(realm)?) + .replace("$EXTRA_FILE_OPTIONS", &self.generate_extra_file_options(realm)?) + .replace("$NETWORK_CONFIG", &self.generate_network_config(realm)?)) + } + + fn generate_extra_bind_mounts(&self, realm: &Realm) -> Result { + let config = realm.config(); + let mut s = String::new(); + + if config.ephemeral_home() { + writeln!(s, "TemporaryFileSystem=/home/user:mode=755,uid=1000,gid=1000")?; + } else { + writeln!(s, "Bind={}:/home/user", realm.base_path_file("home").display())?; + } + + if config.shared_dir() && Path::new("/realms/Shared").exists() { + writeln!(s, "Bind=/realms/Shared:/home/user/Shared")?; + } + + if config.kvm() { + writeln!(s, "Bind=/dev/kvm")?; + } + + if config.gpu() { + writeln!(s, "Bind=/dev/dri/renderD128")?; + if config.gpu_card0() { + writeln!(s, "Bind=/dev/dri/card0")?; + } + } + + if config.sound() { + writeln!(s, "Bind=/dev/snd")?; + writeln!(s, "Bind=/dev/shm")?; + writeln!(s, "BindReadOnly=/run/user/1000/pulse:/run/user/host/pulse")?; + } + + if config.x11() { + writeln!(s, "BindReadOnly=/tmp/.X11-unix")?; + } + + if config.wayland() { + writeln!(s, "BindReadOnly=/run/user/1000/wayland-0:/run/user/host/wayland-0")?; + } + + for bind in config.extra_bindmounts() { + if self.is_valid_bind_item(bind) { + writeln!(s, "Bind={}", bind)?; + } + } + + for bind in config.extra_bindmounts_ro() { + if self.is_valid_bind_item(bind) { + writeln!(s, "BindReadOnly={}", bind)?; + } + } + Ok(s) + } + + fn is_valid_bind_item(&self, item: &str) -> bool { + !item.contains('\n') + } + + fn generate_extra_file_options(&self, realm: &Realm) -> Result { + let mut s = String::new(); + if realm.readonly_rootfs() { + writeln!(s, "ReadOnly=true")?; + writeln!(s, "Overlay=+/var::/var")?; + } + Ok(s) + } + + fn generate_network_config(&self, realm: &Realm) -> Result { + let config = realm.config(); + let mut s = String::new(); + if config.network() { + if config.has_netns() { + return Ok(s); + } + let mut netconf = self.network.lock().unwrap(); + let zone = config.network_zone(); + let addr = if let Some(addr) = config.reserved_ip() { + netconf.allocate_reserved(zone, realm.name(), addr)? + } else { + netconf.allocate_address_for(zone, realm.name())? + }; + let gw = netconf.gateway(zone)?; + writeln!(s, "Environment=IFCONFIG_IP={}", addr)?; + writeln!(s, "Environment=IFCONFIG_GW={}", gw)?; + writeln!(s, "[Network]")?; + writeln!(s, "Zone=clear")?; + } else { + writeln!(s, "[Network]")?; + writeln!(s, "Private=true")?; + } + Ok(s) + } + + fn generate_service_file(&self, realm: &Realm, rootfs: &Path) -> String { + let rootfs = rootfs.display().to_string(); + let netns_arg = match realm.config().netns() { + Some(netns) => format!("--network-namespace-path=/run/netns/{}", netns), + None => "".into(), + }; + + REALM_SERVICE_TEMPLATE.replace("$REALM_NAME", realm.name()).replace("$ROOTFS", &rootfs).replace("$NETNS_ARG", &netns_arg) + } +} + + +pub const NSPAWN_FILE_TEMPLATE: &str = r###" +[Exec] +Boot=true +$NETWORK_CONFIG + +[Files] +BindReadOnly=/opt/share +BindReadOnly=/storage/citadel-state/resolv.conf:/etc/resolv.conf + +$EXTRA_BIND_MOUNTS + +$EXTRA_FILE_OPTIONS + +"###; + +pub const REALM_SERVICE_TEMPLATE: &str = r###" +[Unit] +Description=Application Image $REALM_NAME instance + +[Service] +Environment=SYSTEMD_NSPAWN_SHARE_NS_IPC=1 +ExecStart=/usr/bin/systemd-nspawn --quiet --notify-ready=yes --keep-unit $NETNS_ARG --machine=$REALM_NAME --link-journal=auto --directory=$ROOTFS + +KillMode=mixed +Type=notify +RestartForceExitStatus=133 +SuccessExitStatus=133 +"###;