Files
latchd/rust/main.rs
Zane Walker f2a885d621 refactor: split the TUI and refresh project docs
Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds.

Checks: make check; make integration-test; go test ./... (legacy/go).
2026-10-05 21:37:15 +05:30

287 lines
9.3 KiB
Rust

//! 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<PathBuf>,
/// TOML file with values for text substitution.
#[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 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<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(())
}