287 lines
9.2 KiB
Rust
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(())
|
|
}
|