From 4e94b8a28042db70feb3d2c80ce8bf04bb4ac0ea Mon Sep 17 00:00:00 2001 From: r0chd Date: Wed, 15 Jul 2026 10:44:58 +0200 Subject: [PATCH] feat(protocol): expose privileged operation capabilities --- ctl/src/main.rs | 143 +++++++++++++++++++++++++++++++++- daemon/src/seat.rs | 47 +++++++++-- daemon/src/session.rs | 52 +++++++++++-- daemon/src/user.rs | 7 ++ protocols/session_core_v1.xml | 25 ++++++ 5 files changed, 257 insertions(+), 17 deletions(-) diff --git a/ctl/src/main.rs b/ctl/src/main.rs index 0b05b6d..ac391f0 100644 --- a/ctl/src/main.rs +++ b/ctl/src/main.rs @@ -28,6 +28,7 @@ struct Session { leader: u32, timestamp: u32, active: session_core_v1::ActiveState, + can_modify: bool, done: bool, } @@ -37,6 +38,7 @@ struct User { username: String, lingering: bool, state: session_core_v1::UserState, + can_modify: bool, not_found: bool, done: bool, } @@ -47,6 +49,7 @@ struct Seat { mode: session_core_v1::SeatMode, current_vt: u32, clients: Vec, + can_modify: bool, destroyed: bool, done: bool, } @@ -85,6 +88,7 @@ struct State { seat_clients: Vec, seat_devices: Vec, done: bool, + can_control_power: bool, error: Option, } @@ -101,6 +105,9 @@ impl hyprwire::Dispatch for State { session_core_manager::Event::Done => { self.done = true; } + session_core_manager::Event::CanControlPower { can_control_power } => { + self.can_control_power = can_control_power != 0; + } session_core_manager::Event::Error { code: _, message } => { self.error = Some(message); self.done = true; @@ -127,6 +134,7 @@ impl State { mode: session_core_v1::SeatMode::NonVtBound, current_vt: 0, clients: Vec::new(), + can_modify: false, destroyed: false, done: false, }); @@ -222,6 +230,7 @@ impl State { username: String::new(), lingering: false, state: session_core_v1::UserState::Offline, + can_modify: false, not_found: false, done: false, }); @@ -247,6 +256,7 @@ impl State { timestamp: 0, done: false, active: session_core_v1::ActiveState::Online, + can_modify: false, }); } } @@ -283,6 +293,9 @@ impl hyprwire::Dispatch for State { session_core_session::Event::Active { state } => { session.active = state; } + session_core_session::Event::CanModify { can_modify } => { + session.can_modify = can_modify != 0; + } _ => {} } } @@ -303,6 +316,9 @@ impl hyprwire::Dispatch for State { session_core_user::Event::Username { username } => user.username = username, session_core_user::Event::Lingering { lingering } => user.lingering = lingering != 0, session_core_user::Event::State { state } => user.state = state, + session_core_user::Event::CanModify { can_modify } => { + user.can_modify = can_modify != 0; + } session_core_user::Event::Done | session_core_user::Event::Ok => { user.done = true; } @@ -332,6 +348,9 @@ impl hyprwire::Dispatch for State { session_core_seat::Event::Seat { seat } => self.seats[idx].name = seat, session_core_seat::Event::Mode { mode } => self.seats[idx].mode = mode, session_core_seat::Event::CurrentVt { vt } => self.seats[idx].current_vt = vt, + session_core_seat::Event::CanModify { can_modify } => { + self.seats[idx].can_modify = can_modify != 0; + } session_core_seat::Event::ClientAdded { pid } => { if !self.seats[idx].clients.contains(&pid) { self.seats[idx].clients.push(pid); @@ -650,6 +669,15 @@ impl SessionAction { Self::Unlock => object.send_unlock(), } } + + const fn name(self) -> &'static str { + match self { + Self::Close => "close", + Self::Kill => "kill", + Self::Lock => "lock", + Self::Unlock => "unlock", + } + } } #[derive(Clone, Copy)] @@ -665,6 +693,13 @@ impl UserAction { Self::Terminate => object.send_terminate(), } } + + const fn name(self) -> &'static str { + match self { + Self::Kill => "kill", + Self::Terminate => "terminate", + } + } } const fn active_state_name(state: session_core_v1::ActiveState) -> &'static str { @@ -739,6 +774,7 @@ fn main() -> anyhow::Result<()> { seat_clients: Vec::new(), seat_devices: Vec::new(), done: false, + can_control_power: false, error: None, }; @@ -790,6 +826,7 @@ fn main() -> anyhow::Result<()> { obj.insert("leader".into(), json!(session.leader)); obj.insert("timestamp".into(), json!(session.timestamp)); obj.insert("active".into(), json!(active_state_name(session.active))); + obj.insert("can_modify".into(), json!(session.can_modify)); serde_json::Value::Object(obj) }).collect::>(), })); @@ -938,6 +975,15 @@ fn main() -> anyhow::Result<()> { "error:".red(), uid.as_raw() ))?; + if !session.can_modify { + eprintln!( + "{} cannot {} session {}: permission denied", + "error:".red(), + action.name(), + session.id + ); + return Ok(()); + } action.send(&session.object); return Ok(()); @@ -945,10 +991,15 @@ fn main() -> anyhow::Result<()> { let mut unmatched = Vec::new(); let mut matched = Vec::new(); + let mut denied = Vec::new(); for id in ids { if let Some(session) = state.sessions.iter().find(|session| session.id == *id) { - matched.push(session.object.clone()); + if session.can_modify { + matched.push(session.object.clone()); + } else { + denied.push(session.id); + } } else { unmatched.push(id); } @@ -964,6 +1015,20 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + if !denied.is_empty() { + let ids = denied + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + eprintln!( + "{} cannot {} session ids without permission: {ids}", + "error:".red(), + action.name() + ); + return Ok(()); + } + for obj in matched { action.send(&obj); } @@ -1013,6 +1078,7 @@ fn main() -> anyhow::Result<()> { "username": user.username, "lingering": user.lingering, "state": user_state_name(user.state), + "can_modify": user.can_modify, }) }).collect::>(), })); @@ -1094,6 +1160,27 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + let denied = state + .users + .iter() + .filter(|user| !user.not_found && !user.can_modify) + .map(|user| user.uid) + .collect::>(); + + if !denied.is_empty() { + let uids = denied + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + eprintln!( + "{} cannot {} user ids without permission: {uids}", + "error:".red(), + action.name() + ); + return Ok(()); + } + for user in &mut state.users { user.done = false; action.send(&user.object); @@ -1144,6 +1231,27 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + let denied = state + .users + .iter() + .filter(|user| !user.not_found && !user.can_modify) + .map(|user| user.uid) + .collect::>(); + + if !denied.is_empty() { + let uids = denied + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + eprintln!( + "{} cannot {} user ids without permission: {uids}", + "error:".red(), + action.name() + ); + return Ok(()); + } + for user in &mut state.users { user.done = false; action.send(&user.object); @@ -1198,6 +1306,7 @@ fn main() -> anyhow::Result<()> { "mode": seat_mode_name(seat.mode), "current_vt": seat.current_vt, "clients": seat.clients.len(), + "can_modify": seat.can_modify, }) }).collect::>(), })); @@ -1278,6 +1387,7 @@ fn main() -> anyhow::Result<()> { "name": seat.name, "mode": seat_mode_name(seat.mode), "current_vt": seat.current_vt, + "can_modify": seat.can_modify, "clients": clients.iter().map(|client| { let process_name = process_name(client.pid); json!({ @@ -1404,6 +1514,7 @@ fn main() -> anyhow::Result<()> { let mut unmatched = Vec::new(); let mut matched = Vec::new(); + let mut denied = Vec::new(); for seat_name in &seats { if let Some(seat) = state @@ -1411,7 +1522,11 @@ fn main() -> anyhow::Result<()> { .iter() .find(|seat| seat.name == *seat_name && !seat.destroyed) { - matched.push(seat.object.clone()); + if seat.can_modify { + matched.push(seat.object.clone()); + } else { + denied.push(seat.name.clone()); + } } else { unmatched.push(seat_name); } @@ -1427,6 +1542,15 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + if !denied.is_empty() { + eprintln!( + "{} cannot terminate seats without permission: {}", + "error:".red(), + denied.join(", ") + ); + return Ok(()); + } + for seat in &mut state.seats { if matched.iter().any(|object| object == &seat.object) { seat.done = false; @@ -1448,6 +1572,21 @@ fn main() -> anyhow::Result<()> { } Command::Completions { shell: _ } => unreachable!(), cmd => { + let command_name = match cmd { + Command::Reboot => "reboot", + Command::Poweroff => "power off", + Command::Hibernate => "hibernate", + Command::Suspend => "suspend", + _ => unreachable!(), + }; + if !state.can_control_power { + eprintln!( + "{} cannot {command_name} while other users have active sessions", + "error:".red() + ); + return Ok(()); + } + state.done = false; state.error = None; diff --git a/daemon/src/seat.rs b/daemon/src/seat.rs index a412ef2..8e63c43 100644 --- a/daemon/src/seat.rs +++ b/daemon/src/seat.rs @@ -48,6 +48,8 @@ pub fn add_object( world: &mut world::World, seat_entity: entity::Entity, object: session_core_seat::SessionCoreSeat, + session_entities: &[entity::Entity], + management_access: &crate::Access, ) { if let Some(name) = world.get::(seat_entity) { object.send_seat(&name.0); @@ -65,6 +67,15 @@ pub fn add_object( } } } + if let Some(client) = object.client() { + object.send_can_modify(u32::from(can_modify( + world, + seat_entity, + session_entities, + client.creds(), + management_access, + ))); + } object.send_done(); if let Some(mut objects) = world.get_mut::(seat_entity) { @@ -72,6 +83,28 @@ pub fn add_object( } } +fn can_modify( + world: &world::World, + seat_entity: entity::Entity, + session_entities: &[entity::Entity], + creds: &rustix::net::UCred, + management_access: &crate::Access, +) -> bool { + if management_access.allows(creds) { + return true; + } + + let Some(name) = world.get::(seat_entity) else { + return false; + }; + + session::entities_by_seat_name(world, session_entities, &name.0).all(|entity| { + world + .get::(entity) + .is_some_and(|uid| uid.0 == creds.uid.as_raw()) + }) +} + pub fn notify_client_added( world: &world::World, seat_entity: entity::Entity, @@ -815,13 +848,13 @@ impl hyprwire::Dispatch(*entity) - .is_none_or(|uid| uid.0 != client.creds().uid.as_raw()) - }) - { + if !can_modify( + &self.world, + seat_entity, + &self.session_entities, + client.creds(), + &self.management_access, + ) { object.send_error( session_core_v1::ErrorCode::PermissionDenied, "permission denied", diff --git a/daemon/src/session.rs b/daemon/src/session.rs index fb01119..54c8f2e 100644 --- a/daemon/src/session.rs +++ b/daemon/src/session.rs @@ -113,6 +113,15 @@ pub fn entity_by_management_object( }) } +pub fn can_modify( + world: &world::World, + entity: entity::Entity, + creds: &rustix::net::UCred, +) -> bool { + let session_uid = world.get::(entity).map_or(0, |uid| uid.0); + creds.uid.is_root() || creds.uid.as_raw() == session_uid +} + impl hyprwire::Dispatch for crate::Sessiond { @@ -138,8 +147,7 @@ impl let Some(client) = object.client() else { return; }; - let session_uid = self.world.get::(entity).map_or(0, |uid| uid.0); - if !client.creds().uid.is_root() && client.creds().uid.as_raw() != session_uid { + if !can_modify(&self.world, entity, client.creds()) { object.send_error( session_core_v1::ErrorCode::PermissionDenied, "permission denied", @@ -282,6 +290,9 @@ pub fn add_object( if let Some(active_state) = world.get::(entity) { object.send_active(active_state.0); } + if let Some(client) = object.client() { + object.send_can_modify(u32::from(can_modify(world, entity, client.creds()))); + } if let Some(seat) = world.get::(entity) { object.send_seat(&seat.0); } @@ -365,11 +376,25 @@ impl session_core_v1::SessionCoreV1Handler .for_each(|session_id| { object.send_session_added(session_id.as_raw()); }); + if let Some(client) = object.client() { + object.send_can_control_power(u32::from(self.can_control_power(client.creds()))); + } object.send_done(); self.info_managers.push(object); } } +impl crate::Sessiond { + // Allows to manage power commands (power off, reboot) only + // if no other sessions are active or user has root permissions + fn can_control_power(&self, creds: &rustix::net::UCred) -> bool { + self.management_access.allows(creds) + || entities(&self.world, &self.session_entities) + .filter_map(|entity| self.world.get::(entity)) + .all(|session| session.0 == creds.uid.as_raw()) + } +} + impl hyprwire::Dispatch for crate::Sessiond { @@ -732,7 +757,12 @@ impl return; }; - user::add_object(&mut self.world, entity, session_core_user); + user::add_object( + &mut self.world, + entity, + session_core_user, + &self.management_access, + ); } session_core_manager::Event::Reboot => { let Some(client) = object.client() else { @@ -745,7 +775,7 @@ impl .filter(|session| session.0 != client.creds().uid.as_raw()) .count(); - if foreign_session_count > 0 && !self.management_access.allows(client.creds()) { + if !self.can_control_power(client.creds()) { log::warn!(uid = client.creds().uid.as_raw(), pid = client.creds().pid.as_raw_pid(), foreign_session_count; "reboot denied: multiple sessions active"); object.send_error( session_core_v1::ErrorCode::ActiveSessions, @@ -796,7 +826,7 @@ impl .filter(|session| session.0 != client.creds().uid.as_raw()) .count(); - if foreign_session_count > 0 && !self.management_access.allows(client.creds()) { + if !self.can_control_power(client.creds()) { log::warn!(uid = client.creds().uid.as_raw(), pid = client.creds().pid.as_raw_pid(), foreign_session_count; "poweroff denied: multiple sessions active"); object.send_error( session_core_v1::ErrorCode::ActiveSessions, @@ -847,7 +877,7 @@ impl .filter(|session| session.0 != client.creds().uid.as_raw()) .count(); - if foreign_session_count > 0 && !self.management_access.allows(client.creds()) { + if !self.can_control_power(client.creds()) { log::warn!(uid = client.creds().uid.as_raw(), pid = client.creds().pid.as_raw_pid(), foreign_session_count; "suspend denied: multiple sessions active"); object.send_error( session_core_v1::ErrorCode::ActiveSessions, @@ -882,7 +912,7 @@ impl .filter(|session| session.0 != client.creds().uid.as_raw()) .count(); - if foreign_session_count > 0 && !self.management_access.allows(client.creds()) { + if !self.can_control_power(client.creds()) { log::warn!(uid = client.creds().uid.as_raw(), pid = client.creds().pid.as_raw_pid(), foreign_session_count; "hibernate denied: multiple sessions active"); object.send_error( session_core_v1::ErrorCode::ActiveSessions, @@ -916,7 +946,13 @@ impl session_core_seat.send_destroyed(); return; }; - seat::add_object(&mut self.world, seat_entity, session_core_seat); + seat::add_object( + &mut self.world, + seat_entity, + session_core_seat, + &self.session_entities, + &self.management_access, + ); } session_core_manager::Event::Destroy => { self.info_managers.retain(|m| m != object); diff --git a/daemon/src/user.rs b/daemon/src/user.rs index 70546c8..ec16d64 100644 --- a/daemon/src/user.rs +++ b/daemon/src/user.rs @@ -139,6 +139,7 @@ pub fn add_object( world: &mut world::World, entity: entity::Entity, object: session_core_user::SessionCoreUser, + management_access: &crate::Access, ) { if let Some(uid) = world.get::(entity) { object.send_uid(uid.0); @@ -152,6 +153,12 @@ pub fn add_object( if let Some(state) = world.get::(entity) { object.send_state(state.0); } + if let Some(client) = object.client() { + let uid = world.get::(entity).map_or(0, |uid| uid.0); + let can_modify = + management_access.allows(client.creds()) || uid == client.creds().uid.as_raw(); + object.send_can_modify(u32::from(can_modify)); + } object.send_done(); if let Some(mut objects) = world.get_mut::(entity) { diff --git a/protocols/session_core_v1.xml b/protocols/session_core_v1.xml index 78c887b..47044a5 100644 --- a/protocols/session_core_v1.xml +++ b/protocols/session_core_v1.xml @@ -80,6 +80,13 @@ + + + Non-zero if this client can reboot, power off, suspend, or hibernate + without first closing other users' sessions. + + + @@ -155,6 +162,12 @@ + + + Non-zero if this client may lock, unlock, terminate, or kill this session. + + + @@ -212,6 +225,12 @@ + + + Non-zero if this client may terminate all sessions on this seat. + + + @@ -355,6 +374,12 @@ + + + Non-zero if this client may terminate or kill this user's sessions. + + + -- 2.51.2