Files
latchd/crates/latchd-core/src/session.rs

864 lines
27 KiB
Rust

//! Session discovery, environment construction, and child lifecycle.
use std::{
ffi::{CString, OsStr},
fs::{self, File, OpenOptions},
io::{self, Write},
os::unix::{
fs::{MetadataExt, OpenOptionsExt},
io::AsRawFd,
process::CommandExt,
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use crate::{
accounting::UtmpxGuard,
accounts::Identity,
config::Config,
error::{Error, ResultExt, io_error},
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use signal_hook::{consts::SIGUSR1, flag};
/// A session family supported by latchd.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionKind {
/// A login shell on the current virtual terminal.
Tty,
/// An X11 desktop session.
X11,
/// A Wayland desktop session.
Wayland,
}
/// A discoverable desktop or script session.
#[derive(Debug, Clone)]
pub struct SessionDescriptor {
/// Session family.
pub kind: SessionKind,
/// User-facing title.
pub title: String,
/// Program and arguments, without shell interpretation.
pub argv: Vec<String>,
/// XDG current-desktop value.
pub current_desktop: Option<String>,
/// XDG session-desktop value.
pub session_desktop: Option<String>,
}
/// A fully materialized session request.
#[derive(Debug, Clone)]
pub struct SessionSpec {
/// Session family.
pub kind: SessionKind,
/// Session title for logs.
pub title: String,
/// Program and arguments.
pub argv: Vec<String>,
/// Explicit child environment.
pub environment: Vec<(String, String)>,
/// Working directory.
pub working_directory: PathBuf,
/// Private stdout/stderr log for graphical sessions.
pub client_log_path: PathBuf,
/// Login shell for TTY fallback.
pub shell: PathBuf,
/// Virtual terminal number.
pub tty: u8,
/// X11 configuration when this is an X11 session.
pub x11: Option<X11Runtime>,
}
/// Runtime X11 parameters passed to a session.
#[derive(Debug, Clone)]
pub struct X11Runtime {
/// X display name.
pub display: String,
/// X server command.
pub server_path: PathBuf,
/// Xauthority command.
pub xauth_path: PathBuf,
/// X server timeout.
pub timeout: Duration,
/// X server log path.
pub log_path: PathBuf,
}
/// Discovers configured sessions in stable directory order.
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
let mut sessions = Vec::new();
scan_desktop_dir(
&config.x11.sessions_path,
SessionKind::X11,
&config.initial_path,
&mut sessions,
);
scan_desktop_dir(
&config.wayland.sessions_path,
SessionKind::Wayland,
&config.initial_path,
&mut sessions,
);
scan_script_dir(&config.x11.scripts_path, SessionKind::X11, &mut sessions);
scan_script_dir(
&config.wayland.scripts_path,
SessionKind::Wayland,
&mut sessions,
);
if sessions.is_empty() || config.environment_switcher.include_tty_shell {
sessions.push(SessionDescriptor {
kind: SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
});
}
sessions
}
/// Checks configured binaries and discovered session entries without starting them.
pub fn preflight(config: &Config) -> Result<(), Error> {
let shell = secure_executable(&config.system_shell, "configured system shell")?;
if !shell.is_file() {
return Err(Error::Config(format!(
"configured system shell is not a regular file: {}",
shell.display()
)));
}
let sessions = discover(config);
for session in &sessions {
if session.kind == SessionKind::Tty {
continue;
}
let Some(program) = session.argv.first() else {
return Err(Error::Config(format!(
"session {} has no executable",
session.title
)));
};
let executable = find_executable(program, &config.initial_path).ok_or_else(|| {
Error::Config(format!(
"session {} executable is not available: {program}",
session.title
))
})?;
secure_executable(&executable, "session executable")?;
}
if sessions
.iter()
.any(|session| session.kind == SessionKind::X11)
{
secure_executable(&config.x11.server_path, "X11 server")?;
secure_executable(&config.x11.xauth_path, "xauth")?;
}
Ok(())
}
fn find_executable(program: &str, path: &str) -> Option<PathBuf> {
let candidate = Path::new(program);
if candidate.is_absolute() {
return candidate.exists().then(|| candidate.to_owned());
}
path.split(':')
.map(|directory| Path::new(directory).join(program))
.find(|candidate| candidate.is_file())
}
fn secure_executable(path: &Path, label: &str) -> Result<PathBuf, Error> {
let resolved =
fs::canonicalize(path).io_context(format!("resolve {label} {}", path.display()))?;
let metadata = fs::symlink_metadata(&resolved)
.io_context(format!("inspect {label} {}", resolved.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"{label} must resolve to a root-owned, non-writable regular file: {}",
path.display()
)));
}
if metadata.mode() & 0o111 == 0 {
return Err(Error::Config(format!(
"{label} is not executable: {}",
path.display()
)));
}
Ok(resolved)
}
/// Builds an explicit environment and process specification for one identity.
pub fn build_spec(
identity: &Identity,
config: &Config,
descriptor: &SessionDescriptor,
) -> Result<SessionSpec, Error> {
let mut environment = inherited_safe_environment();
insert_env(
&mut environment,
"HOME",
identity.home.to_string_lossy().into_owned(),
);
insert_env(
&mut environment,
"SHELL",
identity.shell.to_string_lossy().into_owned(),
);
insert_env(&mut environment, "USER", identity.username.clone());
insert_env(&mut environment, "LOGNAME", identity.username.clone());
insert_env(&mut environment, "PATH", config.initial_path.clone());
insert_env(
&mut environment,
"PWD",
identity.home.to_string_lossy().into_owned(),
);
insert_env(&mut environment, "XDG_SESSION_CLASS", "user".into());
insert_env(
&mut environment,
"XDG_SESSION_TYPE",
match descriptor.kind {
SessionKind::Tty => "tty",
SessionKind::X11 => "x11",
SessionKind::Wayland => "wayland",
}
.into(),
);
insert_env(&mut environment, "XDG_SEAT", "seat0".into());
insert_env(&mut environment, "XDG_VTNR", config.tty.to_string());
insert_env(
&mut environment,
"XDG_RUNTIME_DIR",
format!("/run/user/{}", identity.uid),
);
insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into());
insert_env(
&mut environment,
"XDG_CONFIG_HOME",
identity.home.join(".config").to_string_lossy().into_owned(),
);
insert_env(
&mut environment,
"XDG_CACHE_HOME",
identity.home.join(".cache").to_string_lossy().into_owned(),
);
insert_env(
&mut environment,
"XDG_DATA_HOME",
identity
.home
.join(".local/share")
.to_string_lossy()
.into_owned(),
);
insert_env(
&mut environment,
"XDG_STATE_HOME",
identity
.home
.join(".local/state")
.to_string_lossy()
.into_owned(),
);
insert_env(
&mut environment,
"XDG_DATA_DIRS",
"/usr/local/share:/usr/share".into(),
);
insert_env(&mut environment, "XDG_CONFIG_DIRS", "/etc/xdg".into());
let x11 = if descriptor.kind == SessionKind::X11 {
insert_env(&mut environment, "DISPLAY", config.x11.display.clone());
Some(X11Runtime {
display: config.x11.display.clone(),
server_path: config.x11.server_path.clone(),
xauth_path: config.x11.xauth_path.clone(),
timeout: Duration::from_secs(config.x11.server_timeout_secs as u64),
log_path: config.x11.server_log_path.clone(),
})
} else {
None
};
if let Some(value) = descriptor.current_desktop.as_deref() {
insert_env(&mut environment, "XDG_CURRENT_DESKTOP", value.to_owned());
}
if let Some(value) = descriptor.session_desktop.as_deref() {
insert_env(&mut environment, "XDG_SESSION_DESKTOP", value.to_owned());
}
let mut argv = if descriptor.kind == SessionKind::Tty {
Vec::new()
} else if descriptor.argv.is_empty() {
return Err(Error::Session(format!(
"session {} has no executable",
descriptor.title
)));
} else {
descriptor.argv.clone()
};
if let Some(program) = argv.first_mut() {
if !Path::new(program).is_absolute() {
let resolved = find_executable(program, &config.initial_path).ok_or_else(|| {
Error::Session(format!(
"session {} executable is not available: {program}",
descriptor.title
))
})?;
*program = resolved.to_string_lossy().into_owned();
}
}
if !identity.shell.is_absolute() {
return Err(Error::Session(
"account shell must be an absolute path".into(),
));
}
Ok(SessionSpec {
kind: descriptor.kind,
title: descriptor.title.clone(),
argv,
environment,
working_directory: identity.home.clone(),
client_log_path: config.client_log_path.clone(),
shell: identity.shell.clone(),
tty: config.tty,
x11,
})
}
/// Runs a user session and owns all child process cleanup.
pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
let home_metadata = fs::symlink_metadata(&identity.home)
.map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?;
if !home_metadata.is_dir()
|| (home_metadata.uid() != identity.uid && home_metadata.uid() != 0)
|| home_metadata.mode() & 0o022 != 0
{
return Err(Error::Session(format!(
"home directory is missing or insecure: {}",
identity.home.display()
)));
}
if spec.kind == SessionKind::X11 {
return run_x11(identity, spec);
}
let mut command = user_command(identity, spec)?;
let mut child = command.spawn().io_context("spawn user session")?;
let mut accounting = match UtmpxGuard::start(&identity.username, spec.tty, child.id() as _) {
Ok(guard) => guard,
Err(error) => {
let _ = terminate_child(&mut child);
return Err(error);
}
};
let status = match child.wait().io_context("wait for user session") {
Ok(status) => status,
Err(error) => {
let _ = terminate_child(&mut child);
return Err(error);
}
};
if !status.success() {
let _ = accounting.close();
return Err(Error::Session(format!(
"{} exited with {status}",
spec.title
)));
}
accounting.close()?;
Ok(())
}
fn user_command(identity: &Identity, spec: &SessionSpec) -> Result<Command, Error> {
let (program, args): (&OsStr, &[String]) = if spec.kind == SessionKind::Tty {
(spec.shell.as_os_str(), &[] as &[String])
} else {
let program = spec
.argv
.first()
.ok_or_else(|| Error::Session("empty session argv".into()))?;
(OsStr::new(program), &spec.argv[1..])
};
let mut command = Command::new(program);
command
.args(args)
.current_dir(&spec.working_directory)
.env_clear()
.envs(spec.environment.iter().map(|(key, value)| (key, value)))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if spec.kind != SessionKind::Tty {
let output = private_log(&spec.client_log_path)?;
let error = output.try_clone().io_context("clone client log")?;
command
.stdout(Stdio::from(output))
.stderr(Stdio::from(error));
}
install_user_credentials(&mut command, identity, spec.kind == SessionKind::Tty)?;
Ok(command)
}
fn install_user_credentials(
command: &mut Command,
identity: &Identity,
controlling_tty: bool,
) -> Result<(), Error> {
let username = CString::new(identity.username.as_str())
.map_err(|_| Error::Session("username contains NUL".into()))?;
let uid = identity.uid;
let gid = identity.gid;
unsafe {
command.pre_exec(move || {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
if controlling_tty && libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 {
return Err(io::Error::last_os_error());
}
if libc::initgroups(username.as_ptr(), gid) == -1 {
return Err(io::Error::last_os_error());
}
if libc::setgid(gid) == -1 || libc::setuid(uid) == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
Ok(())
}
fn run_x11(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
let runtime = spec
.x11
.as_ref()
.ok_or_else(|| Error::Session("missing X11 runtime configuration".into()))?;
let authority = create_xauthority(identity, runtime)?;
let ready = Arc::new(AtomicBool::new(false));
flag::register(SIGUSR1, Arc::clone(&ready))
.map_err(|error| Error::Session(format!("register X11 readiness signal: {error}")))?;
let x_log = private_log(&runtime.log_path)?;
let x_log_err = x_log.try_clone().io_context("clone Xorg log")?;
let mut server = Command::new(&runtime.server_path);
let vt = format!("vt{}", spec.tty);
server
.args([runtime.display.as_str(), vt.as_str()])
.arg("-auth")
.arg(authority.path())
.stdin(Stdio::null())
.stdout(Stdio::from(x_log))
.stderr(Stdio::from(x_log_err));
unsafe {
server.pre_exec(|| {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut xserver = server.spawn().io_context("spawn X server")?;
let deadline = Instant::now() + runtime.timeout;
while !ready.load(Ordering::Acquire) {
if let Some(status) = xserver.try_wait().io_context("poll X server")? {
return Err(Error::Session(format!("X server exited with {status}")));
}
if runtime.timeout != Duration::ZERO && Instant::now() >= deadline {
terminate_child(&mut xserver)?;
return Err(Error::Session("X server readiness timeout".into()));
}
thread::sleep(Duration::from_millis(20));
}
let mut environment = spec.environment.clone();
insert_env(
&mut environment,
"XAUTHORITY",
authority.path().to_string_lossy().into_owned(),
);
let mut client_spec = spec.clone();
client_spec.environment = environment;
let result = user_command(identity, &client_spec).and_then(|mut client| {
let mut child = client.spawn().io_context("spawn X11 client")?;
match child.wait().io_context("wait for X11 client") {
Ok(status) => Ok(status),
Err(error) => {
let _ = terminate_child(&mut child);
Err(error)
}
}
});
let cleanup = terminate_child(&mut xserver);
cleanup?;
let result = result?;
if !result.success() {
return Err(Error::Session(format!("X11 client exited with {result}")));
}
Ok(())
}
struct AuthorityFile(PathBuf);
impl AuthorityFile {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for AuthorityFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn create_xauthority(identity: &Identity, runtime: &X11Runtime) -> Result<AuthorityFile, Error> {
let mut cookie = [0u8; 16];
rand::rng().fill_bytes(&mut cookie);
let path = identity
.home
.join(format!(".Xauthority.latchd-{}", std::process::id()));
let file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.io_context(format!("create {}", path.display()))?;
let owner = unsafe { libc::fchown(file.as_raw_fd(), identity.uid, identity.gid) };
if owner == -1 {
let error = io::Error::last_os_error();
let _ = fs::remove_file(&path);
return Err(io_error(format!("chown {}", path.display()), error));
}
drop(file);
let mut command = Command::new(&runtime.xauth_path);
command
.arg("-f")
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Err(error) = install_user_credentials(&mut command, identity, false) {
let _ = fs::remove_file(&path);
return Err(error);
}
let mut child = match command.spawn().io_context("spawn xauth") {
Ok(child) => child,
Err(error) => {
let _ = fs::remove_file(&path);
return Err(error);
}
};
if let Some(mut stdin) = child.stdin.take() {
if let Err(error) = writeln!(stdin, "add {} . {}", runtime.display, hex_cookie(&cookie))
.io_context("write xauth command")
{
let _ = terminate_child(&mut child);
let _ = fs::remove_file(&path);
return Err(error);
}
}
let status = match child.wait().io_context("wait for xauth") {
Ok(status) => status,
Err(error) => {
let _ = fs::remove_file(&path);
return Err(error);
}
};
if !status.success() {
let _ = fs::remove_file(&path);
return Err(Error::Session(format!("xauth exited with {status}")));
}
Ok(AuthorityFile(path))
}
fn hex_cookie(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn terminate_child(child: &mut Child) -> Result<(), Error> {
if child.try_wait().io_context("poll child")?.is_some() {
return Ok(());
}
unsafe {
libc::kill(-(child.id() as i32), libc::SIGTERM);
}
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if child
.try_wait()
.io_context("wait for child termination")?
.is_some()
{
return Ok(());
}
thread::sleep(Duration::from_millis(25));
}
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
child.wait().io_context("reap killed child")?;
Ok(())
}
fn private_log(path: &Path) -> Result<File, Error> {
const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024;
let parent = path
.parent()
.ok_or_else(|| Error::Config(format!("log path has no parent: {}", path.display())))?;
let metadata = fs::symlink_metadata(parent)
.io_context(format!("inspect log directory {}", parent.display()))?;
if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"log directory must be root-owned and not group-writable: {}",
parent.display()
)));
}
let file = OpenOptions::new()
.create(true)
.append(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600)
.open(path)
.io_context(format!("open log {}", path.display()))?;
if file
.metadata()
.io_context(format!("inspect log {}", path.display()))?
.len()
> MAX_LOG_BYTES
{
return Err(Error::Config(format!(
"log exceeds {MAX_LOG_BYTES} bytes: {}",
path.display()
)));
}
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))
.io_context(format!("secure log {}", path.display()))?;
Ok(file)
}
fn inherited_safe_environment() -> Vec<(String, String)> {
std::env::vars()
.filter(|(key, _)| key == "TERM" || key == "LANG" || key.starts_with("LC_"))
.collect()
}
fn insert_env(environment: &mut Vec<(String, String)>, key: &str, value: String) {
if let Some((_, existing)) = environment.iter_mut().find(|(name, _)| name == key) {
*existing = value;
} else {
environment.push((key.into(), value));
}
}
fn scan_desktop_dir(
path: &Path,
kind: SessionKind,
session_path: &str,
output: &mut Vec<SessionDescriptor>,
) {
let Ok(entries) = fs::read_dir(path) else {
return;
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if entry.path().extension().and_then(|value| value.to_str()) != Some("desktop") {
continue;
}
let Ok(metadata) = fs::symlink_metadata(entry.path()) else {
continue;
};
if !metadata.is_file() || metadata.mode() & 0o022 != 0 || metadata.uid() != 0 {
continue;
}
let Ok(raw) = fs::read_to_string(entry.path()) else {
continue;
};
let fields = desktop_fields(&raw);
if fields
.get("Hidden")
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
|| fields
.get("NoDisplay")
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
{
continue;
}
if fields
.get("Type")
.is_some_and(|value| !value.eq_ignore_ascii_case("Application"))
{
continue;
}
if let Some(try_exec) = fields.get("TryExec")
&& find_executable(try_exec, session_path).is_none()
{
continue;
}
let Some(exec) = fields.get("Exec").and_then(|value| parse_argv(value).ok()) else {
continue;
};
if exec.is_empty() {
continue;
}
let title = fields
.get("Name")
.cloned()
.unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned());
let (current_desktop, session_desktop) = fields
.get("DesktopNames")
.map(|value| {
(
Some(value.replace(';', ":")),
value.split(';').next().map(str::to_owned),
)
})
.unwrap_or((None, None));
output.push(SessionDescriptor {
kind,
title,
argv: exec,
current_desktop,
session_desktop,
});
}
}
fn scan_script_dir(path: &Path, kind: SessionKind, output: &mut Vec<SessionDescriptor>) {
let Ok(entries) = fs::read_dir(path) else {
return;
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if !metadata.is_file()
|| metadata.mode() & 0o111 == 0
|| metadata.mode() & 0o022 != 0
|| metadata.uid() != 0
{
continue;
}
let file_name = entry.file_name();
let Some(title) = file_name.to_str() else {
continue;
};
output.push(SessionDescriptor {
kind,
title: title.into(),
argv: vec![path.to_string_lossy().into_owned()],
current_desktop: None,
session_desktop: None,
});
}
}
fn desktop_fields(raw: &str) -> std::collections::HashMap<String, String> {
let mut fields = std::collections::HashMap::new();
let mut in_desktop = false;
for line in raw.lines().map(str::trim) {
if line.starts_with('[') && line.ends_with(']') {
in_desktop = line == "[Desktop Entry]";
continue;
}
if !in_desktop || line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
fields.insert(key.trim().into(), value.trim().into());
}
}
fields
}
fn parse_argv(value: &str) -> Result<Vec<String>, Error> {
let mut output = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
for character in value.chars() {
if escaped {
current.push(character);
escaped = false;
continue;
}
match (quote, character) {
(_, '\\') => escaped = true,
(Some(expected), character) if expected == character => quote = None,
(None, '\'' | '"') => quote = Some(character),
(None, character) if character.is_whitespace() => {
if !current.is_empty() {
output.push(std::mem::take(&mut current));
}
}
(_, character) => current.push(character),
}
}
if escaped || quote.is_some() {
return Err(Error::Session("unterminated session command quote".into()));
}
if !current.is_empty() {
output.push(current);
}
if output.iter().any(|argument| argument.contains('%')) {
return Err(Error::Session(
"desktop Exec field codes are not supported".into(),
));
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_width::UnicodeWidthStr;
#[test]
fn parses_argv_without_shell_expansion() {
let argv = parse_argv("/bin/example --title 'hello world'").expect("valid argv");
assert_eq!(argv, ["/bin/example", "--title", "hello world"]);
}
#[test]
fn rejects_field_codes() {
assert!(parse_argv("foo %f").is_err());
}
#[test]
fn display_width_is_not_byte_length() {
assert_eq!(UnicodeWidthStr::width("界"), 2);
}
#[test]
fn environment_does_not_copy_display_or_xauthority() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "test".into(),
argv: vec!["/bin/true".into()],
current_desktop: None,
session_desktop: None,
};
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
assert!(
spec.environment
.iter()
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
);
}
}