Files
latchd/crates/latchd-core/src/config.rs

385 lines
12 KiB
Rust

use std::{
collections::HashMap,
fs,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::error::{Error, ResultExt};
/// Validated application configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Virtual terminal number used by the service.
pub tty: u8,
/// Main log file.
pub main_log_path: PathBuf,
/// Per-session log file.
pub client_log_path: PathBuf,
/// PAM service name.
pub pam_service: String,
/// Default shell used only when the account has no shell value.
pub system_shell: PathBuf,
/// Session PATH.
pub initial_path: String,
/// X11 settings.
pub x11: X11Config,
/// Wayland settings.
pub wayland: WaylandConfig,
/// Environment switcher settings.
pub environment_switcher: SwitcherConfig,
/// Power actions shown by the login screen.
pub power_controls: PowerControls,
/// Account enumeration and advisory lockout settings.
pub accounts: AccountsConfig,
/// TUI-only background, animation, and notification settings.
pub visual: VisualConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
tty: 2,
main_log_path: PathBuf::from("/var/log/latchd.log"),
client_log_path: PathBuf::from("/var/log/latchd.client.log"),
pam_service: "latchd".into(),
system_shell: PathBuf::from("/bin/sh"),
initial_path: "/usr/local/sbin:/usr/local/bin:/usr/bin".into(),
x11: X11Config::default(),
wayland: WaylandConfig::default(),
environment_switcher: SwitcherConfig::default(),
power_controls: PowerControls::default(),
accounts: AccountsConfig::default(),
visual: VisualConfig::default(),
}
}
}
/// Account discovery and faillock settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AccountsConfig {
/// Minimum UID shown by automatic account discovery.
pub minimum_uid: u32,
/// Usernames excluded from the chooser.
pub excluded_users: Vec<String>,
/// Whether the chooser lists NSS accounts.
pub enumerate: bool,
/// Absolute faillock helper path.
pub faillock_path: PathBuf,
/// faillock configuration used to read the deny threshold.
pub faillock_config: PathBuf,
/// Shadow database used for local password and account aging checks.
pub shadow_path: PathBuf,
}
impl Default for AccountsConfig {
fn default() -> Self {
Self {
minimum_uid: 1000,
excluded_users: Vec::new(),
enumerate: true,
faillock_path: PathBuf::from("/usr/sbin/faillock"),
faillock_config: PathBuf::from("/etc/security/faillock.conf"),
shadow_path: PathBuf::from("/etc/shadow"),
}
}
}
/// Visual effects used only by the terminal login screen.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VisualConfig {
/// Optional root-owned image used as a low-contrast terminal wallpaper.
pub wallpaper_path: Option<PathBuf>,
/// Enable the wallpaper and procedural background layer.
pub background_enabled: bool,
/// Advance animated background and spinner frames.
pub animations: bool,
/// Show transient in-TUI notifications for user-visible events.
pub notifications: bool,
/// Disable motion while retaining static visual styling.
pub reduced_motion: bool,
/// Maximum wallpaper file size accepted before decoding.
pub max_wallpaper_bytes: u64,
}
impl Default for VisualConfig {
fn default() -> Self {
Self {
wallpaper_path: None,
background_enabled: true,
animations: true,
notifications: true,
reduced_motion: false,
max_wallpaper_bytes: 8 * 1024 * 1024,
}
}
}
/// X11 session configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct X11Config {
/// X display name.
pub display: String,
/// X server executable.
pub server_path: PathBuf,
/// Xauthority executable.
pub xauth_path: PathBuf,
/// X server startup timeout.
pub server_timeout_secs: u16,
/// X server log.
pub server_log_path: PathBuf,
/// Desktop-entry directory.
pub sessions_path: PathBuf,
/// Custom script directory.
pub scripts_path: PathBuf,
}
impl Default for X11Config {
fn default() -> Self {
Self {
display: ":1".into(),
server_path: "/usr/bin/X".into(),
xauth_path: "/usr/bin/xauth".into(),
server_timeout_secs: 60,
server_log_path: "/var/log/latchd.xorg.log".into(),
sessions_path: "/usr/share/xsessions".into(),
scripts_path: "/etc/latchd/wms".into(),
}
}
}
/// Wayland session configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WaylandConfig {
/// Desktop-entry directory.
pub sessions_path: PathBuf,
/// Custom script directory.
pub scripts_path: PathBuf,
}
impl Default for WaylandConfig {
fn default() -> Self {
Self {
sessions_path: "/usr/share/wayland-sessions".into(),
scripts_path: "/etc/latchd/wayland".into(),
}
}
}
/// Session switcher settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SwitcherConfig {
/// Include the fallback TTY shell in the chooser.
pub include_tty_shell: bool,
/// Maximum visible title width.
pub max_display_length: usize,
}
impl Default for SwitcherConfig {
fn default() -> Self {
Self {
include_tty_shell: false,
max_display_length: 18,
}
}
}
/// Power actions available to the login screen.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PowerControls {
/// Built-in or administrator-defined actions.
pub entries: Vec<PowerAction>,
}
/// A power action represented as an executable argv vector.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PowerAction {
/// Displayed key, such as `F1`.
pub key: String,
/// Displayed label.
pub hint: String,
/// Executable path.
pub program: PathBuf,
/// Arguments passed without shell interpretation.
#[serde(default)]
pub args: Vec<String>,
}
/// Loads a configuration file over the built-in defaults.
pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
let mut config = Config::default();
if let Some(variables_path) = variables {
let values = load_variables(variables_path)?;
if let Some(config_path) = path {
let raw = fs::read_to_string(config_path)
.io_context(format!("read {}", config_path.display()))?;
let expanded = resolve_variables(&raw, &values);
config = toml::from_str(&expanded).map_err(|source| Error::Toml {
path: config_path.display().to_string(),
source,
})?;
}
} else if let Some(config_path) = path {
let raw = fs::read_to_string(config_path)
.io_context(format!("read {}", config_path.display()))?;
config = toml::from_str(&raw).map_err(|source| Error::Toml {
path: config_path.display().to_string(),
source,
})?;
}
validate(&config)?;
Ok(config)
}
fn load_variables(path: &Path) -> Result<HashMap<String, String>, Error> {
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
let values: HashMap<String, toml::Value> =
toml::from_str(&raw).map_err(|source| Error::Toml {
path: path.display().to_string(),
source,
})?;
Ok(values
.into_iter()
.map(|(key, value)| (key, value.to_string().trim_matches('"').to_owned()))
.collect())
}
fn resolve_variables(input: &str, variables: &HashMap<String, String>) -> String {
let mut keys: Vec<_> = variables.keys().collect();
keys.sort_by_key(|key| std::cmp::Reverse(key.len()));
keys.into_iter().fold(input.to_owned(), |result, key| {
result.replace(&format!("${key}"), &variables[key])
})
}
/// Validates values that must be safe before starting the login screen.
pub fn validate(config: &Config) -> Result<(), Error> {
if config.tty == 0 || config.tty > 63 {
return Err(Error::Config("tty must be between 1 and 63".into()));
}
if config.pam_service.is_empty() || config.pam_service.contains('\0') {
return Err(Error::Config("pam_service must be a non-empty name".into()));
}
if config.initial_path.is_empty() {
return Err(Error::Config("initial_path must not be empty".into()));
}
if config
.initial_path
.split(':')
.any(|entry| entry.is_empty() || !Path::new(entry).is_absolute())
{
return Err(Error::Config(
"initial_path must contain only non-empty absolute directories".into(),
));
}
if config.environment_switcher.max_display_length == 0 {
return Err(Error::Config(
"environment_switcher.max_display_length must be greater than zero".into(),
));
}
for (name, path) in [
("main_log_path", &config.main_log_path),
("client_log_path", &config.client_log_path),
("system_shell", &config.system_shell),
("x11.server_path", &config.x11.server_path),
("x11.xauth_path", &config.x11.xauth_path),
("x11.server_log_path", &config.x11.server_log_path),
("accounts.faillock_path", &config.accounts.faillock_path),
("accounts.faillock_config", &config.accounts.faillock_config),
("accounts.shadow_path", &config.accounts.shadow_path),
] {
if !path.is_absolute() {
return Err(Error::Config(format!("{name} must be an absolute path")));
}
}
if config.visual.max_wallpaper_bytes == 0
|| config.visual.max_wallpaper_bytes > 64 * 1024 * 1024
{
return Err(Error::Config(
"visual.max_wallpaper_bytes must be between 1 and 67108864".into(),
));
}
if let Some(path) = &config.visual.wallpaper_path {
if !path.is_absolute() {
return Err(Error::Config(
"visual.wallpaper_path must be an absolute path".into(),
));
}
}
if config.x11.display.trim().is_empty() || config.x11.server_timeout_secs == 0 {
return Err(Error::Config(
"x11 display must be non-empty and server_timeout_secs must be greater than zero"
.into(),
));
}
for action in &config.power_controls.entries {
let valid_key = action
.key
.strip_prefix('F')
.and_then(|number| number.parse::<u8>().ok())
.is_some_and(|number| (1..=12).contains(&number));
if !valid_key || action.hint.is_empty() || !action.program.is_absolute() {
return Err(Error::Config(format!(
"power action {} must use F1-F12, an absolute program path, and a non-empty label",
action.key
)));
}
}
Ok(())
}
/// Rejects a privileged configuration source that can be replaced by another user.
pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
let metadata =
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"{label} must be a root-owned, non-group-writable regular file: {}",
path.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variables_are_replaced_longest_first() {
let mut vars = HashMap::new();
vars.insert("A".into(), "short".into());
vars.insert("AB".into(), "long".into());
assert_eq!(resolve_variables("$AB/$A", &vars), "long/short");
}
#[test]
fn defaults_are_valid() {
validate(&Config::default()).expect("defaults are static and valid");
}
#[test]
fn unknown_fields_are_rejected() {
assert!(toml::from_str::<Config>("unexpected = true").is_err());
}
#[test]
fn relative_path_entries_are_rejected() {
let config = Config {
initial_path: "relative:/usr/bin".into(),
..Config::default()
};
assert!(validate(&config).is_err());
}
}