feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
17
crates/latchd-core/Cargo.toml
Normal file
17
crates/latchd-core/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "latchd-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
rand = "0.9"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
signal-hook = "0.3"
|
||||
thiserror = "2.0"
|
||||
toml = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
unicode-width = "0.2"
|
||||
97
crates/latchd-core/src/accounting.rs
Normal file
97
crates/latchd-core/src/accounting.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Linux UTMPX accounting with idempotent RAII cleanup.
|
||||
|
||||
use std::{io, mem, os::raw::c_char, ptr};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// A live user-process record in UTMPX.
|
||||
pub struct UtmpxGuard {
|
||||
record: libc::utmpx,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl UtmpxGuard {
|
||||
/// Adds a user-process record for a child and returns its cleanup guard.
|
||||
pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result<Self, Error> {
|
||||
let mut record: libc::utmpx = unsafe { mem::zeroed() };
|
||||
record.ut_type = libc::USER_PROCESS;
|
||||
record.ut_pid = pid;
|
||||
write_field(&mut record.ut_line, &format!("tty{tty}"));
|
||||
write_field(&mut record.ut_id, &format!("l{tty}"));
|
||||
write_field(&mut record.ut_user, username);
|
||||
write_timestamp(&mut record);
|
||||
put_record(&record, "write UTMPX login record")?;
|
||||
Ok(Self {
|
||||
record,
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the corresponding dead-process record. Calling it twice is safe.
|
||||
pub fn close(&mut self) -> Result<(), Error> {
|
||||
if self.closed {
|
||||
return Ok(());
|
||||
}
|
||||
self.record.ut_type = libc::DEAD_PROCESS;
|
||||
self.record.ut_user.fill(0);
|
||||
self.record.ut_host.fill(0);
|
||||
write_timestamp(&mut self.record);
|
||||
put_record(&self.record, "write UTMPX logout record")?;
|
||||
self.closed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UtmpxGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_field<const N: usize>(field: &mut [c_char; N], value: &str) {
|
||||
field.fill(0);
|
||||
for (destination, byte) in field.iter_mut().zip(value.as_bytes().iter().copied()) {
|
||||
*destination = byte as c_char;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_timestamp(record: &mut libc::utmpx) {
|
||||
let seconds = unsafe { libc::time(ptr::null_mut()) };
|
||||
record.ut_tv.tv_sec = seconds as _;
|
||||
record.ut_tv.tv_usec = 0;
|
||||
}
|
||||
|
||||
fn put_record(record: &libc::utmpx, operation: &'static str) -> Result<(), Error> {
|
||||
unsafe {
|
||||
libc::setutxent();
|
||||
let result = libc::pututxline(record);
|
||||
libc::endutxent();
|
||||
if result.is_null() {
|
||||
return Err(Error::Session(format!(
|
||||
"{operation}: {}",
|
||||
io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_writer_truncates_without_panicking() {
|
||||
let mut field = [0 as c_char; 4];
|
||||
write_field(&mut field, "abcdef");
|
||||
assert_eq!(
|
||||
field,
|
||||
[
|
||||
b'a' as c_char,
|
||||
b'b' as c_char,
|
||||
b'c' as c_char,
|
||||
b'd' as c_char
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
237
crates/latchd-core/src/accounts.rs
Normal file
237
crates/latchd-core/src/accounts.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! NSS account discovery and advisory faillock status.
|
||||
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::AccountsConfig,
|
||||
error::{Error, ResultExt},
|
||||
};
|
||||
|
||||
/// An account that can be offered by the login screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
/// Canonical username.
|
||||
pub username: String,
|
||||
/// Numeric user ID.
|
||||
pub uid: u32,
|
||||
/// Primary group ID.
|
||||
pub gid: u32,
|
||||
/// Home directory.
|
||||
pub home: PathBuf,
|
||||
/// Login shell.
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
/// A non-authoritative account lockout summary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FaillockStatus {
|
||||
/// Whether the faillock helper returned a usable result.
|
||||
pub available: bool,
|
||||
/// Number of observed failed attempts.
|
||||
pub failures: u32,
|
||||
/// Configured failure threshold, when discoverable.
|
||||
pub max_tries: Option<u32>,
|
||||
/// Remaining attempts before the advisory threshold.
|
||||
pub remaining: Option<u32>,
|
||||
/// Whether the advisory threshold has been reached.
|
||||
pub locked_out: bool,
|
||||
}
|
||||
|
||||
/// Enumerates interactive accounts using NSS and deterministic ordering.
|
||||
pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
|
||||
let mut accounts = Vec::new();
|
||||
unsafe {
|
||||
libc::setpwent();
|
||||
loop {
|
||||
let entry = libc::getpwent();
|
||||
if entry.is_null() {
|
||||
break;
|
||||
}
|
||||
let passwd = &*entry;
|
||||
let Some(username) = c_string(passwd.pw_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(home) = c_path(passwd.pw_dir) else {
|
||||
continue;
|
||||
};
|
||||
let Some(shell) = c_path(passwd.pw_shell) else {
|
||||
continue;
|
||||
};
|
||||
if !eligible(&username, passwd.pw_uid, &home, &shell, config) {
|
||||
continue;
|
||||
}
|
||||
accounts.push(Identity {
|
||||
username,
|
||||
uid: passwd.pw_uid,
|
||||
gid: passwd.pw_gid,
|
||||
home,
|
||||
shell,
|
||||
});
|
||||
}
|
||||
libc::endpwent();
|
||||
}
|
||||
accounts.sort_by(|left, right| left.username.cmp(&right.username));
|
||||
accounts
|
||||
}
|
||||
|
||||
/// Runs the configured helper and parses its stable numeric output.
|
||||
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
|
||||
let helper = &config.faillock_path;
|
||||
if !helper.is_absolute() {
|
||||
return Err(Error::Config("faillock_path must be absolute".into()));
|
||||
}
|
||||
if !trusted_helper(helper) {
|
||||
return Ok(FaillockStatus::default());
|
||||
}
|
||||
let mut child = Command::new(helper)
|
||||
.args(["--user", username, "--legacy-output"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.io_context("start faillock query")?;
|
||||
let deadline = Instant::now() + Duration::from_millis(750);
|
||||
loop {
|
||||
if child
|
||||
.try_wait()
|
||||
.io_context("poll faillock query")?
|
||||
.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok(FaillockStatus::default());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let output = child.wait_with_output().io_context("read faillock query")?;
|
||||
if !output.status.success() {
|
||||
return Ok(FaillockStatus::default());
|
||||
}
|
||||
let failures = parse_failure_count(&output.stdout);
|
||||
let max_tries = read_deny(&config.faillock_config);
|
||||
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
||||
Ok(FaillockStatus {
|
||||
available: true,
|
||||
failures,
|
||||
max_tries,
|
||||
remaining,
|
||||
locked_out: remaining.is_some_and(|value| value == 0),
|
||||
})
|
||||
}
|
||||
|
||||
fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool {
|
||||
if uid < config.minimum_uid || config.excluded_users.iter().any(|value| value == username) {
|
||||
return false;
|
||||
}
|
||||
if shell == Path::new("/bin/false")
|
||||
|| shell == Path::new("/usr/sbin/nologin")
|
||||
|| shell == Path::new("/sbin/nologin")
|
||||
|| !shell.is_absolute()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(metadata) = std::fs::symlink_metadata(home) else {
|
||||
return false;
|
||||
};
|
||||
metadata.is_dir()
|
||||
&& (metadata.uid() == uid || metadata.uid() == 0)
|
||||
&& metadata.mode() & 0o022 == 0
|
||||
}
|
||||
|
||||
fn c_string(pointer: *const libc::c_char) -> Option<String> {
|
||||
(!pointer.is_null())
|
||||
.then(|| {
|
||||
unsafe { CStr::from_ptr(pointer) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn c_path(pointer: *const libc::c_char) -> Option<PathBuf> {
|
||||
c_string(pointer)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn parse_failure_count(bytes: &[u8]) -> u32 {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().next())
|
||||
.find_map(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_deny(path: &Path) -> Option<u32> {
|
||||
if !trusted_helper(path) {
|
||||
return None;
|
||||
}
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
contents.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
(key.trim() == "deny")
|
||||
.then(|| value.trim().parse().ok())
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn trusted_helper(path: &Path) -> bool {
|
||||
let Ok(metadata) = std::fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
metadata.is_file()
|
||||
&& metadata.uid() == 0
|
||||
&& metadata.mode() & 0o022 == 0
|
||||
&& (metadata.mode() & 0o111 != 0 || path.ends_with("faillock.conf"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_failure_count() {
|
||||
assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4);
|
||||
assert_eq!(parse_failure_count(b""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_non_login_shells_and_untrusted_homes() {
|
||||
let config = AccountsConfig::default();
|
||||
assert!(!eligible(
|
||||
"service",
|
||||
1000,
|
||||
Path::new("/tmp"),
|
||||
Path::new("/bin/false"),
|
||||
&config,
|
||||
));
|
||||
assert!(!eligible(
|
||||
"service",
|
||||
1000,
|
||||
Path::new("/definitely/missing"),
|
||||
Path::new("/bin/sh"),
|
||||
&config,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unavailable_faillock_helper_is_advisory() {
|
||||
let config = AccountsConfig {
|
||||
faillock_path: PathBuf::from("/bin/false"),
|
||||
..AccountsConfig::default()
|
||||
};
|
||||
let status = faillock_status("alice", &config).expect("false helper should be handled");
|
||||
assert_eq!(status, FaillockStatus::default());
|
||||
}
|
||||
}
|
||||
380
crates/latchd-core/src/config.rs
Normal file
380
crates/latchd-core/src/config.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, ResultExt};
|
||||
|
||||
/// Validated application configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Virtual terminal number used by the service.
|
||||
pub tty: u8,
|
||||
/// Main log file.
|
||||
pub main_log_path: PathBuf,
|
||||
/// Per-session log file.
|
||||
pub client_log_path: PathBuf,
|
||||
/// PAM service name.
|
||||
pub pam_service: String,
|
||||
/// Default shell used only when the account has no shell value.
|
||||
pub system_shell: PathBuf,
|
||||
/// Session PATH.
|
||||
pub initial_path: String,
|
||||
/// X11 settings.
|
||||
pub x11: X11Config,
|
||||
/// Wayland settings.
|
||||
pub wayland: WaylandConfig,
|
||||
/// Environment switcher settings.
|
||||
pub environment_switcher: SwitcherConfig,
|
||||
/// Power actions shown by the login screen.
|
||||
pub power_controls: PowerControls,
|
||||
/// Account enumeration and advisory lockout settings.
|
||||
pub accounts: AccountsConfig,
|
||||
/// TUI-only background, animation, and notification settings.
|
||||
pub visual: VisualConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tty: 2,
|
||||
main_log_path: PathBuf::from("/var/log/latchd.log"),
|
||||
client_log_path: PathBuf::from("/var/log/latchd.client.log"),
|
||||
pam_service: "latchd".into(),
|
||||
system_shell: PathBuf::from("/bin/sh"),
|
||||
initial_path: "/usr/local/sbin:/usr/local/bin:/usr/bin".into(),
|
||||
x11: X11Config::default(),
|
||||
wayland: WaylandConfig::default(),
|
||||
environment_switcher: SwitcherConfig::default(),
|
||||
power_controls: PowerControls::default(),
|
||||
accounts: AccountsConfig::default(),
|
||||
visual: VisualConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Account discovery and faillock settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct AccountsConfig {
|
||||
/// Minimum UID shown by automatic account discovery.
|
||||
pub minimum_uid: u32,
|
||||
/// Usernames excluded from the chooser.
|
||||
pub excluded_users: Vec<String>,
|
||||
/// Whether the chooser lists NSS accounts.
|
||||
pub enumerate: bool,
|
||||
/// Absolute faillock helper path.
|
||||
pub faillock_path: PathBuf,
|
||||
/// faillock configuration used to read the deny threshold.
|
||||
pub faillock_config: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for AccountsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
minimum_uid: 1000,
|
||||
excluded_users: Vec::new(),
|
||||
enumerate: true,
|
||||
faillock_path: PathBuf::from("/usr/sbin/faillock"),
|
||||
faillock_config: PathBuf::from("/etc/security/faillock.conf"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual effects used only by the terminal login screen.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct VisualConfig {
|
||||
/// Optional root-owned image used as a low-contrast terminal wallpaper.
|
||||
pub wallpaper_path: Option<PathBuf>,
|
||||
/// Enable the wallpaper and procedural background layer.
|
||||
pub background_enabled: bool,
|
||||
/// Advance animated background and spinner frames.
|
||||
pub animations: bool,
|
||||
/// Show transient in-TUI notifications for user-visible events.
|
||||
pub notifications: bool,
|
||||
/// Disable motion while retaining static visual styling.
|
||||
pub reduced_motion: bool,
|
||||
/// Maximum wallpaper file size accepted before decoding.
|
||||
pub max_wallpaper_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for VisualConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wallpaper_path: None,
|
||||
background_enabled: true,
|
||||
animations: true,
|
||||
notifications: true,
|
||||
reduced_motion: false,
|
||||
max_wallpaper_bytes: 8 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// X11 session configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct X11Config {
|
||||
/// X display name.
|
||||
pub display: String,
|
||||
/// X server executable.
|
||||
pub server_path: PathBuf,
|
||||
/// Xauthority executable.
|
||||
pub xauth_path: PathBuf,
|
||||
/// X server startup timeout.
|
||||
pub server_timeout_secs: u16,
|
||||
/// X server log.
|
||||
pub server_log_path: PathBuf,
|
||||
/// Desktop-entry directory.
|
||||
pub sessions_path: PathBuf,
|
||||
/// Custom script directory.
|
||||
pub scripts_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for X11Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display: ":1".into(),
|
||||
server_path: "/usr/bin/X".into(),
|
||||
xauth_path: "/usr/bin/xauth".into(),
|
||||
server_timeout_secs: 60,
|
||||
server_log_path: "/var/log/latchd.xorg.log".into(),
|
||||
sessions_path: "/usr/share/xsessions".into(),
|
||||
scripts_path: "/etc/latchd/wms".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayland session configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct WaylandConfig {
|
||||
/// Desktop-entry directory.
|
||||
pub sessions_path: PathBuf,
|
||||
/// Custom script directory.
|
||||
pub scripts_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for WaylandConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sessions_path: "/usr/share/wayland-sessions".into(),
|
||||
scripts_path: "/etc/latchd/wayland".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session switcher settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SwitcherConfig {
|
||||
/// Include the fallback TTY shell in the chooser.
|
||||
pub include_tty_shell: bool,
|
||||
/// Maximum visible title width.
|
||||
pub max_display_length: usize,
|
||||
}
|
||||
|
||||
impl Default for SwitcherConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_tty_shell: false,
|
||||
max_display_length: 18,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Power actions available to the login screen.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PowerControls {
|
||||
/// Built-in or administrator-defined actions.
|
||||
pub entries: Vec<PowerAction>,
|
||||
}
|
||||
|
||||
/// A power action represented as an executable argv vector.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PowerAction {
|
||||
/// Displayed key, such as `F1`.
|
||||
pub key: String,
|
||||
/// Displayed label.
|
||||
pub hint: String,
|
||||
/// Executable path.
|
||||
pub program: PathBuf,
|
||||
/// Arguments passed without shell interpretation.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Loads a configuration file over the built-in defaults.
|
||||
pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
|
||||
let mut config = Config::default();
|
||||
if let Some(variables_path) = variables {
|
||||
let values = load_variables(variables_path)?;
|
||||
if let Some(config_path) = path {
|
||||
let raw = fs::read_to_string(config_path)
|
||||
.io_context(format!("read {}", config_path.display()))?;
|
||||
let expanded = resolve_variables(&raw, &values);
|
||||
config = toml::from_str(&expanded).map_err(|source| Error::Toml {
|
||||
path: config_path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
} else if let Some(config_path) = path {
|
||||
let raw = fs::read_to_string(config_path)
|
||||
.io_context(format!("read {}", config_path.display()))?;
|
||||
config = toml::from_str(&raw).map_err(|source| Error::Toml {
|
||||
path: config_path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
validate(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn load_variables(path: &Path) -> Result<HashMap<String, String>, Error> {
|
||||
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
|
||||
let values: HashMap<String, toml::Value> =
|
||||
toml::from_str(&raw).map_err(|source| Error::Toml {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
Ok(values
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, value.to_string().trim_matches('"').to_owned()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn resolve_variables(input: &str, variables: &HashMap<String, String>) -> String {
|
||||
let mut keys: Vec<_> = variables.keys().collect();
|
||||
keys.sort_by_key(|key| std::cmp::Reverse(key.len()));
|
||||
keys.into_iter().fold(input.to_owned(), |result, key| {
|
||||
result.replace(&format!("${key}"), &variables[key])
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates values that must be safe before starting the login screen.
|
||||
pub fn validate(config: &Config) -> Result<(), Error> {
|
||||
if config.tty == 0 || config.tty > 63 {
|
||||
return Err(Error::Config("tty must be between 1 and 63".into()));
|
||||
}
|
||||
if config.pam_service.is_empty() || config.pam_service.contains('\0') {
|
||||
return Err(Error::Config("pam_service must be a non-empty name".into()));
|
||||
}
|
||||
if config.initial_path.is_empty() {
|
||||
return Err(Error::Config("initial_path must not be empty".into()));
|
||||
}
|
||||
if config
|
||||
.initial_path
|
||||
.split(':')
|
||||
.any(|entry| entry.is_empty() || !Path::new(entry).is_absolute())
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"initial_path must contain only non-empty absolute directories".into(),
|
||||
));
|
||||
}
|
||||
if config.environment_switcher.max_display_length == 0 {
|
||||
return Err(Error::Config(
|
||||
"environment_switcher.max_display_length must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
for (name, path) in [
|
||||
("main_log_path", &config.main_log_path),
|
||||
("client_log_path", &config.client_log_path),
|
||||
("system_shell", &config.system_shell),
|
||||
("x11.server_path", &config.x11.server_path),
|
||||
("x11.xauth_path", &config.x11.xauth_path),
|
||||
("x11.server_log_path", &config.x11.server_log_path),
|
||||
("accounts.faillock_path", &config.accounts.faillock_path),
|
||||
("accounts.faillock_config", &config.accounts.faillock_config),
|
||||
] {
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Config(format!("{name} must be an absolute path")));
|
||||
}
|
||||
}
|
||||
if config.visual.max_wallpaper_bytes == 0
|
||||
|| config.visual.max_wallpaper_bytes > 64 * 1024 * 1024
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"visual.max_wallpaper_bytes must be between 1 and 67108864".into(),
|
||||
));
|
||||
}
|
||||
if let Some(path) = &config.visual.wallpaper_path {
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Config(
|
||||
"visual.wallpaper_path must be an absolute path".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if config.x11.display.trim().is_empty() || config.x11.server_timeout_secs == 0 {
|
||||
return Err(Error::Config(
|
||||
"x11 display must be non-empty and server_timeout_secs must be greater than zero"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
for action in &config.power_controls.entries {
|
||||
let valid_key = action
|
||||
.key
|
||||
.strip_prefix('F')
|
||||
.and_then(|number| number.parse::<u8>().ok())
|
||||
.is_some_and(|number| (1..=12).contains(&number));
|
||||
if !valid_key || action.hint.is_empty() || !action.program.is_absolute() {
|
||||
return Err(Error::Config(format!(
|
||||
"power action {} must use F1-F12, an absolute program path, and a non-empty label",
|
||||
action.key
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rejects a privileged configuration source that can be replaced by another user.
|
||||
pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
|
||||
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"{label} must be a root-owned, non-group-writable regular file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variables_are_replaced_longest_first() {
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("A".into(), "short".into());
|
||||
vars.insert("AB".into(), "long".into());
|
||||
assert_eq!(resolve_variables("$AB/$A", &vars), "long/short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_valid() {
|
||||
validate(&Config::default()).expect("defaults are static and valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_rejected() {
|
||||
assert!(toml::from_str::<Config>("unexpected = true").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_entries_are_rejected() {
|
||||
let config = Config {
|
||||
initial_path: "relative:/usr/bin".into(),
|
||||
..Config::default()
|
||||
};
|
||||
assert!(validate(&config).is_err());
|
||||
}
|
||||
}
|
||||
68
crates/latchd-core/src/error.rs
Normal file
68
crates/latchd-core/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::io;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// The application-wide error type.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// A command-line or configuration value was invalid.
|
||||
#[error("configuration: {0}")]
|
||||
Config(String),
|
||||
/// A filesystem operation failed.
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
/// The operation being performed.
|
||||
context: String,
|
||||
/// The underlying error.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
/// TOML could not be decoded.
|
||||
#[error("parse {path}: {source}")]
|
||||
Toml {
|
||||
/// The file being parsed.
|
||||
path: String,
|
||||
/// The parser error.
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
/// PAM rejected or could not complete an operation.
|
||||
#[error("PAM {operation} failed with status {status}: {message}")]
|
||||
Pam {
|
||||
/// The PAM operation.
|
||||
operation: &'static str,
|
||||
/// The numeric PAM status.
|
||||
status: i32,
|
||||
/// The human-readable PAM message.
|
||||
message: String,
|
||||
},
|
||||
/// A session process failed.
|
||||
#[error("session: {0}")]
|
||||
Session(String),
|
||||
/// The terminal could not be initialized or restored.
|
||||
#[error("terminal: {0}")]
|
||||
Terminal(String),
|
||||
/// An authentication worker request was cancelled or disconnected.
|
||||
#[error("authentication worker: {0}")]
|
||||
Worker(String),
|
||||
}
|
||||
|
||||
/// Adds a stable operation label to an I/O error.
|
||||
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
||||
Error::Io {
|
||||
context: context.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a failed result into an application error with context.
|
||||
pub trait ResultExt<T> {
|
||||
/// Adds context to an I/O operation.
|
||||
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
|
||||
}
|
||||
|
||||
impl<T> ResultExt<T> for io::Result<T> {
|
||||
fn io_context(self, context: impl Into<String>) -> Result<T, Error> {
|
||||
self.map_err(|source| io_error(context, source))
|
||||
}
|
||||
}
|
||||
15
crates/latchd-core/src/lib.rs
Normal file
15
crates/latchd-core/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Core configuration, account, session, and accounting contracts for latchd.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
/// Linux UTMPX accounting primitives.
|
||||
pub mod accounting;
|
||||
/// NSS account discovery and faillock status.
|
||||
pub mod accounts;
|
||||
/// Validated application configuration.
|
||||
pub mod config;
|
||||
/// Structured application errors.
|
||||
pub mod error;
|
||||
/// Session discovery and process lifecycle.
|
||||
pub mod session;
|
||||
861
crates/latchd-core/src/session.rs
Normal file
861
crates/latchd-core/src/session.rs
Normal file
@@ -0,0 +1,861 @@
|
||||
//! 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 signal_hook::{consts::SIGUSR1, flag};
|
||||
|
||||
/// A session family supported by latchd.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user