//! Terminal login manager entry point and service loop. #![deny(unsafe_op_in_unsafe_fn)] #![warn(missing_docs)] use std::{ fs::{self, OpenOptions}, io, os::unix::fs::{MetadataExt, OpenOptionsExt}, path::{Path, PathBuf}, }; use clap::Parser; use latchd_auth::AuthService; use latchd_core::{ audit::{AuditAction, AuditEvent}, config::{self, Config}, error::{Error, ResultExt}, session, }; use latchd_tui as tui; use tracing::{error, info}; const BUILD_VERSION: &str = env!("LATCHD_VERSION"); const BUILD_COMMIT: &str = env!("LATCHD_COMMIT"); const BUILD_TIME: &str = env!("LATCHD_BUILD_TIME"); /// Command-line options for the login service. #[derive(Debug, Parser)] #[command( name = "latchd", version = BUILD_VERSION, about = "A secure terminal login manager" )] struct Cli { /// Configuration TOML path. #[arg(short, long)] config: Option, /// TOML file with values for text substitution. #[arg(short = 'v', long)] variables: Option, /// Override the configured virtual terminal. #[arg(long)] tty: Option, /// Override the X11 desktop-entry directory. #[arg(long)] xsessions: Option, /// Override the Wayland desktop-entry directory. #[arg(long)] wlsessions: Option, /// Override the session PATH. #[arg(long)] initial_path: Option, /// Run the screen without authentication or session startup. #[arg(long)] preview: bool, /// Validate the configuration and sessions without opening the screen. #[arg(long, alias = "preflight")] no_tui: bool, /// Skip the main log file. #[arg(long)] no_log: bool, /// Print build information and selected configuration values. #[arg(long)] show_config: bool, /// Print embedded build metadata. #[arg(long)] build_info: bool, } fn main() -> color_eyre::Result<()> { color_eyre::install()?; run().map_err(|error| color_eyre::eyre::eyre!(error.user_report())) } fn run() -> Result<(), Error> { let cli = Cli::parse(); if cli.build_info { println!("version={BUILD_VERSION}\ncommit={BUILD_COMMIT}\nepoch={BUILD_TIME}"); return Ok(()); } let config_path = resolve_config_path(cli.config.as_deref(), cli.preview)?; if let Some(path) = cli.variables.as_deref() { if !path.exists() { return Err(Error::Config(format!( "variables file does not exist: {}", path.display() ))); } } let variables_path = cli.variables.as_deref(); if !cli.preview { if let Some(path) = config_path.as_deref() { config::validate_source(path, "configuration")?; } if let Some(path) = variables_path { config::validate_source(path, "variables")?; } } let mut config = Config::default(); if config_path.is_some() || variables_path.is_some() { config = config::load(config_path.as_deref(), variables_path)?; } if let Some(tty) = cli.tty { if tty == 0 { return Err(Error::Config("--tty must be greater than zero".into())); } config.tty = tty; } if let Some(path) = cli.xsessions { config.x11.sessions_path = path; } if let Some(path) = cli.wlsessions { config.wayland.sessions_path = path; } if let Some(path) = cli.initial_path { if path.is_empty() { return Err(Error::Config("--initial-path must not be empty".into())); } config.initial_path = path; } config::validate(&config)?; if cli.show_config { println!( "version = {BUILD_VERSION}\ncommit = {BUILD_COMMIT}\nbuilt = {BUILD_TIME}\ntty = {}\npam_service = {:?}\nsystem_shell = {:?}", config.tty, config.pam_service, config.system_shell ); return Ok(()); } if cli.no_tui { session::preflight(&config)?; println!("configuration valid"); return Ok(()); } if !cli.preview { if std::env::var_os("XDG_SESSION_TYPE").is_some() { return Err(Error::Config( "already inside a session; use --preview".into(), )); } if !running_as_root() { return Err(Error::Config("latchd must run as root".into())); } validate_terminal()?; } if !cli.no_log && !cli.preview { setup_logging(&config.main_log_path)?; } else { tracing_subscriber::fmt() .with_writer(io::sink) .with_ansi(false) .init(); } let auth = AuthService::new()?; while let Some(selection) = tui::run(config.clone(), auth.clone(), cli.preview)? { let identity = selection.ticket.identity().clone(); let spec = match session::build_spec(&identity, &config, &selection.session) { Ok(spec) => spec, Err(error) => { let _ = auth.cancel(selection.ticket); error!(%error, "cannot build session specification"); continue; } }; info!(user = %identity.username, session = %selection.session.title, "starting session"); let audit = AuditEvent::started(AuditAction::Session, &identity.username); tracing::info!( target: "latchd.audit", action = %audit.action, outcome = %audit.outcome, account = %audit.account, pam_reason = ?audit.pam_reason, session = %selection.session.title, "security event" ); if let Err(error) = auth.run_session(selection.ticket, spec) { let audit = AuditEvent::failed(AuditAction::Session, &identity.username, &error); tracing::info!( target: "latchd.audit", action = %audit.action, outcome = %audit.outcome, account = %audit.account, pam_reason = ?audit.pam_reason, session = %selection.session.title, "security event" ); error!("session ended with an error; see the structured audit event"); } else { let audit = AuditEvent::succeeded(AuditAction::Session, &identity.username); tracing::info!( target: "latchd.audit", action = %audit.action, outcome = %audit.outcome, account = %audit.account, pam_reason = ?audit.pam_reason, session = %selection.session.title, "security event" ); } } Ok(()) } fn resolve_config_path(explicit: Option<&Path>, preview: bool) -> Result, Error> { if let Some(path) = explicit { if path.exists() { return Ok(Some(path.to_owned())); } return Err(Error::Config(format!( "configuration does not exist: {}", path.display() ))); } let system_path = Path::new(config::SYSTEM_CONFIG_PATH); if !preview { return Ok(system_path.exists().then(|| system_path.to_owned())); } if let Some(path) = config::user_config_path().filter(|path| path.exists()) { return Ok(Some(path)); } Ok(system_path.exists().then(|| system_path.to_owned())) } fn running_as_root() -> bool { // SAFETY: `geteuid` has no pointer arguments or memory preconditions. unsafe { libc::geteuid() == 0 } } fn setup_logging(path: &std::path::Path) -> Result<(), 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())))?; fs::create_dir_all(parent).io_context(format!("create log directory {}", parent.display()))?; let parent_metadata = fs::symlink_metadata(parent) .io_context(format!("inspect log directory {}", parent.display()))?; if !parent_metadata.is_dir() || parent_metadata.uid() != 0 || parent_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()))?; tracing_subscriber::fmt() .with_writer(file) .with_ansi(false) .init(); Ok(()) } fn validate_terminal() -> Result<(), Error> { let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1; let stdout_is_tty = unsafe { libc::isatty(libc::STDOUT_FILENO) } == 1; if !stdin_is_tty || !stdout_is_tty { return Err(Error::Terminal( "latchd requires a controlling terminal (use --preview for non-interactive checks)" .into(), )); } Ok(()) }