From f9f94b37d72eb808cd445e9b16d004ca14a044f2 Mon Sep 17 00:00:00 2001 From: Oskar Rochowiak Date: Tue, 9 Jun 2026 18:26:24 +0200 Subject: [PATCH] tty switching, seat management --- Cargo.lock | 21 + ctl/src/main.rs | 28 +- daemon/Cargo.toml | 4 +- daemon/build.rs | 1 + daemon/src/cgroup.rs | 23 +- daemon/src/client.rs | 55 ++ daemon/src/device.rs | 268 +++++++++ daemon/src/main.rs | 453 ++++++++------- daemon/src/seat.rs | 827 ++++++++++++++++++++++++++++ daemon/src/session.rs | 190 +++++++ daemon/src/terminal.rs | 187 +++++++ nix/finix-vm.nix | 8 +- nix/finix.nix | 17 +- pam/src/lib.rs | 35 +- protocols/seat_v1.xml | 116 ++++ protocols/session_core_v1.xml | 7 +- protocols/session_management_v1.xml | 3 +- 17 files changed, 2001 insertions(+), 242 deletions(-) create mode 100644 daemon/src/client.rs create mode 100644 daemon/src/device.rs create mode 100644 daemon/src/seat.rs create mode 100644 daemon/src/session.rs create mode 100644 daemon/src/terminal.rs create mode 100644 protocols/seat_v1.xml diff --git a/Cargo.lock b/Cargo.lock index 752b287..873b48f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags", + "nix", "polling", "rustix", "slab", @@ -92,6 +93,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -171,11 +178,13 @@ version = "0.1.0" dependencies = [ "anyhow", "calloop", + "clap", "env_logger", "hyprwire", "hyprwire-scanner", "log", "rustix", + "users", ] [[package]] @@ -309,6 +318,18 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" diff --git a/ctl/src/main.rs b/ctl/src/main.rs index d417b07..846103f 100644 --- a/ctl/src/main.rs +++ b/ctl/src/main.rs @@ -16,7 +16,7 @@ struct Session { object: session_core_session::SessionCoreSession, uid: u32, username: String, - tty: String, + vt: u32, seat: Option, leader: u32, timestamp: u32, @@ -44,7 +44,7 @@ impl hyprwire::Dispatch for State { object: object.send_get_session::(session_id).unwrap(), uid: 0, username: String::new(), - tty: String::new(), + vt: 0, seat: None, leader: 0, timestamp: 0, @@ -60,7 +60,7 @@ impl hyprwire::Dispatch for State { object: object.send_get_session::(session_id).unwrap(), uid: 0, username: String::new(), - tty: String::new(), + vt: 0, seat: None, leader: 0, timestamp: 0, @@ -93,7 +93,7 @@ impl hyprwire::Dispatch for State { match event { session_core_session::Event::Uid { uid } => session.uid = uid, session_core_session::Event::Username { username } => session.username = username, - session_core_session::Event::Tty { tty } => session.tty = tty, + session_core_session::Event::Vt { vt } => session.vt = vt, session_core_session::Event::Seat { seat } => session.seat = Some(seat), session_core_session::Event::Leader { leader } => session.leader = leader, session_core_session::Event::Timestamp { timestamp } => session.timestamp = timestamp, @@ -107,12 +107,7 @@ impl hyprwire::Dispatch for State { self.done = true; } session_core_session::Event::Active { state } => { - session.active = match state { - 0 => session_core_v1::ActiveState::Online, - 1 => session_core_v1::ActiveState::Active, - 2 => session_core_v1::ActiveState::Closing, - _ => unimplemented!("unknown active state: {state}"), - }; + session.active = state; } _ => {} } @@ -177,6 +172,7 @@ fn main() -> anyhow::Result<()> { session_core_v1::ActiveState::Online => "online", session_core_v1::ActiveState::Active => "active", session_core_v1::ActiveState::Closing => "closing", + session_core_v1::ActiveState::Opening => "opening", } }; @@ -222,10 +218,10 @@ fn main() -> anyhow::Result<()> { .max() .unwrap_or(0) .max(4); - let tty_w = state + let vt_w = state .sessions .iter() - .map(|s| s.tty.len()) + .map(|s| format!("tty{}", s.vt).len()) .max() .unwrap_or(0) .max(3); @@ -259,7 +255,7 @@ fn main() -> anyhow::Result<()> { .max(5); println!( - " {: anyhow::Result<()> { + 2 + user_w + 2 - + tty_w + + vt_w + 2 + seat_w + 2 @@ -283,11 +279,11 @@ fn main() -> anyhow::Result<()> { ); for session in state.sessions { println!( - " {: anyhow::Result<()> { - let session_cgroup_path = self.root_path.join(format!("session-{session_id}")); + let session_cgroup_path = self + .root_path + .join(format!("session-{}", session_id.as_raw())); fs::create_dir(&session_cgroup_path)?; fs::write( session_cgroup_path.join("cgroup.procs"), leader_pid.to_string(), )?; - log::info!(session_id = session_id, leader_pid = leader_pid, path = session_cgroup_path.to_str(); "cgroup created"); + log::info!(session_id = session_id.as_raw(), leader_pid = leader_pid, path = session_cgroup_path.to_str(); "cgroup created"); let cgroup = Cgroup { session_id, @@ -48,7 +51,7 @@ impl CgroupManager { Ok(()) } - pub fn poll_destroy(&self, session_id: u32) -> anyhow::Result<()> { + pub fn poll_destroy(&self, session_id: SessionId) -> anyhow::Result<()> { let cgroup = self .cgroups .iter() @@ -80,7 +83,7 @@ impl CgroupManager { .context("session cgroup not found")?; fs::remove_dir(&cgroup.path)?; - log::info!(session_id = session_id, path = cgroup.path.to_str(); "cgroup destroyed"); + log::info!(session_id = session_id.as_raw(), path = cgroup.path.to_str(); "cgroup destroyed"); return Ok(calloop::PostAction::Remove); } @@ -90,7 +93,7 @@ impl CgroupManager { match result { Ok(action) => Ok(action), Err(e) => { - log::error!(session_id = session_id, error = e.to_string().as_str(); "cgroup poll error"); + log::error!(session_id = session_id.as_raw(), error = e.to_string().as_str(); "cgroup poll error"); Ok(calloop::PostAction::Remove) } } @@ -99,7 +102,7 @@ impl CgroupManager { Ok(()) } - pub fn kill_session(&self, session_id: u32) -> anyhow::Result<()> { + pub fn kill_session(&self, session_id: SessionId) -> anyhow::Result<()> { let cgroup = self .cgroups .iter() @@ -112,7 +115,7 @@ impl CgroupManager { Ok(()) } - pub fn terminate_session(&self, session_id: u32) -> anyhow::Result<()> { + pub fn terminate_session(&self, session_id: SessionId) -> anyhow::Result<()> { let cgroup = self .cgroups .iter() @@ -130,7 +133,7 @@ impl CgroupManager { Ok(()) } - pub fn find_session_by_pid(&self, pid: u32) -> anyhow::Result> { + pub fn find_session_by_pid(&self, pid: u32) -> anyhow::Result> { for cgroup in self.cgroups.iter() { let procs = fs::read_to_string(cgroup.path.join("cgroup.procs"))?; @@ -148,6 +151,6 @@ impl CgroupManager { } struct Cgroup { - session_id: u32, + session_id: SessionId, path: path::PathBuf, } diff --git a/daemon/src/client.rs b/daemon/src/client.rs new file mode 100644 index 0000000..d7d8f50 --- /dev/null +++ b/daemon/src/client.rs @@ -0,0 +1,55 @@ +use crate::device; +use crate::seat_v1; +use crate::seat_v1::seat; + +pub struct Client { + pub(crate) seat_name: String, + pub(crate) object: seat::Seat, + pub(crate) devices: Vec, + pub(crate) state: crate::seat::SeatState, + pub(crate) session_id: u32, +} + +impl PartialEq for Client { + fn eq(&self, other: &Self) -> bool { + self.object == other.object + } +} + +impl Eq for Client {} + +impl Client { + pub(crate) fn close_device(&mut self, object: &seat_v1::device::Device) -> anyhow::Result<()> { + let idx = self + .devices + .iter() + .position(|device| device.contains_object(object)) + .ok_or_else(|| anyhow::anyhow!("device not found"))?; + + let device = &mut self.devices[idx]; + device.remove_object(object); + log::debug!( + "Closing device {} for client {} on {}", + device.path().display(), + self.session_id, + self.seat_name + ); + + if device.decrement_ref() > 0 { + return Ok(()); + } + + if let Err(err) = device.deactivate() { + log::error!( + "Could not deactivate device {} for client {} on {}: {err}", + device.path().display(), + self.session_id, + self.seat_name + ); + } + + self.devices.remove(idx); + + Ok(()) + } +} diff --git a/daemon/src/device.rs b/daemon/src/device.rs new file mode 100644 index 0000000..0829802 --- /dev/null +++ b/daemon/src/device.rs @@ -0,0 +1,268 @@ +use crate::seat_v1::device; +use rustix::{io, ioctl}; +use std::ffi::c_int; +use std::os::fd; +use std::path; + +impl hyprwire::Dispatch for crate::Sessiond { + fn event( + &mut self, + object: &device::Device, + event: ::Event<'_>, + ) { + let device::Event::Destroy = event; + + let Some(client) = self.clients.iter().find(|client| { + client + .borrow() + .devices + .iter() + .any(|device| device.contains_object(object)) + }) else { + return; + }; + + if let Err(err) = client.borrow_mut().close_device(object) { + log::error!("Could not close device: {err}"); + } + } +} + +const DRM_SET_MASTER: ioctl::Opcode = ioctl::opcode::none(b'd', 0x1e); +const DRM_DROP_MASTER: ioctl::Opcode = ioctl::opcode::none(b'd', 0x1f); +const EVIOCREVOKE: ioctl::Opcode = ioctl::opcode::write::(b'E', 0x91); +const HIDIOCREVOKE: ioctl::Opcode = ioctl::opcode::write::(b'H', 0x0d); + +fn drm_set_master(fd: Fd) -> io::Result<()> { + unsafe { ioctl::ioctl(fd, ioctl::NoArg::::new()) } +} + +fn drm_drop_master(fd: Fd) -> io::Result<()> { + unsafe { ioctl::ioctl(fd, ioctl::NoArg::::new()) } +} + +fn evdev_revoke(fd: Fd) -> io::Result<()> { + unsafe { ioctl::ioctl(fd, ioctl::IntegerSetter::::new_usize(0)) } +} + +fn hidraw_revoke(fd: Fd) -> io::Result<()> { + unsafe { ioctl::ioctl(fd, ioctl::IntegerSetter::::new_usize(0)) } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceKind { + Evdev, + Drm, + Wscons, + Hidraw, +} + +impl DeviceKind { + pub fn from_path(path: &path::Path) -> Option { + if matches_named_device(path, "/dev/input", "event") { + Some(Self::Evdev) + } else if path.starts_with("/dev/dri/") || path.starts_with("/dev/drm/") { + Some(Self::Drm) + } else if path.starts_with("/dev/wskbd") + || path.starts_with("/dev/wsmouse") + || path.starts_with("/dev/wsmux") + { + Some(Self::Wscons) + } else if path.starts_with("/dev/hidraw") { + Some(Self::Hidraw) + } else { + None + } + } +} + +fn matches_named_device(path: &path::Path, parent: &str, prefix: &str) -> bool { + path.parent() == Some(path::Path::new(parent)) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(prefix)) +} + +pub struct Device { + objects: Vec, + path: path::PathBuf, + ref_cnt: usize, + r#type: DeviceKind, + id: u32, + active: bool, + fd: fd::OwnedFd, +} + +impl Device { + pub(crate) fn new( + object: device::Device, + path: path::PathBuf, + r#type: DeviceKind, + id: u32, + active: bool, + fd: fd::OwnedFd, + ) -> Self { + Self { + objects: vec![object], + path, + ref_cnt: 1, + r#type, + id, + active, + fd, + } + } + + pub(crate) fn activate(&mut self) -> anyhow::Result<()> { + if self.active { + return Ok(()); + } + + match self.r#type { + DeviceKind::Drm => { + drm_set_master(&self.fd) + .map_err(|err| anyhow::anyhow!("Could not make device fd drm master: {err}"))?; + self.active = true; + } + DeviceKind::Evdev | DeviceKind::Hidraw => { + return Err(anyhow::anyhow!("device type cannot be activated")); + } + DeviceKind::Wscons => { + self.active = true; + } + } + + Ok(()) + } + + pub(crate) fn deactivate(&mut self) -> anyhow::Result<()> { + if !self.active { + return Ok(()); + } + + match self.r#type { + DeviceKind::Drm => { + if let Err(err) = drm_drop_master(&self.fd) { + log::error!("Could not revoke drm master on device fd: {err}"); + return Err(err.into()); + } + } + DeviceKind::Evdev => { + if let Err(err) = evdev_revoke(&self.fd) { + log::error!("Could not revoke evdev on device fd: {err}"); + return Err(err.into()); + } + } + DeviceKind::Hidraw => { + if let Err(err) = hidraw_revoke(&self.fd) { + log::error!("Could not revoke hidraw on device fd: {err}"); + return Err(err.into()); + } + } + DeviceKind::Wscons => {} + } + + self.active = false; + Ok(()) + } + + pub(crate) fn matches_path(&self, path: &path::Path) -> bool { + self.path == path + } + + pub(crate) fn add_object(&mut self, object: device::Device) { + self.ref_cnt += 1; + self.objects.push(object); + } + + pub(crate) fn id(&self) -> u32 { + self.id + } + + pub(crate) fn kind(&self) -> DeviceKind { + self.r#type + } + + pub(crate) fn fd(&self) -> &fd::OwnedFd { + &self.fd + } + + pub(crate) fn path(&self) -> &path::Path { + &self.path + } + + pub(crate) fn absolute_path_string(&self) -> String { + path::absolute(&self.path) + .unwrap_or(self.path.clone()) + .display() + .to_string() + } + + pub(crate) fn last_object(&self) -> Option<&device::Device> { + self.objects.last() + } + + pub(crate) fn contains_object(&self, object: &device::Device) -> bool { + self.objects.iter().any(|candidate| candidate == object) + } + + pub(crate) fn remove_object(&mut self, object: &device::Device) { + self.objects.retain(|candidate| candidate != object); + } + + pub(crate) fn send_revoked(&self) { + for object in &self.objects { + object.send_revoked(); + } + } + + pub(crate) fn decrement_ref(&mut self) -> usize { + self.ref_cnt = self.ref_cnt.saturating_sub(1); + self.ref_cnt + } +} + +impl Drop for Device { + fn drop(&mut self) { + if !self.active { + return; + } + + if let Err(err) = self.deactivate() { + log::error!( + "Could not deactivate {} during drop: {err}", + self.path.display() + ); + } + self.active = false; + } +} + +#[cfg(test)] +mod tests { + use super::{DeviceKind, matches_named_device}; + use std::path::Path; + + #[test] + fn recognizes_evdev_event_nodes() { + assert_eq!( + DeviceKind::from_path(Path::new("/dev/input/event0")), + Some(DeviceKind::Evdev) + ); + assert_eq!( + DeviceKind::from_path(Path::new("/dev/input/event12")), + Some(DeviceKind::Evdev) + ); + } + + #[test] + fn does_not_match_partial_parent_paths() { + assert!(!matches_named_device( + Path::new("/dev/input/by-path/event0"), + "/dev/input", + "event" + )); + assert_eq!(DeviceKind::from_path(Path::new("/dev/input")), None); + } +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index b67f6a8..b0e5a14 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -2,83 +2,148 @@ mod session_management_v1 { hyprwire::include_protocol!("session_management_v1"); pub use server::*; } - mod session_core_v1 { hyprwire::include_protocol!("session_core_v1"); pub use server::*; pub use spec::*; } +mod seat_v1 { + hyprwire::include_protocol!("seat_v1"); + pub use server::*; + pub use spec::*; +} mod cgroup; - -use calloop::generic; +mod client; +mod device; +mod seat; +mod session; +mod terminal; + +use calloop::{generic, signals}; +use clap::Parser; use hyprwire::server; +use rustix::{net, process}; use session_core_v1::{session_core_manager, session_core_session}; use session_management_v1::session_manager; use std::os::fd::AsRawFd; use std::os::unix::fs::PermissionsExt; -use std::{env, fs, path, time}; - -struct Session { - id: u32, - uid: u32, - username: String, - tty: String, - seat: Option, - timestamp: time::SystemTime, - leader_id: i32, - objects: Vec, - state: session_core_v1::ActiveState, +use std::{cell, env, fs, path, rc}; + +#[derive(Parser)] +struct Args { + #[arg(long, value_name = "USER", help = "Allow USER to bind seat_v1")] + seat_user: Vec, + #[arg( + long, + value_name = "GROUP", + help = "Allow members of GROUP to bind seat_v1" + )] + seat_group: Vec, +} + +struct SeatAccess { + users: Vec, + groups: Vec, } -impl Session { - fn set_state(&mut self, state: session_core_v1::ActiveState) { - log::info!(session_id = self.id, tty = self.tty.as_str(); "session state changed"); - self.state = state; - for object in self.objects.iter() { - object.send_active(self.state as u32); +impl SeatAccess { + fn new(users: Vec, groups: Vec) -> Self { + if users.is_empty() && groups.is_empty() { + return Self { + users: vec![process::Uid::ROOT], + groups: vec![process::Gid::ROOT], + }; } + + Self { users, groups } } - fn add_object(&mut self, object: session_core_session::SessionCoreSession) { - log::debug!(session_id = self.id, uid = object.client().map(|c| c.creds().uid.as_raw()).unwrap_or(0); "session info requested"); - object.send_session_id(self.id); - object.send_uid(self.uid); - object.send_username(&self.username); - object.send_tty(&self.tty); - object.send_active(self.state as u32); - if let Some(seat) = &self.seat { - object.send_seat(seat); + fn allows(&self, creds: &net::UCred) -> bool { + if self.users.contains(&creds.uid) || self.groups.contains(&creds.gid) { + return true; } - object.send_leader(self.leader_id as u32); - object.send_timestamp( - self.timestamp - .duration_since(time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as u32, - ); - object.send_done(); - - self.objects.push(object); + + self.process_groups(creds.pid.as_raw_pid() as u32) + .is_ok_and(|groups| groups.iter().any(|gid| self.groups.contains(gid))) + } + + fn process_groups(&self, pid: u32) -> anyhow::Result> { + let status = fs::read_to_string(format!("/proc/{pid}/status"))?; + let Some(groups) = status.lines().find_map(|line| line.strip_prefix("Groups:")) else { + return Ok(Vec::new()); + }; + + groups + .split_whitespace() + .map(|group| { + group + .parse::() + .map(process::Gid::from_raw) + .map_err(Into::into) + }) + .collect() } } struct Sessiond { - active_tty: Option, - next_session_id: u32, - sessions: Vec, + next_session_id: session::SessionId, + sessions: Vec, info_managers: Vec, cgroup_manager: cgroup::CgroupManager, + seat: Vec, + clients: Vec>>, + seat_access: SeatAccess, +} + +impl Sessiond { + fn sync_vt_session_states(&mut self) { + let active_vts = self + .seat + .iter_mut() + .filter_map(|seat| { + seat.active_vt() + .inspect_err(|err| log::error!("Could not query active VT: {err}")) + .ok() + .flatten() + }) + .collect::>(); + + if active_vts.is_empty() { + return; + } + + for session in &mut self.sessions { + if matches!( + session.state(), + session_core_v1::ActiveState::Opening | session_core_v1::ActiveState::Closing + ) { + continue; + } + + let state = if active_vts.contains(&session.vt()) { + session_core_v1::ActiveState::Active + } else { + session_core_v1::ActiveState::Online + }; + + if session.state() != state { + session.set_state(state); + } + } + } } impl session_management_v1::SessionManagementV1Handler for Sessiond {} impl session_core_v1::SessionCoreV1Handler for Sessiond { fn bind(&mut self, object: session_core_manager::SessionCoreManager) { + self.sync_vt_session_states(); + let session_ids = self .sessions .iter() - .map(|session| session.id) + .map(|session| session.id().as_raw()) .collect::>(); object.send_sessions(&session_ids); self.info_managers.push(object); @@ -101,35 +166,27 @@ impl hyprwire::Dispatch for Sessiond { } match event { - session_manager::Event::CreateSession { uid, username, tty } => { - log::info!(session_id = self.next_session_id, uid, username = username.as_str(), tty = tty.as_str(); "session created"); + session_manager::Event::CreateSession { uid, username, vt } => { + let session_id = self.next_session_id; + log::info!(session_id = session_id.as_raw(), uid, username = username.as_str(), vt; "session created"); let leader_id = client.creds().pid.as_raw_pid(); if let Err(e) = self .cgroup_manager - .create_session_cgroup(self.next_session_id, leader_id) + .create_session_cgroup(session_id, leader_id) { - log::error!(session_id = self.next_session_id, error = e.to_string().as_str(); "failed to create session cgroup"); + log::error!(session_id = session_id.as_raw(), error = e.to_string().as_str(); "failed to create session cgroup"); } - let mut session = Session { - id: self.next_session_id, - uid, - username, - tty, - seat: None, - timestamp: time::SystemTime::now(), - leader_id, - objects: Vec::new(), - state: session_core_v1::ActiveState::Online, - }; - if self - .active_tty - .as_ref() - .is_some_and(|active_tty| *active_tty == session.tty) - { - session.set_state(session_core_v1::ActiveState::Active); + let mut session = session::Session::new(session_id, uid, username, vt, leader_id); + let session_seat = self + .seat + .first() + .map(|seat| seat.name().to_string()) + .unwrap_or_default(); + if !session_seat.is_empty() { + session.set_seat(session_seat.clone()); } let runtime_dir = format!("/run/user/{uid}"); @@ -147,43 +204,68 @@ impl hyprwire::Dispatch for Sessiond { log::error!(uid, error = e.to_string().as_str(); "failed to chmod runtime dir"); } - object.send_session_created(self.next_session_id); + if let Some(seat) = self + .seat + .iter_mut() + .filter(|seat| seat.is_vt_bound()) + .find_map(|seat| match seat.is_vt_active(session.vt()) { + Ok(true) => Some(seat.name().to_string()), + Ok(false) => None, + Err(err) => { + log::error!( + session_id = session.id().as_raw(), + vt = session.vt(), + error = err.to_string().as_str(); + "failed to check active VT" + ); + None + } + }) + { + session.set_seat(seat); + session.set_state(session_core_v1::ActiveState::Active); + } else { + session.set_state(session_core_v1::ActiveState::Online); + } + + object.send_session_created(session_id.as_raw(), &session_seat); self.sessions.push(session); for manager in &self.info_managers { - manager.send_session_added(self.next_session_id); + manager.send_session_added(session_id.as_raw()); } - self.next_session_id = self.next_session_id.wrapping_add(1); + self.next_session_id = self.next_session_id.next(); } session_manager::Event::DestroySession { session_id } => { + let session_id = session::SessionId::from_raw(session_id); let Some(mut session) = self .sessions - .extract_if(.., |session| session.id == session_id) + .extract_if(.., |session| session.id() == session_id) .next() else { - log::warn!(session_id; "destroy requested for unknown session"); + log::warn!(session_id = session_id.as_raw(); "destroy requested for unknown session"); return; }; - log::info!(session_id, uid = session.uid, username = session.username.as_str(); "session destroyed"); + log::info!(session_id = session_id.as_raw(), uid = session.uid(), username = session.username(); "session destroyed"); session.set_state(session_core_v1::ActiveState::Closing); - for object in session.objects.iter() { + for object in session.objects().iter() { object.send_destroyed(); } for manager in &self.info_managers { - manager.send_session_removed(session_id); + manager.send_session_removed(session_id.as_raw()); } if let Err(e) = self.cgroup_manager.poll_destroy(session_id) { - log::error!(session_id, error = e.to_string().as_str(); "failed to poll destroy session cgroup"); + log::error!(session_id = session_id.as_raw(), error = e.to_string().as_str(); "failed to poll destroy session cgroup"); } - if !self.sessions.iter().any(|s| s.uid == session.uid) - && let Err(e) = fs::remove_dir(format!("/run/user/{}", session.uid)) + if !self.sessions.iter().any(|s| s.uid() == session.uid()) + && let Err(e) = fs::remove_dir(format!("/run/user/{}", session.uid())) { - log::error!(session_id, uid = session.uid, error = e.to_string().as_str(); "failed to remove runtime dir"); + log::error!(session_id = session_id.as_raw(), uid = session.uid(), error = e.to_string().as_str(); "failed to remove runtime dir"); } } session_manager::Event::Destroy => {} @@ -202,22 +284,27 @@ impl hyprwire::Dispatch for Sessiond { session_core_session, session_id, } => { + self.sync_vt_session_states(); + + let session_id = session::SessionId::from_raw(session_id); if let Some(session) = self .sessions .iter_mut() - .find(|session| session.id == session_id) + .find(|session| session.id() == session_id) { session.add_object(session_core_session); return; } - log::warn!(session_id; "get_session requested for unknown session"); + log::warn!(session_id = session_id.as_raw(); "get_session requested for unknown session"); session_core_session.send_destroyed(); } session_core_manager::Event::GetSessionByPid { session_core_session, pid, } => { + self.sync_vt_session_states(); + let Ok(Some(session_id)) = self.cgroup_manager.find_session_by_pid(pid) else { log::warn!(pid; "get_session_by_pid: no session found for pid"); session_core_session.send_destroyed(); @@ -227,13 +314,13 @@ impl hyprwire::Dispatch for Sessiond { if let Some(session) = self .sessions .iter_mut() - .find(|session| session.id == session_id) + .find(|session| session.id() == session_id) { session.add_object(session_core_session); return; } - log::warn!(session_id; "get_session_by_pid requested for unknown session"); + log::warn!(session_id = session_id.as_raw(); "get_session_by_pid requested for unknown session"); session_core_session.send_destroyed(); } session_core_manager::Event::Destroy => { @@ -243,81 +330,9 @@ impl hyprwire::Dispatch for Sessiond { } } -impl hyprwire::Dispatch for Sessiond { - fn event( - &mut self, - object: &session_core_session::SessionCoreSession, - event: ::Event<'_>, - ) { - let Some(idx) = self.sessions.iter_mut().position(|session| { - session - .objects - .iter() - .any(|session_object| session_object == object) - }) else { - return; - }; - - if let session_core_session::Event::Destroy = event { - self.sessions[idx] - .objects - .retain(|session_object| session_object != object); - return; - } - - let Some(client) = object.client() else { - return; - }; - if !client.creds().uid.is_root() && client.creds().uid.as_raw() != self.sessions[idx].uid { - object.send_error( - session_core_v1::ErrorCode::PermissionDenied as u32, - "permission denied", - ); - return; - } - - match event { - session_core_session::Event::Terminate => { - let session = self.sessions.swap_remove(idx); - - log::info!(session_id = session.id; "terminate requested"); - for obj in session.objects.iter() { - obj.send_active(session_core_v1::ActiveState::Closing as u32); - } - if let Err(err) = self.cgroup_manager.terminate_session(session.id) { - log::error!(session_id = session.id, err = err.to_string().as_str(); "terminate failed"); - } - } - session_core_session::Event::Kill => { - log::info!(session_id = self.sessions[idx].id; "kill requested"); - for obj in self.sessions[idx].objects.iter() { - obj.send_active(session_core_v1::ActiveState::Closing as u32); - } - if let Err(err) = self.cgroup_manager.kill_session(self.sessions[idx].id) { - log::error!(session_id = self.sessions[idx].id, err = err.to_string().as_str(); "kill failed"); - } - self.sessions.swap_remove(idx); - } - session_core_session::Event::Lock => { - log::info!(session_id = self.sessions[idx].id; "lock requested"); - for object in self.sessions[idx].objects.iter() { - object.send_locked(); - object.send_ok(); - } - } - session_core_session::Event::Unlock => { - log::info!(session_id = self.sessions[idx].id; "unlock requested"); - for object in self.sessions[idx].objects.iter() { - object.send_unlocked(); - object.send_ok(); - } - } - _ => unreachable!(), - } - } -} - fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let log_level = match env::var("LOG_LEVEL") { Ok(log) if log == "trace" => log::LevelFilter::Trace, Ok(log) if log == "debug" => log::LevelFilter::Debug, @@ -333,16 +348,39 @@ fn main() -> anyhow::Result<()> { log::info!("sessiond daemon starting"); + let seat_access = SeatAccess::new( + args.seat_user + .iter() + .map(|user| resolve_user(user)) + .collect::>>()?, + args.seat_group + .iter() + .map(|group| resolve_group(group)) + .collect::>>()?, + ); + let mut event_loop = calloop::EventLoop::try_new()?; let cgroup_manager = cgroup::CgroupManager::new(event_loop.handle())?; + let vtbound = env::var("SESSIOND_VTBOUND") + .ok() + .is_none_or(|vtbound| vtbound == "1"); + + let seat = if vtbound { + seat::Seat::vt_bound("seat0".to_string()) + } else { + seat::Seat::non_vt_bound("seat0".to_string()) + }; + let mut sessiond = Sessiond { - active_tty: None, - next_session_id: 1, + next_session_id: session::SessionId::from_raw(1), sessions: Vec::new(), info_managers: Vec::new(), cgroup_manager, + seat: vec![seat], + clients: Vec::new(), + seat_access, }; let path = path::Path::new("/run/sessiond.sock"); @@ -352,6 +390,7 @@ fn main() -> anyhow::Result<()> { socket .add_implementation::(&mut sessiond, 1); socket.add_implementation::(&mut sessiond, 1); + socket.add_implementation::(&mut sessiond, 1); let fd_wrapper = unsafe { generic::FdWrapper::new(socket.extract_loop_fd().as_raw_fd()) }; let source = generic::Generic::new( @@ -370,55 +409,75 @@ fn main() -> anyhow::Result<()> { }) .unwrap(); - let tty0_path = path::Path::new("/sys/class/tty/tty0/active"); - let source = generic::Generic::new( - fs::File::open(tty0_path)?, - calloop::Interest { - readable: true, - writable: false, - }, - calloop::Mode::Level, - ); + let signals = signals::Signals::new(&[signals::Signal::SIGUSR1, signals::Signal::SIGUSR2])?; event_loop .handle() - .insert_source(source, move |_, _, state| { - let active = match fs::read_to_string(tty0_path) { - Ok(active) => active, - Err(e) => { - log::error!(error = e.to_string().as_str(); "failed to read active tty"); - return Ok(calloop::PostAction::Continue); + .insert_source(signals, move |event, _, state| { + let mut session_updates = Vec::new(); + + for seat in state.seat.iter_mut().filter(|seat| seat.is_vt_bound()) { + match event.signal() { + signals::Signal::SIGUSR1 => match seat.vt_release() { + Ok(vt) => { + session_updates.push((vt, session_core_v1::ActiveState::Online)); + } + Err(e) => { + log::error!("Could not release VT: {e}"); + } + }, + signals::Signal::SIGUSR2 => match seat.vt_activate() { + Ok(vt) => { + session_updates.push((vt, session_core_v1::ActiveState::Active)); + } + Err(e) => { + log::error!("Could not activate VT: {e}"); + } + }, + _ => unreachable!(), } - }; - let active = active.trim(); - - if let Some(session) = state.sessions.iter_mut().find(|session| { - state - .active_tty - .as_ref() - .is_some_and(|tty| *tty == session.tty) - }) { - if session.tty == active { - return Ok(calloop::PostAction::Continue); - } - - session.set_state(session_core_v1::ActiveState::Online); } - state.active_tty = Some(active.to_string()); + for (vt, active_state) in session_updates { + if active_state == session_core_v1::ActiveState::Active { + for session in state.sessions.iter_mut().filter(|session| { + session.vt() != vt + && session.state() == session_core_v1::ActiveState::Active + }) { + session.set_state(session_core_v1::ActiveState::Online); + } + } - if let Some(session) = state - .sessions - .iter_mut() - .find(|session| session.tty == active) - { - session.set_state(session_core_v1::ActiveState::Active); + for session in state + .sessions + .iter_mut() + .filter(|session| session.vt() == vt) + { + session.set_state(active_state); + } } - - Ok(calloop::PostAction::Continue) - }) - .unwrap(); + })?; event_loop.run(None, &mut sessiond, |_| {})?; Ok(()) } + +fn resolve_user(user: &str) -> anyhow::Result { + if let Ok(uid) = user.parse::() { + return Ok(process::Uid::from_raw(uid)); + } + + users::get_user_by_name(user) + .map(|user: users::User| process::Uid::from_raw(user.uid())) + .ok_or_else(|| anyhow::anyhow!("unknown seat user {user:?}")) +} + +fn resolve_group(group: &str) -> anyhow::Result { + if let Ok(gid) = group.parse::() { + return Ok(process::Gid::from_raw(gid)); + } + + users::get_group_by_name(group) + .map(|group: users::Group| process::Gid::from_raw(group.gid())) + .ok_or_else(|| anyhow::anyhow!("unknown seat group {group:?}")) +} diff --git a/daemon/src/seat.rs b/daemon/src/seat.rs new file mode 100644 index 0000000..84903ec --- /dev/null +++ b/daemon/src/seat.rs @@ -0,0 +1,827 @@ +use crate::seat_v1::{seat, seat_manager}; +use crate::{client, device, seat_v1, terminal}; +use rustix::io; +use std::os::fd::AsFd; +use std::{cell, fs, rc}; + +const MAX_SEAT_DEVICES: u8 = 128; + +impl seat_v1::SeatV1Handler for crate::Sessiond { + fn bind(&mut self, object: seat_manager::SeatManager) { + let Some(client) = object.client() else { + object.error( + io::Errno::PERM.raw_os_error() as u32, + "seat access requires peer credentials", + ); + return; + }; + + let creds = client.creds(); + if self.seat_access.allows(creds) { + return; + } + + log::warn!( + pid = creds.pid.as_raw_pid(), + uid = creds.uid.as_raw(), + gid = creds.gid.as_raw(); + "rejected seat_v1 bind from unauthorized client" + ); + object.error(io::Errno::PERM.raw_os_error() as u32, "seat access denied"); + } +} + +impl hyprwire::Dispatch for crate::Sessiond { + fn event( + &mut self, + object: &seat_manager::SeatManager, + event: ::Event<'_>, + ) { + match event { + seat_manager::Event::OpenSeat { seat } => { + let Some(server_client) = object.client() else { + seat.error( + io::Errno::PERM.raw_os_error() as u32, + "seat access requires peer credentials", + ); + return; + }; + + let creds = server_client.creds(); + if !self.seat_access.allows(creds) { + log::warn!( + pid = creds.pid.as_raw_pid(), + uid = creds.uid.as_raw(), + gid = creds.gid.as_raw(); + "rejected seat open from unauthorized client" + ); + seat.error(io::Errno::PERM.raw_os_error() as u32, "seat access denied"); + return; + } + + let session_id = { + let pid = server_client.creds().pid.as_raw_pid() as u32; + match self.cgroup_manager.find_session_by_pid(pid) { + Ok(session_id) => session_id, + Err(err) => { + log::error!("Could not find session for seat opener {pid}: {err}"); + None + } + } + }; + + let client = rc::Rc::new(cell::RefCell::new(client::Client { + seat_name: String::new(), + object: seat, + devices: Vec::new(), + state: crate::seat::SeatState::New, + session_id: 0, + })); + + let Some(seat) = self.seat.first_mut() else { + log::error!("No seats configured"); + client.borrow().object.send_disabled(); + return; + }; + + if let Err(err) = seat.add_client(rc::Rc::clone(&client)) { + log::error!("Could not add client to seat: {err}"); + client.borrow().object.send_disabled(); + return; + } + if let Err(err) = seat.open_client(rc::Rc::clone(&client)) { + log::error!("Could not open client on seat: {err}"); + let _ = seat.remove_client(&client); + client.borrow().object.send_disabled(); + return; + } + + if let Some(session_id) = session_id { + let seat_name = client.borrow().seat_name.clone(); + if let Some(session) = self + .sessions + .iter_mut() + .find(|session| session.id() == session_id) + { + session.set_seat(seat_name); + } else { + log::warn!(session_id = session_id.as_raw(); "seat opened for unknown session"); + } + } + + self.clients.push(client); + } + seat_manager::Event::Destroy => {} + } + } +} + +impl hyprwire::Dispatch for crate::Sessiond { + fn event(&mut self, object: &seat::Seat, event: ::Event<'_>) { + let Some(client) = self + .clients + .iter() + .find(|client| client.borrow().object == *object) + .cloned() + else { + return; + }; + + let Some(seat) = self.seat.first_mut() else { + log::error!("No seats configured"); + return; + }; + + match event { + seat::Event::AckDisabled => { + let state = client.borrow().state; + if let Err(err) = seat.ack_disable_client(&client) { + log::error!("Could not ack disable: {err}"); + + match state { + SeatState::Enabled => object.send_seat_error( + seat_v1::SeatError::InvalidAckDisabled, + "ack_disabled was sent while enabled", + ), + SeatState::Disabled => object.send_seat_error( + seat_v1::SeatError::AlreadyDisabled, + "disabled state was already acknowledged", + ), + _ => {} + } + } + } + seat::Event::OpenDevice { device, path } => { + let seat_name = client.borrow().seat_name.clone(); + let Some(seat) = self.seat.iter().find(|seat| seat.name() == seat_name) else { + log::error!("No shared seat found for {}", seat_name); + return; + }; + + if !seat.is_active_client(&client) { + device.send_failed( + seat_v1::DeviceError::SeatDisabled, + io::Errno::PERM.raw_os_error() as u32, + "open_device is only valid for the active seat client", + ); + return; + } + + let mut client = client.borrow_mut(); + + let Ok(sanitized_path) = fs::canonicalize(path) else { + device.send_failed( + seat_v1::DeviceError::OpenFailed, + io::Errno::NOENT.raw_os_error() as u32, + "could not canonicalize device path", + ); + return; + }; + + if client.state != crate::seat::SeatState::Enabled { + device.send_failed( + seat_v1::DeviceError::SeatDisabled, + io::Errno::BUSY.raw_os_error() as u32, + "open_device is only valid while the seat is enabled", + ); + return; + } + + let Some(kind) = crate::device::DeviceKind::from_path(&sanitized_path) else { + log::error!( + "{} is not a supported device type ", + sanitized_path.display() + ); + device.send_failed( + seat_v1::DeviceError::OpenFailed, + io::Errno::INVAL.raw_os_error() as u32, + "unsupported device type", + ); + return; + }; + + if let Some(old_device) = client + .devices + .iter_mut() + .find(|device| device.matches_path(&sanitized_path)) + { + old_device.add_object(device); + let object = old_device.last_object().unwrap(); + object.send_opened( + old_device.id(), + old_device.fd().as_fd(), + old_device.absolute_path_string(), + ); + return; + } + + if client.devices.len() as u8 >= MAX_SEAT_DEVICES { + log::error!("Client exceeded max seat devices"); + device.send_failed( + seat_v1::DeviceError::OpenFailed, + io::Errno::MFILE.raw_os_error() as u32, + "client exceeded max seat devices", + ); + return; + } + + let device_id = client + .devices + .iter() + .map(|device| device.id()) + .max() + .map_or(1, |id| id + 1); + + let fd = match rustix::fs::open( + &sanitized_path, + rustix::fs::OFlags::RDWR + | rustix::fs::OFlags::NOCTTY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC + | rustix::fs::OFlags::NONBLOCK, + rustix::fs::Mode::empty(), + ) { + Ok(fd) => fd, + Err(e) => { + log::error!("Could not open file: {e}"); + device.send_failed( + seat_v1::DeviceError::OpenFailed, + e.raw_os_error() as u32, + "could not open device", + ); + return; + } + }; + + let mut seat_device = crate::device::Device::new( + device, + sanitized_path.clone(), + kind, + device_id, + matches!( + kind, + crate::device::DeviceKind::Evdev | crate::device::DeviceKind::Hidraw + ), + fd, + ); + + if let Err(err) = seat_device.activate() { + log::error!("Could not activate {}: {err}", seat_device.path().display()); + let errno = err + .root_cause() + .downcast_ref::() + .copied() + .unwrap_or(io::Errno::INVAL); + seat_device.last_object().unwrap().send_failed( + seat_v1::DeviceError::OpenFailed, + errno.raw_os_error() as u32, + "could not activate device", + ); + return; + } + + seat_device.last_object().unwrap().send_opened( + device_id, + seat_device.fd().as_fd(), + seat_device.absolute_path_string(), + ); + client.devices.push(seat_device); + } + seat::Event::SwitchSession { session } => { + if !seat.is_vt_bound() { + object.send_seat_error( + seat_v1::SeatError::InvalidSwitchSession, + "session switching is not supported", + ); + return; + } + + if let Err(err) = seat.set_next_session(&client, session) { + log::error!("Could not switch session: {err}"); + } + } + seat::Event::Destroy => { + if let Err(err) = seat.remove_client(&client) { + log::error!("Could not remove client from seat: {err}"); + } + self.clients.retain(|c| !rc::Rc::ptr_eq(c, &client)); + } + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SeatState { + New, + Enabled, + PendingDisabled, + Disabled, + Closed, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SeatKind { + VtBound, + NonVtBound, +} + +pub struct Seat { + name: String, + clients: Vec>>, + active_client: Option>>, + next_client: Option>>, + kind: SeatKind, + cur_vt: u32, +} + +impl Default for Seat { + fn default() -> Self { + Seat { + name: "seat0".to_string(), + clients: Vec::new(), + active_client: None, + next_client: None, + kind: SeatKind::NonVtBound, + cur_vt: 0, + } + } +} + +impl Seat { + pub fn vt_bound(name: String) -> Self { + log::info!("Created VT-bound seat {}", name); + Self { + name, + kind: SeatKind::VtBound, + ..Default::default() + } + } + + pub fn non_vt_bound(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + pub fn is_vt_bound(&self) -> bool { + self.kind == SeatKind::VtBound + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn is_active_client(&self, client: &rc::Rc>) -> bool { + self.active_client + .as_ref() + .is_some_and(|active_client| rc::Rc::ptr_eq(active_client, client)) + } + + pub fn is_vt_active(&mut self, vt: u32) -> anyhow::Result { + if !self.is_vt_bound() { + return Ok(false); + } + + self.update_vt()?; + Ok(self.cur_vt == vt) + } + + pub fn active_vt(&mut self) -> anyhow::Result> { + if !self.is_vt_bound() { + return Ok(None); + } + + self.update_vt()?; + Ok(Some(self.cur_vt)) + } + + pub fn update_vt(&mut self) -> anyhow::Result<()> { + let tty0fd = terminal::Terminal::open(0)?; + self.cur_vt = u32::from(tty0fd.current_vt()?); + + Ok(()) + } + + pub fn vt_open(vt: u32) -> anyhow::Result<()> { + let terminal = terminal::Terminal::open(vt)?; + terminal.set_process_switching(true)?; + terminal.set_keyboard(false)?; + terminal.set_graphics(true)?; + + Ok(()) + } + + pub fn vt_close(vt: u32) -> anyhow::Result<()> { + let terminal = terminal::Terminal::open(vt)?; + terminal.set_process_switching(false)?; + terminal.set_keyboard(true)?; + terminal.set_graphics(false)?; + + Ok(()) + } + + pub fn vt_switch(&self, vt: u32) -> anyhow::Result<()> { + let terminal = terminal::Terminal::open(self.cur_vt)?; + terminal.set_process_switching(true)?; + terminal.switch_vt(vt as i32)?; + + Ok(()) + } + + pub fn vt_ack(&self, release: bool) -> anyhow::Result<()> { + let terminal = terminal::Terminal::open(self.cur_vt)?; + + if release { + terminal.ack_release()?; + } else { + terminal.ack_acquire()?; + } + + Ok(()) + } + + pub fn vt_activate(&mut self) -> anyhow::Result { + if !self.is_vt_bound() { + log::debug!("VT activation on non VT-bound seat, ignoring"); + return Err(anyhow::anyhow!("seat is not VT-bound")); + } + + self.update_vt()?; + let acquired_vt = self.cur_vt; + log::debug!("Activating VT"); + self.vt_ack(false)?; + + if self.active_client.is_none() { + self.activate()?; + } + + Ok(acquired_vt) + } + + pub fn vt_release(&mut self) -> anyhow::Result { + if !self.is_vt_bound() { + log::debug!("VT release request on non VT-bound seat, ignoring"); + return Err(anyhow::anyhow!("seat is not VT-bound")); + } + + self.update_vt()?; + let released_vt = self + .active_client + .as_ref() + .map(|client| client.borrow().session_id) + .unwrap_or(self.cur_vt); + log::debug!("Releasing VT"); + + if let Some(active_client) = self.active_client.clone() { + self.disable_client(&active_client)?; + } + + self.vt_ack(true)?; + self.cur_vt = 0; + + Ok(released_vt) + } + + pub fn activate(&mut self) -> anyhow::Result<()> { + if self.active_client.is_some() { + return Ok(()); + } + + let next_client = if self.next_client.is_some() { + log::debug!("Activating next queued client on {}", self.name()); + self.next_client.take() + } else if self.clients.is_empty() { + log::info!("No clients on {} to activate", self.name()); + return Err(anyhow::anyhow!("No clients on {} to activate", self.name())); + } else if self.is_vt_bound() { + if let Some(client) = self + .clients + .iter() + .find(|client| client.borrow().session_id == self.cur_vt) + { + log::debug!("Activating client belonging to VT {}", self.cur_vt); + Some(rc::Rc::clone(client)) + } else { + log::info!("No clients belonging to VT {} to activate", self.cur_vt); + return Err(anyhow::anyhow!( + "No clients belonging to VT {} to activate", + self.cur_vt + )); + } + } else { + log::debug!("Activating first client on {}", self.name()); + self.clients.first().cloned() + }; + + if let Some(client) = next_client { + self.open_client(client)?; + } + + Ok(()) + } + + pub fn open_client( + &mut self, + client: rc::Rc>, + ) -> anyhow::Result<()> { + debug_assert_eq!(client.borrow().seat_name, self.name); + + let state = client.borrow().state; + if state != SeatState::New && state != SeatState::Disabled { + log::error!("Could not enable client: client is not new or disabled"); + return Err(anyhow::anyhow!("client is not new or disabled")); + } + + if self.active_client.as_ref().is_some_and(|active_client| { + !self.is_vt_bound() || active_client.borrow().state != SeatState::PendingDisabled + }) { + log::error!("Could not enable client: seat already has an active client"); + return Err(anyhow::anyhow!("seat already has an active client")); + } + + if self.is_vt_bound() + && let Err(err) = Self::vt_open(client.borrow().session_id) + { + log::error!("Could not open VT for client: {err}"); + return Err(err); + } + + { + let mut client_ref = client.borrow_mut(); + for device in &mut client_ref.devices { + if let Err(err) = device.activate() { + log::error!("Could not activate {}: {err}", device.path().display()); + } + } + } + + client.borrow().object.send_enabled(); + + client.borrow_mut().state = SeatState::Enabled; + self.active_client = Some(rc::Rc::clone(&client)); + log::info!( + "Opened client {} on {}", + client.borrow().session_id, + self.name + ); + + Ok(()) + } + + pub fn add_client( + &mut self, + client: rc::Rc>, + ) -> anyhow::Result<()> { + if !client.borrow().seat_name.is_empty() { + log::error!("Could not add client: client is already a member of a seat"); + return Err(anyhow::anyhow!("client is already a member of a seat")); + } + + if self.is_vt_bound() + && let Some(active_client) = &self.active_client + && active_client.borrow().state != SeatState::PendingDisabled + { + log::error!("Could not add client: seat is VT-bound and has an active client"); + return Err(anyhow::anyhow!("seat is VT-bound and has an active client")); + } + + if client.borrow().session_id != 0 { + log::error!("Could not add client: client cannot be reused"); + return Err(anyhow::anyhow!("client cannot be reused")); + } + + if self.is_vt_bound() { + self.update_vt()?; + if self.cur_vt == 0 { + log::error!("Could not determine VT for client"); + return Err(anyhow::anyhow!("could not determine VT for client")); + } + + if self.active_client.is_some() + && self + .clients + .iter() + .any(|existing| existing.borrow().session_id == self.cur_vt) + { + log::error!( + "Could not add client: seat is VT-bound and already has pending client" + ); + return Err(anyhow::anyhow!( + "seat is VT-bound and already has pending client" + )); + } + + client.borrow_mut().session_id = self.cur_vt; + } else { + let mut next_session = 1; + while self + .clients + .iter() + .any(|existing| existing.borrow().session_id == next_session) + { + next_session += 1; + } + + client.borrow_mut().session_id = next_session; + } + + client.borrow_mut().seat_name = self.name.clone(); + log::info!( + "Added client {} to {}", + client.borrow().session_id, + self.name + ); + self.clients.push(client); + + Ok(()) + } + + pub fn disable_client( + &mut self, + client: &rc::Rc>, + ) -> anyhow::Result<()> { + if client.borrow().state != SeatState::Enabled { + log::error!("Could not disable client: client is not active"); + return Err(anyhow::anyhow!("client is not active")); + } + + if self + .active_client + .as_ref() + .is_none_or(|active_client| !rc::Rc::ptr_eq(active_client, client)) + { + log::error!("Could not disable client: client is not the active seat client"); + return Err(anyhow::anyhow!("client is not the active seat client")); + } + + { + let mut client_ref = client.borrow_mut(); + client_ref.devices.retain_mut(|device| { + if let Err(err) = device.deactivate() { + log::error!("Could not deactivate {}: {err}", device.path().display()); + return true; + } + + match device.kind() { + device::DeviceKind::Drm | device::DeviceKind::Wscons => true, + device::DeviceKind::Evdev | device::DeviceKind::Hidraw => { + device.send_revoked(); + false + } + } + }); + + client_ref.state = SeatState::PendingDisabled; + client_ref.object.send_disabled(); + log::info!( + "Disabling client {} on {}", + client_ref.session_id, + self.name + ); + } + + Ok(()) + } + + pub fn ack_disable_client( + &mut self, + client: &rc::Rc>, + ) -> anyhow::Result<()> { + if client.borrow().state != SeatState::PendingDisabled { + log::error!("Could not ack disable: client is not pending disable"); + return Err(anyhow::anyhow!("client is not pending disable")); + } + + client.borrow_mut().state = SeatState::Disabled; + log::info!( + "Disabled client {} on {}", + client.borrow().session_id, + self.name + ); + + if self + .active_client + .as_ref() + .is_none_or(|active_client| !rc::Rc::ptr_eq(active_client, client)) + { + return Ok(()); + } + + self.active_client = None; + let _ = self.activate(); + + Ok(()) + } + + pub fn set_next_session( + &mut self, + client: &rc::Rc>, + session: u32, + ) -> anyhow::Result<()> { + if client.borrow().state != SeatState::Enabled { + log::error!("Could not set next session: client is not active"); + return Err(anyhow::anyhow!("client is not active")); + } + + if self + .active_client + .as_ref() + .is_none_or(|active_client| !rc::Rc::ptr_eq(active_client, client)) + { + log::error!("Could not set next session: client is not the active seat client"); + return Err(anyhow::anyhow!("client is not the active seat client")); + } + + if session == 0 { + log::error!("Could not set next session: invalid session value {session}"); + return Err(anyhow::anyhow!("invalid session value")); + } + + if session == client.borrow().session_id { + log::info!("Could not set next session: requested session is already active"); + return Ok(()); + } + + if self.next_client.is_some() { + log::info!("Could not set next session: switch is already queued"); + return Ok(()); + } + + if self.is_vt_bound() { + log::info!("Switching from VT {} to VT {}", self.cur_vt, session); + if let Err(err) = self.vt_switch(session) { + log::error!("Could not switch VT: {err}"); + return Err(err); + } + return Ok(()); + } + + let Some(target) = self + .clients + .iter() + .find(|candidate| candidate.borrow().session_id == session) + .cloned() + else { + log::error!("Could not set next session: no such client"); + return Err(anyhow::anyhow!("no such client")); + }; + + log::info!("Queuing switch to client {} on {}", session, self.name); + self.next_client = Some(target); + self.disable_client(client)?; + + Ok(()) + } + + pub fn remove_client( + &mut self, + client: &rc::Rc>, + ) -> anyhow::Result<()> { + if self + .next_client + .as_ref() + .is_some_and(|next_client| rc::Rc::ptr_eq(next_client, client)) + { + self.next_client = None; + } + + let (session, client_state) = { + let client = client.borrow(); + (client.session_id, client.state) + }; + + self.clients + .retain(|existing| !rc::Rc::ptr_eq(existing, client)); + client.borrow_mut().devices.clear(); + + let was_current = self + .active_client + .as_ref() + .is_some_and(|active_client| rc::Rc::ptr_eq(active_client, client)); + if was_current { + self.active_client = None; + let _ = self.activate(); + } + + if self.is_vt_bound() { + if was_current && self.active_client.is_none() { + log::debug!("Closing active VT"); + Self::vt_close(self.cur_vt)?; + } else if !was_current && client_state != SeatState::Closed { + log::debug!("Closing inactive VT"); + Self::vt_close(session)?; + } + } + + { + let mut client = client.borrow_mut(); + client.state = SeatState::Closed; + client.seat_name.clear(); + } + + log::info!("Removed client {} from {}", session, self.name); + + Ok(()) + } +} diff --git a/daemon/src/session.rs b/daemon/src/session.rs new file mode 100644 index 0000000..962f515 --- /dev/null +++ b/daemon/src/session.rs @@ -0,0 +1,190 @@ +use crate::{session_core_session, session_core_v1}; +use std::time; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct SessionId(u32); + +impl SessionId { + pub fn as_raw(self) -> u32 { + self.0 + } + + pub fn from_raw(id: u32) -> Self { + Self(id) + } + + pub fn next(self) -> Self { + Self(self.0.wrapping_add(1)) + } +} + +pub struct Session { + id: SessionId, + uid: u32, + username: String, + vt: u32, + seat: Option, + timestamp: time::SystemTime, + leader_id: i32, + objects: Vec, + state: session_core_v1::ActiveState, +} + +impl Session { + pub fn new(session_id: SessionId, uid: u32, username: String, vt: u32, leader_id: i32) -> Self { + Self { + id: session_id, + uid, + username, + vt, + seat: None, + timestamp: time::SystemTime::now(), + leader_id, + objects: Vec::new(), + state: session_core_v1::ActiveState::Opening, + } + } + + pub fn vt(&self) -> u32 { + self.vt + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn uid(&self) -> u32 { + self.uid + } + + pub fn id(&self) -> SessionId { + self.id + } + + pub fn objects(&self) -> &[session_core_session::SessionCoreSession] { + &self.objects + } + + pub fn state(&self) -> session_core_v1::ActiveState { + self.state + } + + pub fn set_state(&mut self, state: session_core_v1::ActiveState) { + log::info!(session_id = self.id.as_raw(), vt = self.vt; "session state changed"); + self.state = state; + for object in self.objects.iter() { + object.send_active(self.state); + } + } + + pub fn set_seat(&mut self, seat: String) { + if self.seat.as_deref() == Some(seat.as_str()) { + return; + } + + log::info!(session_id = self.id.as_raw(), seat = seat.as_str(); "session seat changed"); + self.seat = Some(seat); + + if let Some(seat) = self.seat.as_ref() { + for object in self.objects.iter() { + object.send_seat(seat); + } + } + } + + pub fn add_object(&mut self, object: session_core_session::SessionCoreSession) { + log::debug!(session_id = self.id.as_raw(), uid = object.client().map(|c| c.creds().uid.as_raw()).unwrap_or(0); "session info requested"); + object.send_session_id(self.id.as_raw()); + object.send_uid(self.uid); + object.send_username(&self.username); + object.send_vt(self.vt); + object.send_active(self.state); + if let Some(seat) = self.seat.as_ref() { + object.send_seat(seat); + } + object.send_leader(self.leader_id as u32); + object.send_timestamp( + self.timestamp + .duration_since(time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32, + ); + object.send_done(); + + self.objects.push(object); + } +} + +impl hyprwire::Dispatch for crate::Sessiond { + fn event( + &mut self, + object: &session_core_session::SessionCoreSession, + event: ::Event<'_>, + ) { + let Some(idx) = self.sessions.iter_mut().position(|session| { + session + .objects + .iter() + .any(|session_object| session_object == object) + }) else { + return; + }; + + if let session_core_session::Event::Destroy = event { + self.sessions[idx] + .objects + .retain(|session_object| session_object != object); + return; + } + + let Some(client) = object.client() else { + return; + }; + if !client.creds().uid.is_root() && client.creds().uid.as_raw() != self.sessions[idx].uid { + object.send_error( + session_core_v1::ErrorCode::PermissionDenied as u32, + "permission denied", + ); + return; + } + + match event { + session_core_session::Event::Terminate => { + let session = self.sessions.swap_remove(idx); + + log::info!(session_id = session.id.as_raw(); "terminate requested"); + for obj in session.objects.iter() { + obj.send_active(session_core_v1::ActiveState::Closing); + } + if let Err(err) = self.cgroup_manager.terminate_session(session.id) { + log::error!(session_id = session.id.as_raw(), err = err.to_string().as_str(); "terminate failed"); + } + } + session_core_session::Event::Kill => { + log::info!(session_id = self.sessions[idx].id.as_raw(); "kill requested"); + for obj in self.sessions[idx].objects.iter() { + obj.send_active(session_core_v1::ActiveState::Closing); + } + if let Err(err) = self.cgroup_manager.kill_session(self.sessions[idx].id) { + log::error!(session_id = self.sessions[idx].id.as_raw(), err = err.to_string().as_str(); "kill failed"); + } + self.sessions.swap_remove(idx); + } + session_core_session::Event::Lock => { + log::info!(session_id = self.sessions[idx].id.as_raw(); "lock requested"); + for object in self.sessions[idx].objects.iter() { + object.send_locked(); + object.send_ok(); + } + } + session_core_session::Event::Unlock => { + log::info!(session_id = self.sessions[idx].id.as_raw(); "unlock requested"); + for object in self.sessions[idx].objects.iter() { + object.send_unlocked(); + object.send_ok(); + } + } + _ => unreachable!(), + } + } +} diff --git a/daemon/src/terminal.rs b/daemon/src/terminal.rs new file mode 100644 index 0000000..f9d829d --- /dev/null +++ b/daemon/src/terminal.rs @@ -0,0 +1,187 @@ +use rustix::ioctl; +use rustix::process; +use std::{fs, path}; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +struct VtStat { + v_active: u16, + v_signal: u16, + v_state: u16, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +struct VtMode { + mode: u8, + waitv: u8, + relsig: i16, + acqsig: i16, + frsig: i16, +} + +const VT_GETSTATE: ioctl::Opcode = 0x5603; +const VT_SETMODE: ioctl::Opcode = 0x5602; +const VT_ACTIVATE: ioctl::Opcode = 0x5606; +const VT_RELDISP: ioctl::Opcode = 0x5605; +const KDSETMODE: ioctl::Opcode = 0x4B3A; +const KDSKBMODE: ioctl::Opcode = 0x4B45; +const VT_AUTO: u8 = 0x00; +const VT_PROCESS: u8 = 0x01; +const VT_ACKACQ: i32 = 0x02; +const KD_TEXT: i32 = 0x00; +const KD_GRAPHICS: i32 = 0x01; +const K_UNICODE: i32 = 0x03; +const K_OFF: i32 = 0x04; +const FRSIG: i16 = 0; + +pub struct Terminal(fs::File); + +impl Terminal { + fn get_tty_path(tty: u32) -> path::PathBuf { + path::PathBuf::from(format!("/dev/tty{tty}")) + } + + pub fn open(tty: u32) -> anyhow::Result { + let path = Self::get_tty_path(tty); + Ok(Self(fs::File::open(&path)?)) + } + + pub fn current_vt(&self) -> anyhow::Result { + let st = unsafe { ioctl::ioctl(&self.0, ioctl::Getter::::new())? }; + + Ok(st.v_active) + } + + pub fn set_process_switching(&self, enable: bool) -> anyhow::Result<()> { + log::debug!("Setting process switching to {enable}"); + + let mode = VtMode { + mode: if enable { VT_PROCESS } else { VT_AUTO }, + waitv: 0, + relsig: if enable { + process::Signal::USR1.as_raw() as i16 + } else { + 0 + }, + acqsig: if enable { + process::Signal::USR2.as_raw() as i16 + } else { + 0 + }, + frsig: FRSIG, + }; + + let result = + unsafe { ioctl::ioctl(&self.0, ioctl::Setter::::new(mode)) }; + + if let Err(err) = result { + log::error!( + "Could not set VT mode to {} process switching: {}", + if enable { "enable" } else { "disable" }, + err + ); + return Err(err.into()); + } + + Ok(()) + } + + pub fn switch_vt(&self, vt: i32) -> anyhow::Result<()> { + log::debug!("Switching to VT {vt}"); + + let result = unsafe { + ioctl::ioctl( + &self.0, + ioctl::IntegerSetter::::new_usize(vt as usize), + ) + }; + + if let Err(err) = result { + log::error!("Could not activate VT {vt}: {err}"); + return Err(err.into()); + } + + Ok(()) + } + + pub fn ack_release(&self) -> anyhow::Result<()> { + log::debug!("Acking VT release"); + + let result = + unsafe { ioctl::ioctl(&self.0, ioctl::IntegerSetter::::new_usize(1)) }; + + if let Err(err) = result { + log::error!("Could not ack VT release: {err}"); + return Err(err.into()); + } + + Ok(()) + } + + pub fn ack_acquire(&self) -> anyhow::Result<()> { + log::debug!("Acking VT acquire"); + + let result = unsafe { + ioctl::ioctl( + &self.0, + ioctl::IntegerSetter::::new_usize(VT_ACKACQ as usize), + ) + }; + + if let Err(err) = result { + log::error!("Could not ack VT acquire: {err}"); + return Err(err.into()); + } + + Ok(()) + } + + pub fn set_keyboard(&self, enable: bool) -> anyhow::Result<()> { + log::debug!("Setting KD keyboard state to {enable}"); + + let result = unsafe { + ioctl::ioctl( + &self.0, + ioctl::IntegerSetter::::new_usize( + (if enable { K_UNICODE } else { K_OFF }) as usize, + ), + ) + }; + + if let Err(err) = result { + log::error!( + "Could not set KD keyboard mode to {}: {}", + if enable { "enabled" } else { "disabled" }, + err + ); + return Err(err.into()); + } + + Ok(()) + } + + pub fn set_graphics(&self, enable: bool) -> anyhow::Result<()> { + log::debug!("Setting KD graphics state to {enable}"); + + let result = unsafe { + ioctl::ioctl( + &self.0, + ioctl::IntegerSetter::::new_usize( + (if enable { KD_GRAPHICS } else { KD_TEXT }) as usize, + ), + ) + }; + + if let Err(err) = result { + log::error!( + "Could not set KD graphics mode to {}: {}", + if enable { "graphics" } else { "text" }, + err + ); + return Err(err.into()); + } + + Ok(()) + } +} diff --git a/nix/finix-vm.nix b/nix/finix-vm.nix index d6ed051..86849b0 100644 --- a/nix/finix-vm.nix +++ b/nix/finix-vm.nix @@ -4,6 +4,8 @@ ... }: { + environment.systemPackages = [ pkgs.busybox ]; + services = { sysklogd.enable = true; sessiond = { @@ -13,7 +15,11 @@ mdevd.enable = true; getty = { enable = true; - ttys = [ "tty1" "tty2" "ttyS0" ]; + ttys = [ + "tty1" + "tty2" + "ttyS0" + ]; }; }; diff --git a/nix/finix.nix b/nix/finix.nix index 098b4d4..8a192fb 100644 --- a/nix/finix.nix +++ b/nix/finix.nix @@ -6,18 +6,31 @@ }: let cfg = config.services.sessiond; + inherit (lib) types; in { options.services.sessiond = { enable = lib.mkEnableOption "sessiond"; package = lib.mkPackageOption pkgs "sessiond" { }; + seat = { + user = lib.mkOption { + type = types.str; + default = "root"; + description = "User to own the seatd socket"; + }; + group = lib.mkOption { + type = types.str; + default = "seat"; + description = "Group to own the seatd socket"; + }; + }; }; config = lib.mkIf cfg.enable { finit.services.sessiond = { description = "session management daemon"; conditions = [ ]; - command = "${cfg.package}/bin/sessiond"; + command = "${cfg.package}/bin/sessiond --seat-user ${cfg.seat.user} --seat-group ${cfg.seat.group}"; log = true; }; @@ -26,5 +39,7 @@ in ''; environment.systemPackages = [ cfg.package ]; + + users.groups.${cfg.seat.group} = { }; }; } diff --git a/pam/src/lib.rs b/pam/src/lib.rs index 6064798..9e5e0ae 100644 --- a/pam/src/lib.rs +++ b/pam/src/lib.rs @@ -6,10 +6,10 @@ mod session_management_v1 { use hyprwire::client; use pamsm::{PamLibExt, PamServiceModule}; use session_management_v1::session_manager; -use std::path; struct Session { session_id: Option, + seat: Option, } impl hyprwire::Dispatch for Session { @@ -18,8 +18,9 @@ impl hyprwire::Dispatch for Session { _: &session_manager::SessionManager, event: ::Event<'_>, ) { - let session_manager::Event::SessionCreated { session_id } = event; + let session_manager::Event::SessionCreated { session_id, seat } = event; self.session_id = Some(session_id); + self.seat = Some(seat.to_string()); } } @@ -28,7 +29,10 @@ struct Pam; impl PamServiceModule for Pam { fn open_session(pamh: pamsm::Pam, _: pamsm::PamFlags, _: Vec) -> pamsm::PamError { fn inner(pamh: pamsm::Pam) -> Result<(), pamsm::PamError> { - let mut session = Session { session_id: None }; + let mut session = Session { + session_id: None, + seat: None, + }; let mut socket = client::Client::connect("/run/sessiond.sock") .map_err(|_| pamsm::PamError::SESSION_ERR)?; @@ -49,21 +53,22 @@ impl PamServiceModule for Pam { .ok_or(pamsm::PamError::USER_UNKNOWN)? .to_string_lossy() .to_string(); - let tty: path::PathBuf = pamh + let pam_tty = pamh .get_tty()? .ok_or(pamsm::PamError::SESSION_ERR)? .to_string_lossy() - .to_string() - .into(); + .to_string(); let uid = users::get_user_by_name(&username) .ok_or(pamsm::PamError::USER_UNKNOWN)? .uid(); - let tty_name = tty - .file_name() - .ok_or(pamsm::PamError::SESSION_ERR)? - .to_string_lossy(); - manager.send_create_session(uid, username, tty_name); + let vt = pam_tty + .rsplit('/') + .next() + .and_then(|name| name.strip_prefix("tty")) + .and_then(|vt| vt.parse().ok()) + .unwrap_or(0); + manager.send_create_session(uid, username, vt); while session.session_id.is_none() { event_queue @@ -76,6 +81,9 @@ impl PamServiceModule for Pam { .map_err(|_| pamsm::PamError::SESSION_ERR)?; pamh.putenv(&format!("XDG_SESSION_ID={session_id}"))?; + if let Some(seat) = session.seat { + pamh.putenv(&format!("XDG_SEAT={seat}"))?; + } pamh.putenv(&format!("XDG_RUNTIME_DIR=/run/user/{uid}"))?; Ok(()) @@ -99,7 +107,10 @@ impl PamServiceModule for Pam { .map_err(|_| pamsm::PamError::SESSION_ERR) })?; - let mut session = Session { session_id: None }; + let mut session = Session { + session_id: None, + seat: None, + }; let mut socket = client::Client::connect("/run/sessiond.sock") .map_err(|_| pamsm::PamError::SESSION_ERR)?; diff --git a/protocols/seat_v1.xml b/protocols/seat_v1.xml new file mode 100644 index 0000000..d80553c --- /dev/null +++ b/protocols/seat_v1.xml @@ -0,0 +1,116 @@ + + + + + Interface for creating client seat connections. + + + + Creates a seat object for the client's current session. + The daemon sends either enabled or disabled to indicate the initial seat + state. + + + + + + + + + + + Seat control interface based on libseat. A client opens a seat, reacts to + enabled and disabled events, opens device objects while enabled, and may + request session switches when supported by the daemon. + + + + Sent when the client may open and use seat devices. + + + + + Sent when the client must stop using devices opened through this seat and + acknowledge the state change with ack_disabled. + + + + + Emitted when a session switch has taken effect. + + + + + + Sent before the seat object is terminated after a fatal seat-scoped + protocol error. + + + + + + + Acknowledges the most recent disabled event. + + + + + Opens path and returns a device object. The returned object resolves + exactly once with either opened or failed. + + + + + + + Requests switching to the given target session. The daemon reports a seat + error if switching is not supported. + + + + + + Closes the seat and any devices still associated with it. + + + + + + + Device object created by seat.open_device. It resolves exactly once with + either opened or failed. If revoked is emitted, the device is permanently + unusable and the client should destroy the object. + + + + + + + + + + + + + + + + The fd and object must not be used again after this event. + + + + + + + + + + + + + + + + + + diff --git a/protocols/session_core_v1.xml b/protocols/session_core_v1.xml index fb3ac2b..6cd6637 100644 --- a/protocols/session_core_v1.xml +++ b/protocols/session_core_v1.xml @@ -58,8 +58,8 @@ - - + + @@ -72,7 +72,7 @@ - + @@ -118,6 +118,7 @@ + diff --git a/protocols/session_management_v1.xml b/protocols/session_management_v1.xml index 6eabb60..66a39ab 100644 --- a/protocols/session_management_v1.xml +++ b/protocols/session_management_v1.xml @@ -12,13 +12,14 @@ - + Sent only to the manager that called create_session. + -- 2.51.2