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.
238 lines
7.4 KiB
Rust
238 lines
7.4 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::PathBuf,
|
|
};
|
|
|
|
use clap::Parser;
|
|
use latchd_auth::AuthService;
|
|
use latchd_core::{
|
|
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, default_value = "/etc/latchd/config.toml")]
|
|
config: 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 = if cli.config.exists() {
|
|
Some(cli.config.as_path())
|
|
} else {
|
|
if cli.config.as_os_str() != "/etc/latchd/config.toml" {
|
|
return Err(Error::Config(format!(
|
|
"configuration does not exist: {}",
|
|
cli.config.display()
|
|
)));
|
|
}
|
|
None
|
|
};
|
|
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 {
|
|
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, 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 unsafe { libc::geteuid() } != 0 {
|
|
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");
|
|
if let Err(error) = auth.run_session(selection.ticket, spec) {
|
|
error!(%error, "session ended with an error");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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(())
|
|
}
|