385 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|