Files
latchd/rust/main.rs

287 lines
9.2 KiB
Rust

//! latchd is a root-owned terminal login manager.
#![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 latchd.
#[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<PathBuf>,
/// Optional variable TOML path.
#[arg(short = 'v', long)]
variables: Option<PathBuf>,
/// Override the configured virtual terminal.
#[arg(long)]
tty: Option<u8>,
/// Override the X11 desktop-entry directory.
#[arg(long)]
xsessions: Option<PathBuf>,
/// Override the Wayland desktop-entry directory.
#[arg(long)]
wlsessions: Option<PathBuf>,
/// Override the session PATH.
#[arg(long)]
initial_path: Option<String>,
/// Run without authenticating or starting a real session.
#[arg(long)]
preview: bool,
/// Validate configuration and exit without starting the TUI.
#[arg(long, alias = "preflight")]
no_tui: bool,
/// Do not create a main log.
#[arg(long)]
no_log: bool,
/// Print the validated configuration and exit.
#[arg(long)]
show_config: bool,
/// Print embedded release metadata without accessing configuration or PAM.
#[arg(long)]
build_info: bool,
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
run().map_err(|error| color_eyre::eyre::eyre!(error.to_string()))
}
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<Option<PathBuf>, 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(())
}