diff --git a/README.md b/README.md index 9c106dc..ef3f387 100644 --- a/README.md +++ b/README.md @@ -79,19 +79,31 @@ The binary is written to `target/release/sessiond-hooks`. sessiond-hooks --config PATH [--log-target stderr|syslog] ``` -`--config` points at a Dhall file that evaluates to a function +`--config` points at a Dhall file that evaluates to a configuration record: ```dhall let Hooks = env:SESSIOND_HOOKS -in \(ctx : Hooks.Context) - -> { init : Text - , args : List Text - , restart : Hooks.Restart - } +in { delegate_cgroup : Bool + , hook : \(ctx : Hooks.Context) + -> { init : Text + , args : List Text + , restart : Hooks.Restart + } + } ``` -For each login session, `sessiond-hooks` applies this function to the session's -context and spawns `init` with `args`. The `restart` policy controls what happens -when the hook exits, `delay` is the restart -delay in seconds. +For each login session, `sessiond-hooks` applies `hook` to the session's +context and spawns `init` with `args`. The `restart` policy controls what +happens when the hook exits; `delay` is the restart delay in seconds. + +### Cgroup delegation + +Set `delegate_cgroup = True` to create and delegate a cgroup for each managed +session. The resulting path is available as `ctx.cgroup_path` and can be passed +to a session supervisor that supports managing an existing cgroup. + +`ctx.cgroup_path` is an `Optional Text`: it is `None` when delegation is +disabled or unavailable. The hook configuration must therefore handle the +optional value before passing it to a supervisor. The delegated cgroup remains +owned by the managed session and is released when that session is removed. diff --git a/dhall/config.example.dhall b/dhall/config.example.dhall index 0c16f95..02a5fef 100644 --- a/dhall/config.example.dhall +++ b/dhall/config.example.dhall @@ -9,14 +9,21 @@ let Prelude = env:DHALL_PRELUDE let Hooks = env:SESSIOND_HOOKS -let Config = { init : Text, args : List Text, restart : Hooks.Restart } +let Hook = + { init : Text + , args : List Text + , restart : Hooks.Restart + } -in λ(ctx : Hooks.Context) → - let servicesDir = - Prelude.Text.concatSep "/" [ ctx.home, ".config", "dinit.d" ] +in { -- Set to True to expose a delegated session cgroup at ctx.cgroup_path. + delegate_cgroup = False + , hook = λ(ctx : Hooks.Context) → + let servicesDir = + Prelude.Text.concatSep "/" [ ctx.home, ".config", "dinit.d" ] - in { init = "/usr/bin/dinit" - , args = [ "--user", "--services-dir", servicesDir ] - , restart = Hooks.defaultRestart - } - : Config + in { init = "/usr/bin/dinit" + , args = [ "--user", "--services-dir", servicesDir ] + , restart = Hooks.defaultRestart + } + : Hook + } diff --git a/dhall/sessiond-hooks.dhall b/dhall/sessiond-hooks.dhall index 9f57733..fd58683 100644 --- a/dhall/sessiond-hooks.dhall +++ b/dhall/sessiond-hooks.dhall @@ -1,4 +1,10 @@ -let Context = { uid : Natural, user : Text, home : Text, shell : Text } +let Context = + { uid : Natural + , user : Text + , home : Text + , shell : Text + , cgroup_path : Optional Text + } let RestartPolicy = < Never | Failure | Always > diff --git a/protocols/session_management_v2.xml b/protocols/session_management_v2.xml index 9fe47b9..65b4866 100644 --- a/protocols/session_management_v2.xml +++ b/protocols/session_management_v2.xml @@ -1,5 +1,5 @@ - + Copyright (c) 2026 Oskar Rochowiak @@ -30,10 +30,8 @@ Returns a session_management_session object bound to the calling - connection. The session is not created yet: the client may send vt, - remote_host, and env on the returned object to stage optional fields, - then must send done to commit. The daemon then pushes session_id, - followed by done, or error if creation failed. + connection. The client may stage optional fields before creating the + session directly or through a delegated cgroup object. @@ -45,36 +43,33 @@ - Bound to the connection that created the session. Before done is - sent, the session is only staged: vt and remote_host may be sent to - set optional fields. is_local is not a field; the session is treated - as remote if remote_host was set, local otherwise. Environment - variables may also be staged before done. Once created, destroying - this object, explicitly or by disconnecting, tears the session down if - it has not already been destroyed. + Bound to the connection that created the session. Optional fields may be + sent while the session is staged. Setting remote_host marks the session as + remote. Once done has been sent, modifying staged fields or sending done + again is a protocol error. Destroying this object tears down the session + and its resources. - Optional. Must be sent before done. Defaults to 0 (not bound to a VT). + Optional. Must be sent while staged. Defaults to 0 (not bound to a VT). - Optional. Must be sent before done. Its presence marks the session as - remote; if never sent, the session is local. + Optional. Must be sent while staged. - Optional. Must be sent before done. Defaults to user. + Optional. Must be sent while staged. Defaults to user. - Optional. Must be sent before done. May be sent multiple times. + Optional. Must be sent while staged. May be sent multiple times. @@ -82,8 +77,7 @@ Creates the session using uid and any staged vt/remote_host/env. The - daemon responds with session_id, followed by done, or with - error if creation failed. + daemon responds with session_id, followed by done, or with error. @@ -104,6 +98,58 @@ + + + Must be sent while staged. Creates the cgroup that will contain the + session and returns an independent cgroup control object. The object + exposes the path for a session supervisor such as dinit to pass to its + --cgroup-path option and manage directly. The object owns the cgroup and + its destruction destroys the cgroup. + + + + + + + + Control object for the cgroup created for a staged session. The object is + usable independently by a session supervisor such as dinit, which can + manage the cgroup using the delegated path. Destroying this object + destroys the delegated cgroup. + + + + Reports the exact path of the cgroup created for this object. The client + may use this path to edit the cgroup directly. + + + + + + + + + + + Must be sent before the session is committed. May be sent repeatedly. + The requested controller name is made available to the delegated manager + when supported by the kernel and permitted by daemon policy. The daemon + does not enable the controller in the delegated cgroup; the delegated + manager may enable it for its own child cgroups. + + + + + + Must be sent after all controller requests and before the owning session + is committed. After this request, no further controller requests are + allowed. The daemon creates and delegates the cgroup when the owning + session is committed, then responds with path or error. + + + + + diff --git a/src/config.rs b/src/config.rs index 2618353..6b86e9a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ pub struct SessionContext { pub user: String, pub home: String, pub shell: String, + pub cgroup_path: Option, } #[derive(Deserialize, Clone, Copy, Default)] @@ -39,21 +40,42 @@ pub struct Config { impl Config { pub fn load(config_path: &path::Path, ctx: &SessionContext) -> anyhow::Result { + let config = config_path.display().to_string(); let ctx_dhall = serde_dhall::serialize(ctx) .to_string() .context("serializing session context to Dhall")?; - let expr = format!( - "let Context = {{ uid : Natural, user : Text, home : Text, shell : Text }} \ + let hook = format!( + "let Context = {{ uid : Natural, user : Text, home : Text, shell : Text, cgroup_path : Optional Text }} \ let RestartPolicy = < Never | Failure | Always > \ let Restart = {{ on : RestartPolicy, delay : Natural }} \ - let Config = {{ init : Text, args : List Text, restart : Restart }} \ - in (({config}) : Context -> Config) ({ctx})", - config = config_path.display(), + let Hook = {{ init : Text, args : List Text, restart : Restart }} \ + let Config = {{ delegate_cgroup : Bool, hook : Context -> Hook }} \ + in (({config}).hook : Context -> Hook) ({ctx})", ctx = ctx_dhall, ); + let hook = serde_dhall::from_str(&hook) + .imports(true) + .parse::() + .context("evaluating Dhall hook callback")?; + Ok(Self { + init: hook.init, + args: hook.args, + restart: hook.restart, + }) + } + + pub fn delegation_requested(config_path: &path::Path) -> anyhow::Result { + let expr = format!("({config}).delegate_cgroup", config = config_path.display(),); serde_dhall::from_str(&expr) .imports(true) - .parse::() - .context("evaluating Dhall config") + .parse::() + .context("evaluating Dhall cgroup delegation option") } } + +#[derive(Deserialize)] +struct HookConfig { + init: String, + args: Vec, + restart: Restart, +} diff --git a/src/main.rs b/src/main.rs index 6e77d28..2f128a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,9 @@ use calloop::{generic, timer}; use clap::{Parser, ValueEnum}; use hyprwire::client; use session_core_v1::{session_core_manager, session_core_session}; -use session_management_v2::{session_management_session, session_manager}; +use session_management_v2::{ + session_management_cgroup, session_management_session, session_manager, +}; use std::collections::BTreeMap; use std::os::fd::AsRawFd; use std::os::unix::process::CommandExt; @@ -50,13 +52,24 @@ struct Session { class: Option, } -struct ManagedSession { +struct PendingManagedSession { id: u32, uid: u32, object: session_management_session::SessionManagementSession, envs: BTreeMap, hook: Option, + cgroup_delegate: Option, + delegated_cgroup_path: Option, +} + +struct ManagedSession { + id: u32, + uid: u32, + _object: session_management_session::SessionManagementSession, + envs: BTreeMap, + hook: Option, config: config::Config, + _cgroup_delegate: Option, } impl ManagedSession { @@ -223,9 +236,11 @@ impl ManagedSession { } struct SessiondHooks { + pending_managed_sessions: Vec, managed_sessions: Vec, sessions: Vec, config_path: path::PathBuf, + delegate_cgroup: bool, session_manager: Option, loop_handle: calloop::LoopHandle<'static, Self>, } @@ -280,7 +295,7 @@ impl hyprwire::Dispatch fo match event { session_management_session::Event::Env { name, value } => { let Some(session) = self - .managed_sessions + .pending_managed_sessions .iter_mut() .find(|session| session.object == *object) else { @@ -293,7 +308,7 @@ impl hyprwire::Dispatch fo } session_management_session::Event::SessionId { session_id } => { let Some(session) = self - .managed_sessions + .pending_managed_sessions .iter_mut() .find(|session| session.object == *object) else { @@ -305,23 +320,84 @@ impl hyprwire::Dispatch fo } session_management_session::Event::Done => { let Some(session) = self - .managed_sessions - .iter_mut() - .find(|session| session.object == *object) + .pending_managed_sessions + .iter() + .position(|session| session.object == *object) else { return; }; + let session = self.pending_managed_sessions.swap_remove(session); - if session.hook.is_some() { - log::warn!(session_id = session.id, uid = session.uid; - "hook is already running; ignoring duplicate done"); + let Some(user) = uzers::get_user_by_uid(session.uid) else { + log::error!(uid = session.uid; "cannot start hook for unknown uid"); return; - } + }; - session.run_hook(self.loop_handle.clone()); + let ctx = config::SessionContext { + uid: session.uid, + user: user.name().to_string_lossy().into_owned(), + home: user.home_dir().to_string_lossy().into_owned(), + shell: user.shell().to_string_lossy().into_owned(), + cgroup_path: session.delegated_cgroup_path, + }; + let config = match config::Config::load(&self.config_path, &ctx) { + Ok(config) => config, + Err(err) => { + log::error!( + path:% = self.config_path.display(), + uid = session.uid, + error:? = err; "failed to resolve config" + ); + return; + } + }; + + let mut managed_session = ManagedSession { + id: session.id, + uid: session.uid, + _object: session.object, + envs: session.envs, + hook: session.hook, + config, + _cgroup_delegate: session.cgroup_delegate, + }; + managed_session.envs.extend(BTreeMap::from([ + ( + "HOME".to_string(), + user.home_dir().to_string_lossy().into_owned(), + ), + ( + "USER".to_string(), + user.name().to_string_lossy().into_owned(), + ), + ( + "LOGNAME".to_string(), + user.name().to_string_lossy().into_owned(), + ), + ( + "SHELL".to_string(), + user.shell().to_string_lossy().into_owned(), + ), + ])); + managed_session.run_hook(self.loop_handle.clone()); + + self.managed_sessions.push(managed_session); } session_management_session::Event::Error { code, message } => { - log::error!(code:? = code, message:% = message; "session_management_session error"); + if let Some(index) = self + .pending_managed_sessions + .iter() + .position(|session| session.object == *object) + { + let session = self.pending_managed_sessions.swap_remove(index); + log::error!( + uid = session.uid, + session_id = session.id, + code:? = code, + message:% = message; + "managed session creation failed" + ); + } } } } @@ -361,71 +437,47 @@ impl hyprwire::Dispatch for SessiondHo .managed_sessions .iter() .any(|managed_session| managed_session.uid == session.uid) + || self + .pending_managed_sessions + .iter() + .any(|pending_session| pending_session.uid == session.uid) { log::info!(uid = session.uid; "uid already has a managed hook session"); return; } let Some(session_manager) = self.session_manager.as_ref() else { - log::error!(uid = session.uid; "cannot create managed session: session manager is not bound"); return; }; - let Some(user) = uzers::get_user_by_uid(session.uid) else { - log::error!(uid = session.uid; "cannot start hook for unknown uid"); - return; - }; - let ctx = config::SessionContext { - uid: session.uid, - user: user.name().to_string_lossy().into_owned(), - home: user.home_dir().to_string_lossy().into_owned(), - shell: user.shell().to_string_lossy().into_owned(), - }; - let config = match config::Config::load(&self.config_path, &ctx) { - Ok(config) => config, - Err(err) => { - log::error!( - path:% = self.config_path.display(), - uid = session.uid, - error:? = err; "failed to resolve config" - ); - return; - } - }; - - let Some(session_object) = session_manager.send_create_session::(session.uid) - else { - log::error!(uid = session.uid; "failed to create managed session: protocol returned no object"); - return; - }; - session_object.send_class(session_management_v2::SessionClass::Manager); - session_object.send_done(); + let session_object = session_manager + .send_create_session::(session.uid) + .unwrap(); - self.managed_sessions.push(ManagedSession { + let mut pending_managed_session = PendingManagedSession { id: 0, uid: session.uid, object: session_object, - envs: BTreeMap::from([ - ( - "HOME".to_string(), - user.home_dir().to_string_lossy().into_owned(), - ), - ( - "USER".to_string(), - user.name().to_string_lossy().into_owned(), - ), - ( - "LOGNAME".to_string(), - user.name().to_string_lossy().into_owned(), - ), - ( - "SHELL".to_string(), - user.shell().to_string_lossy().into_owned(), - ), - ]), + envs: BTreeMap::new(), hook: None, - config, - }); + cgroup_delegate: None, + delegated_cgroup_path: None, + }; + + if self.delegate_cgroup + && let Some(cgroup_delegate) = pending_managed_session + .object + .send_delegate_cgroup::() + { + cgroup_delegate.send_commit(); + pending_managed_session.cgroup_delegate = Some(cgroup_delegate); + } + pending_managed_session + .object + .send_class(session_management_v2::SessionClass::Manager); + pending_managed_session.object.send_done(); + + self.pending_managed_sessions.push(pending_managed_session); } session_core_session::Event::Destroyed => { let Some(index) = self @@ -455,6 +507,16 @@ impl hyprwire::Dispatch for SessiondHo { return timer::TimeoutAction::Drop; } + + let removed_pending_sessions = state + .pending_managed_sessions + .extract_if(.., |session| session.uid == removed_session.uid); + + for removed_pending_session in removed_pending_sessions { + log::info!(uid = removed_pending_session.uid; + "removed pending managed session"); + } + let removed_sessions = state .managed_sessions .extract_if(.., |session| session.uid == removed_session.uid); @@ -522,12 +584,40 @@ impl hyprwire::Dispatch for SessiondHo } } +impl hyprwire::Dispatch for SessiondHooks { + fn event( + &mut self, + object: &session_management_cgroup::SessionManagementCgroup, + event: ::Event<'_>, + ) { + match event { + session_management_cgroup::Event::Path { path } => { + let Some(session) = self.pending_managed_sessions.iter_mut().find(|session| { + session + .cgroup_delegate + .as_ref() + .is_some_and(|delegate| delegate == object) + }) else { + return; + }; + session.delegated_cgroup_path = Some(path.into()); + } + session_management_cgroup::Event::Error { + code: _, + message: _, + } => {} + } + } +} + fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let config_path = cli .config .canonicalize() .with_context(|| format!("cannot read config file {}", cli.config.display()))?; + let delegate_cgroup = config::Config::delegation_requested(&config_path) + .context("checking cgroup delegation configuration")?; let log_level = match env::var("LOG_LEVEL") { Ok(log) if log == "trace" => log::LevelFilter::Trace, Ok(log) if log == "debug" => log::LevelFilter::Debug, @@ -553,8 +643,10 @@ fn main() -> anyhow::Result<()> { let mut sessiond_hooks = SessiondHooks { loop_handle: event_loop.handle(), managed_sessions: Vec::new(), + pending_managed_sessions: Vec::new(), sessions: Vec::new(), config_path, + delegate_cgroup, session_manager: None, };