Rust · 17602 bytes Raw Blame History
1 //! User session management
2 //!
3 //! Launches and manages user desktop sessions using fork/exec with proper
4 //! PAM session handling in the child process.
5
6 use anyhow::{anyhow, Context, Result};
7 use nix::sys::wait::{waitpid, WaitStatus};
8 use nix::unistd::{ForkResult, Pid, User};
9 use pam::Client;
10 use std::ffi::CString;
11 use std::os::unix::io::RawFd;
12 use std::path::Path;
13 use std::process::ExitStatus;
14
15 use crate::auth::PAM_SERVICE_NAME;
16
17 /// Standard PATH for session processes
18 const SESSION_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
19
20 /// Resolve a command to its full path by searching PATH directories
21 fn resolve_command(cmd: &str, home: &str) -> String {
22 if cmd.starts_with('/') {
23 return cmd.to_string();
24 }
25
26 let search_path = format!(
27 "{}/.local/bin:{}/.cargo/bin:{}",
28 home, home, SESSION_PATH
29 );
30
31 for dir in search_path.split(':') {
32 let full_path = Path::new(dir).join(cmd);
33 if full_path.exists() && full_path.is_file() {
34 if let Ok(metadata) = full_path.metadata() {
35 use std::os::unix::fs::PermissionsExt;
36 if metadata.permissions().mode() & 0o111 != 0 {
37 return full_path.to_string_lossy().to_string();
38 }
39 }
40 }
41 }
42
43 cmd.to_string()
44 }
45
46 /// User session wrapper
47 pub struct UserSession {
48 pid: Pid,
49 username: String,
50 }
51
52 impl UserSession {
53 /// Start a user session
54 ///
55 /// This forks and in the child process:
56 /// 1. Authenticates with PAM and opens a session (registers with logind)
57 /// 2. Drops privileges to the target user
58 /// 3. Sets up TTY for Wayland sessions
59 /// 4. Execs the session command
60 ///
61 /// # Arguments
62 /// * `username` - The user to run the session as
63 /// * `password` - The user's password for PAM authentication
64 /// * `session_cmd` - Command to execute (e.g., ["Hyprland"] or ["gar-session.sh"])
65 /// * `session_type` - "x11" or "wayland"
66 /// * `display` - X11 display (e.g., ":0"), None for Wayland sessions
67 /// * `vt` - Virtual terminal number
68 pub fn start(
69 username: &str,
70 password: &str,
71 session_cmd: &[String],
72 session_type: &str,
73 display: Option<&str>,
74 vt: u32,
75 ) -> Result<Self> {
76 let user = User::from_name(username)
77 .context("Failed to look up user")?
78 .ok_or_else(|| anyhow!("User {} not found", username))?;
79
80 let home = user.dir.to_string_lossy().to_string();
81 let shell = user.shell.to_string_lossy().to_string();
82 let uid = user.uid;
83 let gid = user.gid;
84
85 let is_wayland = session_type == "wayland";
86
87 // Determine session command
88 let (cmd_path, cmd_args) = if session_cmd.is_empty() {
89 let default_session = "/usr/local/bin/gar-session.sh";
90 if Path::new(default_session).exists() {
91 (default_session.to_string(), vec![])
92 } else {
93 (shell.clone(), vec!["-l".to_string()])
94 }
95 } else {
96 let resolved = resolve_command(&session_cmd[0], &home);
97 tracing::debug!(
98 original = %session_cmd[0],
99 resolved = %resolved,
100 "Resolved session command"
101 );
102 (resolved, session_cmd[1..].to_vec())
103 };
104
105 // Prepare TTY for Wayland sessions (as root, before fork)
106 let tty_path = format!("/dev/tty{}", vt);
107 let tty_fd: Option<RawFd> = if is_wayland {
108 // Change TTY ownership to target user
109 let tty_cstr = CString::new(tty_path.as_str()).context("Invalid TTY path")?;
110 let ret = unsafe { libc::chown(tty_cstr.as_ptr(), uid.as_raw(), gid.as_raw()) };
111 if ret != 0 {
112 return Err(anyhow!(
113 "Failed to chown TTY: {}",
114 std::io::Error::last_os_error()
115 ));
116 }
117
118 // Open TTY as root
119 let tty = std::fs::OpenOptions::new()
120 .read(true)
121 .write(true)
122 .open(&tty_path)
123 .context(format!("Failed to open TTY {}", tty_path))?;
124
125 use std::os::unix::io::IntoRawFd;
126 Some(tty.into_raw_fd())
127 } else {
128 None
129 };
130
131 // Build environment for the session
132 let env_vars = build_session_env(
133 username, &home, &shell, uid.as_raw(), vt, session_type, display,
134 );
135
136 tracing::debug!(
137 cmd = %cmd_path,
138 args = ?cmd_args,
139 session_type,
140 vt,
141 "About to fork for session"
142 );
143
144 // Fork!
145 match unsafe { nix::unistd::fork() } {
146 Ok(ForkResult::Parent { child }) => {
147 // Parent: close the TTY fd if we opened one
148 if let Some(fd) = tty_fd {
149 unsafe { libc::close(fd) };
150 }
151
152 tracing::info!(
153 username,
154 session = %cmd_path,
155 args = ?cmd_args,
156 session_type,
157 pid = child.as_raw(),
158 "Started user session"
159 );
160
161 Ok(Self {
162 pid: child,
163 username: username.to_string(),
164 })
165 }
166 Ok(ForkResult::Child) => {
167 // Child process - this will exec or exit
168 // Note: we can't use tracing here as it's not fork-safe
169
170 // Run child process setup - this calls exec() or exit(), never returns
171 child_process_main(
172 username,
173 password,
174 &home,
175 uid,
176 gid,
177 &cmd_path,
178 &cmd_args,
179 env_vars, // Pass ownership, will be modified after PAM opens session
180 is_wayland,
181 tty_fd,
182 vt,
183 session_type,
184 )
185 }
186 Err(e) => {
187 if let Some(fd) = tty_fd {
188 unsafe { libc::close(fd) };
189 }
190 Err(anyhow!("Fork failed: {}", e))
191 }
192 }
193 }
194
195 /// Get the username this session belongs to
196 pub fn username(&self) -> &str {
197 &self.username
198 }
199
200 /// Check if session is still running
201 pub fn is_running(&mut self) -> bool {
202 matches!(
203 waitpid(self.pid, Some(nix::sys::wait::WaitPidFlag::WNOHANG)),
204 Ok(WaitStatus::StillAlive)
205 )
206 }
207
208 /// Wait for session to exit
209 pub fn wait(&mut self) -> Result<ExitStatus> {
210 loop {
211 match waitpid(self.pid, None) {
212 Ok(WaitStatus::Exited(_, code)) => {
213 tracing::info!(
214 username = %self.username,
215 exit_code = code,
216 "Session ended"
217 );
218 // Create ExitStatus from code
219 return Ok(std::process::ExitStatus::from_raw(code << 8));
220 }
221 Ok(WaitStatus::Signaled(_, sig, _)) => {
222 tracing::info!(
223 username = %self.username,
224 signal = ?sig,
225 "Session killed by signal"
226 );
227 return Ok(std::process::ExitStatus::from_raw(128 + sig as i32));
228 }
229 Ok(_) => {
230 // Other status (stopped, continued) - keep waiting
231 continue;
232 }
233 Err(nix::errno::Errno::ECHILD) => {
234 // Child already reaped
235 tracing::info!(username = %self.username, "Session ended (already reaped)");
236 return Ok(std::process::ExitStatus::from_raw(0));
237 }
238 Err(e) => {
239 return Err(anyhow!("waitpid failed: {}", e));
240 }
241 }
242 }
243 }
244
245 /// Get the process ID
246 pub fn pid(&self) -> u32 {
247 self.pid.as_raw() as u32
248 }
249 }
250
251 /// Build environment variables for the session
252 fn build_session_env(
253 username: &str,
254 home: &str,
255 shell: &str,
256 uid: u32,
257 vt: u32,
258 session_type: &str,
259 display: Option<&str>,
260 ) -> Vec<CString> {
261 let mut env = vec![
262 format!("HOME={}", home),
263 format!("USER={}", username),
264 format!("LOGNAME={}", username),
265 format!("SHELL={}", shell),
266 format!("PATH={}/.local/bin:{}/.cargo/bin:{}", home, home, SESSION_PATH),
267 format!("XDG_VTNR={}", vt),
268 "XDG_SEAT=seat0".to_string(),
269 "XDG_SESSION_CLASS=user".to_string(),
270 format!("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus", uid),
271 format!("XDG_RUNTIME_DIR=/run/user/{}", uid),
272 "XDG_DATA_DIRS=/usr/local/share:/usr/share".to_string(),
273 "XDG_CONFIG_DIRS=/etc/xdg".to_string(),
274 format!("XDG_SESSION_TYPE={}", session_type),
275 ];
276
277 if session_type == "x11" {
278 if let Some(d) = display {
279 env.push(format!("DISPLAY={}", d));
280 env.push(format!("XAUTHORITY={}/.Xauthority", home));
281 }
282 env.push("XDG_SESSION_DESKTOP=gar".to_string());
283 env.push("XDG_CURRENT_DESKTOP=gar".to_string());
284 }
285
286 env.into_iter()
287 .filter_map(|s| CString::new(s).ok())
288 .collect()
289 }
290
291 /// Worker process main function - handles PAM session lifecycle
292 ///
293 /// PAM is weird and gets upset if you exec from the process that opened the session,
294 /// registering it automatically as a log-out. Thus, we must fork again and exec in
295 /// a grandchild process, while the worker (this process) holds the PAM session open.
296 ///
297 /// This function either waits for the grandchild and exits, or exits on error - it never returns
298 fn child_process_main(
299 username: &str,
300 password: &str,
301 home: &str,
302 uid: nix::unistd::Uid,
303 gid: nix::unistd::Gid,
304 cmd_path: &str,
305 cmd_args: &[String],
306 mut env_vars: Vec<CString>,
307 _is_wayland: bool,
308 tty_fd: Option<RawFd>,
309 vt: u32,
310 session_type: &str,
311 ) -> ! {
312 // Create new session (detach from parent's controlling terminal)
313 // This must be done before PAM so pam_systemd sees us as a session leader
314 // Required for ALL session types, not just Wayland - without this, the session
315 // becomes "abandoned" by systemd-logind and polkit won't treat it as active
316 unsafe {
317 let sid = libc::setsid();
318 if sid < 0 {
319 eprintln!("[SESSION] setsid() failed: {}", std::io::Error::last_os_error());
320 }
321 }
322
323 // PAM authentication and session opening
324 // This registers the session with systemd-logind for device access
325 // The PAM client is kept alive via mem::forget - it will be cleaned up when this process exits
326 if let Err(e) = pam_authenticate_and_open_session(username, password, vt, session_type) {
327 eprintln!("[SESSION] PAM failed: {}", e);
328 std::process::exit(1);
329 }
330
331 // After pam_open_session(), pam_systemd sets XDG_SESSION_ID in the environment
332 // Read it and add to the environment we'll pass to the session
333 if let Ok(session_id) = std::env::var("XDG_SESSION_ID") {
334 if let Ok(cstr) = CString::new(format!("XDG_SESSION_ID={}", session_id)) {
335 env_vars.push(cstr);
336 }
337 }
338
339 // Initialize supplementary groups (must be done as root, before fork)
340 let username_cstr = match CString::new(username) {
341 Ok(c) => c,
342 Err(e) => {
343 eprintln!("[SESSION] Invalid username: {}", e);
344 std::process::exit(1);
345 }
346 };
347
348 if let Err(e) = nix::unistd::initgroups(&username_cstr, gid) {
349 eprintln!("[SESSION] initgroups failed: {}", e);
350 std::process::exit(1);
351 }
352
353 // Prepare exec arguments before fork (to minimize work in grandchild)
354 let cmd_cstr = match CString::new(cmd_path) {
355 Ok(c) => c,
356 Err(e) => {
357 eprintln!("[SESSION] Invalid command path: {}", e);
358 std::process::exit(1);
359 }
360 };
361
362 let mut argv: Vec<CString> = vec![cmd_cstr.clone()];
363 for arg in cmd_args {
364 match CString::new(arg.as_str()) {
365 Ok(c) => argv.push(c),
366 Err(e) => {
367 eprintln!("[SESSION] Invalid argument: {}", e);
368 std::process::exit(1);
369 }
370 }
371 }
372
373 // Fork again! The grandchild will exec the session command, while this worker
374 // process keeps the PAM session open. This is required because PAM treats exec()
375 // from the process that called pam_open_session() as a logout.
376 match unsafe { libc::fork() } {
377 -1 => {
378 eprintln!("[SESSION] Second fork failed: {}", std::io::Error::last_os_error());
379 std::process::exit(1);
380 }
381 0 => {
382 // Grandchild: drop privileges, setup TTY, and exec
383
384 // Drop privileges
385 if let Err(e) = nix::unistd::setgid(gid) {
386 eprintln!("[SESSION] setgid failed: {}", e);
387 std::process::exit(1);
388 }
389
390 if let Err(e) = nix::unistd::setuid(uid) {
391 eprintln!("[SESSION] setuid failed: {}", e);
392 std::process::exit(1);
393 }
394
395 // Change to home directory
396 if std::env::set_current_dir(home).is_err() {
397 eprintln!("[SESSION] Failed to chdir to home");
398 // Non-fatal, continue
399 }
400
401 // Set up controlling TTY for Wayland sessions
402 if let Some(fd) = tty_fd {
403 unsafe {
404 // Set as controlling terminal (steal if necessary)
405 if libc::ioctl(fd, libc::TIOCSCTTY, 1) < 0 {
406 eprintln!(
407 "[SESSION] TIOCSCTTY failed: {}",
408 std::io::Error::last_os_error()
409 );
410 // Non-fatal for some compositors
411 }
412
413 // Set up stdin/stdout/stderr to the TTY
414 libc::dup2(fd, 0);
415 libc::dup2(fd, 1);
416 libc::dup2(fd, 2);
417
418 if fd > 2 {
419 libc::close(fd);
420 }
421 }
422 }
423
424 // execve replaces the process image
425 match nix::unistd::execve(&cmd_cstr, &argv, &env_vars) {
426 Ok(_) => unreachable!(), // execve doesn't return on success
427 Err(e) => {
428 eprintln!("[SESSION] execve failed: {}", e);
429 std::process::exit(127);
430 }
431 }
432 }
433 grandchild_pid => {
434 // Worker: wait for grandchild to exit, keeping PAM session alive
435 // Close TTY fd in worker - grandchild has its own copy
436 if let Some(fd) = tty_fd {
437 unsafe { libc::close(fd) };
438 }
439
440 // Wait for grandchild
441 let mut status: libc::c_int = 0;
442 loop {
443 let ret = unsafe { libc::waitpid(grandchild_pid, &mut status, 0) };
444 if ret < 0 {
445 let err = std::io::Error::last_os_error();
446 if err.kind() == std::io::ErrorKind::Interrupted {
447 continue; // EINTR, retry
448 }
449 eprintln!("[SESSION] waitpid failed: {}", err);
450 break;
451 }
452 break;
453 }
454
455 // Grandchild exited - worker process can now exit
456 // The PAM session will be cleaned up when this process terminates
457
458 // Exit with grandchild's exit code
459 let exit_code = if unsafe { libc::WIFEXITED(status) } {
460 unsafe { libc::WEXITSTATUS(status) }
461 } else {
462 1
463 };
464 std::process::exit(exit_code);
465 }
466 }
467 }
468
469 /// Authenticate with PAM and open a session
470 /// This must be called in the worker process before forking the grandchild.
471 /// The PAM session stays open as long as this process is alive.
472 fn pam_authenticate_and_open_session(
473 username: &str,
474 password: &str,
475 vt: u32,
476 session_type: &str,
477 ) -> Result<()> {
478 // Set environment variables BEFORE creating PAM client
479 // pam_systemd reads these to determine session type and VT
480 std::env::set_var("XDG_SESSION_TYPE", session_type);
481 std::env::set_var("XDG_VTNR", vt.to_string());
482 std::env::set_var("XDG_SEAT", "seat0");
483 std::env::set_var("XDG_SESSION_CLASS", "user");
484
485 let mut client = Client::with_password(PAM_SERVICE_NAME)
486 .map_err(|e| anyhow!("Failed to create PAM client: {:?}", e))?;
487
488 client.conversation_mut().set_credentials(username, password);
489
490 client
491 .authenticate()
492 .map_err(|e| anyhow!("PAM authentication failed: {:?}", e))?;
493
494 client
495 .open_session()
496 .map_err(|e| anyhow!("PAM open_session failed: {:?}", e))?;
497
498 // Keep the client alive - don't let it drop and close the session
499 // The worker process will hold this until the grandchild (session) exits
500 // Since the worker never execs, PAM won't treat this as a logout
501 std::mem::forget(client);
502
503 Ok(())
504 }
505
506 // Trait implementation for ExitStatus::from_raw
507 use std::os::unix::process::ExitStatusExt;
508