feat: ship Rust login manager and GitLab release pipeline
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.
This commit is contained in:
11
crates/latchd-auth/Cargo.toml
Normal file
11
crates/latchd-auth/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "latchd-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
latchd-core = { path = "../latchd-core" }
|
||||
libc = "0.2"
|
||||
zeroize = "1.8"
|
||||
548
crates/latchd-auth/src/lib.rs
Normal file
548
crates/latchd-auth/src/lib.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
//! PAM authentication with one dedicated worker thread.
|
||||
//!
|
||||
//! PAM handles are intentionally never moved out of the worker. The public
|
||||
//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM
|
||||
//! transaction from the wrong thread or forget to close it.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{CStr, CString},
|
||||
mem,
|
||||
os::raw::{c_char, c_int, c_void},
|
||||
ptr,
|
||||
sync::mpsc::{Receiver, SyncSender, sync_channel},
|
||||
thread,
|
||||
};
|
||||
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use latchd_core::{
|
||||
accounts::Identity,
|
||||
error::Error,
|
||||
session::{self, SessionSpec},
|
||||
};
|
||||
|
||||
const PAM_SUCCESS: c_int = 0;
|
||||
const PAM_SYSTEM_ERR: c_int = 4;
|
||||
const PAM_BUF_ERR: c_int = 5;
|
||||
const PAM_CONV_ERR: c_int = 19;
|
||||
const PAM_AUTHINFO_UNAVAIL: c_int = 9;
|
||||
|
||||
const PAM_PROMPT_ECHO_OFF: c_int = 1;
|
||||
const PAM_PROMPT_ECHO_ON: c_int = 2;
|
||||
const PAM_ERROR_MSG: c_int = 3;
|
||||
const PAM_TEXT_INFO: c_int = 4;
|
||||
const PAM_ESTABLISH_CRED: c_int = 0x0002;
|
||||
const PAM_DELETE_CRED: c_int = 0x0004;
|
||||
|
||||
#[repr(C)]
|
||||
struct PamHandle {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamMessage {
|
||||
msg_style: c_int,
|
||||
msg: *const c_char,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamResponse {
|
||||
resp: *mut c_char,
|
||||
resp_retcode: c_int,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamConv {
|
||||
conv: Option<
|
||||
unsafe extern "C" fn(
|
||||
c_int,
|
||||
*mut *const PamMessage,
|
||||
*mut *mut PamResponse,
|
||||
*mut c_void,
|
||||
) -> c_int,
|
||||
>,
|
||||
appdata_ptr: *mut c_void,
|
||||
}
|
||||
|
||||
#[link(name = "pam")]
|
||||
unsafe extern "C" {
|
||||
#[link_name = "pam_start"]
|
||||
fn pam_start(
|
||||
service: *const c_char,
|
||||
user: *const c_char,
|
||||
conv: *const PamConv,
|
||||
pamh: *mut *mut PamHandle,
|
||||
) -> c_int;
|
||||
#[link_name = "pam_authenticate"]
|
||||
fn pam_authenticate(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_acct_mgmt"]
|
||||
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_setcred"]
|
||||
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_open_session"]
|
||||
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_close_session"]
|
||||
fn pam_close_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_end"]
|
||||
fn pam_end(pamh: *mut PamHandle, pam_status: c_int) -> c_int;
|
||||
#[link_name = "pam_strerror"]
|
||||
fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char;
|
||||
}
|
||||
|
||||
/// An opaque authenticated PAM transaction.
|
||||
pub struct AuthTicket {
|
||||
id: u64,
|
||||
identity: Identity,
|
||||
owner: Option<AuthService>,
|
||||
}
|
||||
|
||||
impl AuthTicket {
|
||||
/// Returns the account identity associated with this ticket.
|
||||
pub fn identity(&self) -> &Identity {
|
||||
&self.identity
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthTicket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(owner) = &self.owner {
|
||||
let _ = owner.commands.try_send(Command::Cancel { id: self.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Authenticate {
|
||||
username: String,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
service: String,
|
||||
response: std::sync::mpsc::SyncSender<Result<AuthTicket, Error>>,
|
||||
},
|
||||
Run {
|
||||
id: u64,
|
||||
spec: SessionSpec,
|
||||
response: std::sync::mpsc::SyncSender<Result<(), Error>>,
|
||||
},
|
||||
Cancel {
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Serializes PAM operations on a dedicated worker thread.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
commands: SyncSender<Command>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
/// Starts the PAM worker.
|
||||
pub fn new() -> Result<Self, Error> {
|
||||
let (commands, requests) = sync_channel::<Command>(1);
|
||||
thread::Builder::new()
|
||||
.name("latchd-pam".into())
|
||||
.spawn(move || worker(requests))
|
||||
.map_err(|error| Error::Worker(format!("spawn PAM worker: {error}")))?;
|
||||
Ok(Self { commands })
|
||||
}
|
||||
|
||||
/// Starts one authentication transaction without blocking the UI thread.
|
||||
pub fn authenticate(
|
||||
&self,
|
||||
username: String,
|
||||
password: Vec<u8>,
|
||||
service: String,
|
||||
) -> Result<AuthTicket, Error> {
|
||||
let (response, result) = sync_channel(0);
|
||||
self.commands
|
||||
.send(Command::Authenticate {
|
||||
username,
|
||||
password: Zeroizing::new(password),
|
||||
service,
|
||||
response,
|
||||
})
|
||||
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
|
||||
let ticket = result
|
||||
.recv()
|
||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))??;
|
||||
let mut ticket = ticket;
|
||||
ticket.owner = Some(self.clone());
|
||||
Ok(ticket)
|
||||
}
|
||||
|
||||
/// Runs the authenticated session and closes its PAM transaction.
|
||||
pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
|
||||
let (response, result) = sync_channel(0);
|
||||
let id = ticket.id;
|
||||
ticket.owner = None;
|
||||
self.commands
|
||||
.send(Command::Run { id, spec, response })
|
||||
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
|
||||
result
|
||||
.recv()
|
||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
|
||||
}
|
||||
|
||||
/// Cancels a ticket that will not be used for a session.
|
||||
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
|
||||
let id = ticket.id;
|
||||
ticket.owner = None;
|
||||
self.commands
|
||||
.send(Command::Cancel { id })
|
||||
.map_err(|_| Error::Worker("PAM worker stopped".into()))
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationData {
|
||||
username: CString,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Drop for ConversationData {
|
||||
fn drop(&mut self) {
|
||||
self.password.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
struct PamSession {
|
||||
handle: *mut PamHandle,
|
||||
conversation: Box<ConversationData>,
|
||||
opened: bool,
|
||||
credentials_established: bool,
|
||||
identity: Option<Identity>,
|
||||
}
|
||||
|
||||
impl PamSession {
|
||||
fn close(&mut self, status: c_int) -> Result<(), Error> {
|
||||
let mut first_error = None;
|
||||
if self.opened {
|
||||
let result = unsafe { pam_close_session(self.handle, 0) };
|
||||
if result != PAM_SUCCESS {
|
||||
first_error = Some(pam_error(self.handle, "pam_close_session", result));
|
||||
}
|
||||
self.opened = false;
|
||||
}
|
||||
if self.credentials_established {
|
||||
let credentials = unsafe { pam_setcred(self.handle, PAM_DELETE_CRED) };
|
||||
if credentials != PAM_SUCCESS && first_error.is_none() {
|
||||
first_error = Some(pam_error(self.handle, "pam_setcred(delete)", credentials));
|
||||
}
|
||||
self.credentials_established = false;
|
||||
}
|
||||
let ended = unsafe { pam_end(self.handle, status) };
|
||||
if ended != PAM_SUCCESS && first_error.is_none() {
|
||||
first_error = Some(pam_error(self.handle, "pam_end", ended));
|
||||
}
|
||||
self.handle = ptr::null_mut();
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PamSession {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
let _ = self.close(PAM_SYSTEM_ERR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worker(requests: Receiver<Command>) {
|
||||
let mut sessions = HashMap::<u64, PamSession>::new();
|
||||
let mut next_id = 1u64;
|
||||
while let Ok(command) = requests.recv() {
|
||||
match command {
|
||||
Command::Authenticate {
|
||||
username,
|
||||
password,
|
||||
service,
|
||||
response,
|
||||
} => {
|
||||
let result =
|
||||
authenticate(&username, password, &service, &mut next_id, &mut sessions);
|
||||
let _ = response.send(result);
|
||||
}
|
||||
Command::Run { id, spec, response } => {
|
||||
let result = match sessions.remove(&id) {
|
||||
Some(mut session) => {
|
||||
let opened = unsafe { pam_open_session(session.handle, 0) };
|
||||
if opened != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_open_session", opened);
|
||||
let _ = session.close(opened);
|
||||
Err(error)
|
||||
} else {
|
||||
session.opened = true;
|
||||
let run = match session.identity.as_ref() {
|
||||
Some(identity) => session::run_user_session(identity, &spec),
|
||||
None => Err(Error::Worker("ticket has no account identity".into())),
|
||||
};
|
||||
let close = session.close(if run.is_ok() {
|
||||
PAM_SUCCESS
|
||||
} else {
|
||||
PAM_SYSTEM_ERR
|
||||
});
|
||||
match (run, close) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Err(Error::Worker("unknown authentication ticket".into())),
|
||||
};
|
||||
let _ = response.send(result);
|
||||
}
|
||||
Command::Cancel { id } => {
|
||||
sessions.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
username: &str,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
service: &str,
|
||||
next_id: &mut u64,
|
||||
sessions: &mut HashMap<u64, PamSession>,
|
||||
) -> Result<AuthTicket, Error> {
|
||||
let service =
|
||||
CString::new(service).map_err(|_| Error::Config("PAM service contains NUL".into()))?;
|
||||
let username_c =
|
||||
CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
|
||||
let conversation = Box::new(ConversationData {
|
||||
username: username_c.clone(),
|
||||
password,
|
||||
});
|
||||
let data = Box::into_raw(conversation);
|
||||
let conversation_ptr = PamConv {
|
||||
conv: Some(converse),
|
||||
appdata_ptr: data.cast(),
|
||||
};
|
||||
let mut handle = ptr::null_mut();
|
||||
let started = unsafe {
|
||||
pam_start(
|
||||
service.as_ptr(),
|
||||
username_c.as_ptr(),
|
||||
&conversation_ptr,
|
||||
&mut handle,
|
||||
)
|
||||
};
|
||||
// Reconstitute the allocation on every path after pam_start. PAM copies the
|
||||
// conversation structure, but does not own appdata_ptr.
|
||||
let conversation = unsafe { Box::from_raw(data) };
|
||||
if started != PAM_SUCCESS {
|
||||
let error = pam_error(handle, "pam_start", started);
|
||||
if !handle.is_null() {
|
||||
let _ = unsafe { pam_end(handle, started) };
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if handle.is_null() {
|
||||
return Err(Error::Pam {
|
||||
operation: "pam_start",
|
||||
status: PAM_SYSTEM_ERR,
|
||||
message: "returned a null handle".into(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut session = PamSession {
|
||||
handle,
|
||||
conversation,
|
||||
opened: false,
|
||||
credentials_established: false,
|
||||
identity: None,
|
||||
};
|
||||
let authenticated = unsafe { pam_authenticate(session.handle, 0) };
|
||||
if authenticated != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_authenticate", authenticated);
|
||||
let _ = session.close(authenticated);
|
||||
return Err(error);
|
||||
}
|
||||
let account = unsafe { pam_acct_mgmt(session.handle, 0) };
|
||||
if account != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_acct_mgmt", account);
|
||||
let _ = session.close(account);
|
||||
return Err(error);
|
||||
}
|
||||
let identity = match lookup_identity(username) {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
let _ = session.close(PAM_SYSTEM_ERR);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
session.identity = Some(identity);
|
||||
let credentials = unsafe { pam_setcred(session.handle, PAM_ESTABLISH_CRED) };
|
||||
if credentials != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_setcred(establish)", credentials);
|
||||
let _ = session.close(credentials);
|
||||
return Err(error);
|
||||
}
|
||||
session.credentials_established = true;
|
||||
|
||||
// Authentication is complete. Do not retain the password for the session.
|
||||
session.conversation.password.zeroize();
|
||||
let identity = session
|
||||
.identity
|
||||
.clone()
|
||||
.ok_or_else(|| Error::Worker("authenticated session has no identity".into()))?;
|
||||
let id = *next_id;
|
||||
*next_id = next_id
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| Error::Worker("authentication ticket counter exhausted".into()))?;
|
||||
sessions.insert(id, session);
|
||||
Ok(AuthTicket {
|
||||
id,
|
||||
identity,
|
||||
owner: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn lookup_identity(username: &str) -> Result<Identity, Error> {
|
||||
let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
|
||||
let passwd = unsafe { libc::getpwnam(name.as_ptr()) };
|
||||
if passwd.is_null() {
|
||||
return Err(Error::Pam {
|
||||
operation: "getpwnam",
|
||||
status: PAM_AUTHINFO_UNAVAIL,
|
||||
message: format!("unknown account {username}"),
|
||||
});
|
||||
}
|
||||
let passwd = unsafe { &*passwd };
|
||||
let home = c_path(passwd.pw_dir, "home")?;
|
||||
let shell = c_path(passwd.pw_shell, "shell")?;
|
||||
if shell == std::path::Path::new("/bin/false")
|
||||
|| shell == std::path::Path::new("/usr/sbin/nologin")
|
||||
|| shell == std::path::Path::new("/sbin/nologin")
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"account does not have an interactive shell".into(),
|
||||
));
|
||||
}
|
||||
Ok(Identity {
|
||||
username: username.into(),
|
||||
uid: passwd.pw_uid,
|
||||
gid: passwd.pw_gid,
|
||||
home,
|
||||
shell,
|
||||
})
|
||||
}
|
||||
|
||||
fn c_path(pointer: *const c_char, field: &str) -> Result<std::path::PathBuf, Error> {
|
||||
if pointer.is_null() {
|
||||
return Err(Error::Config(format!("account has no {field}")));
|
||||
}
|
||||
let value = unsafe { CStr::from_ptr(pointer) }
|
||||
.to_str()
|
||||
.map_err(|_| Error::Config(format!("account {field} is not UTF-8")))?;
|
||||
if value.is_empty() {
|
||||
return Err(Error::Config(format!("account has empty {field}")));
|
||||
}
|
||||
Ok(value.into())
|
||||
}
|
||||
|
||||
#[allow(unsafe_op_in_unsafe_fn)]
|
||||
unsafe extern "C" fn converse(
|
||||
count: c_int,
|
||||
messages: *mut *const PamMessage,
|
||||
responses: *mut *mut PamResponse,
|
||||
data: *mut c_void,
|
||||
) -> c_int {
|
||||
if count <= 0 || messages.is_null() || responses.is_null() || data.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let count = count as usize;
|
||||
let output = libc::calloc(count, mem::size_of::<PamResponse>()) as *mut PamResponse;
|
||||
if output.is_null() {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
let context = &*(data.cast::<ConversationData>());
|
||||
for index in 0..count {
|
||||
let message = *messages.add(index);
|
||||
if message.is_null() {
|
||||
free_responses(output, index);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let value: Option<&[u8]> = match (*message).msg_style {
|
||||
PAM_PROMPT_ECHO_OFF => Some(context.password.as_slice()),
|
||||
PAM_PROMPT_ECHO_ON => Some(context.username.as_bytes()),
|
||||
PAM_ERROR_MSG | PAM_TEXT_INFO => None,
|
||||
_ => {
|
||||
free_responses(output, index);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
};
|
||||
if let Some(value) = value {
|
||||
let destination = libc::malloc(value.len().saturating_add(1)) as *mut u8;
|
||||
if destination.is_null() {
|
||||
free_responses(output, index);
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
ptr::copy_nonoverlapping(value.as_ptr(), destination, value.len());
|
||||
*destination.add(value.len()) = 0;
|
||||
(*output.add(index)).resp = destination.cast();
|
||||
}
|
||||
}
|
||||
*responses = output;
|
||||
PAM_SUCCESS
|
||||
}
|
||||
|
||||
#[allow(unsafe_op_in_unsafe_fn)]
|
||||
unsafe fn free_responses(responses: *mut PamResponse, count: usize) {
|
||||
for index in 0..count {
|
||||
libc::free((*responses.add(index)).resp.cast());
|
||||
}
|
||||
libc::free(responses.cast());
|
||||
}
|
||||
|
||||
fn pam_error(handle: *mut PamHandle, operation: &'static str, status: c_int) -> Error {
|
||||
let message = if handle.is_null() {
|
||||
format!("status {status}")
|
||||
} else {
|
||||
let pointer = unsafe { pam_strerror(handle, status) };
|
||||
if pointer.is_null() {
|
||||
format!("status {status}")
|
||||
} else {
|
||||
unsafe { CStr::from_ptr(pointer) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
};
|
||||
Error::Pam {
|
||||
operation,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conversation_never_returns_password_for_echo_on() {
|
||||
let context = Box::new(ConversationData {
|
||||
username: CString::new("alice").unwrap(),
|
||||
password: Zeroizing::new(b"secret".to_vec()),
|
||||
});
|
||||
let pointer = Box::into_raw(context);
|
||||
let message = PamMessage {
|
||||
msg_style: PAM_PROMPT_ECHO_ON,
|
||||
msg: ptr::null(),
|
||||
};
|
||||
let mut message_pointer = &message as *const PamMessage;
|
||||
let mut responses = ptr::null_mut();
|
||||
let status = unsafe { converse(1, &mut message_pointer, &mut responses, pointer.cast()) };
|
||||
assert_eq!(status, PAM_SUCCESS);
|
||||
let response = unsafe { &*responses };
|
||||
assert_eq!(
|
||||
unsafe { CStr::from_ptr(response.resp) }.to_bytes(),
|
||||
b"alice"
|
||||
);
|
||||
unsafe {
|
||||
libc::free(response.resp.cast());
|
||||
libc::free(responses.cast());
|
||||
drop(Box::from_raw(pointer));
|
||||
}
|
||||
}
|
||||
}
|
||||
17
crates/latchd-core/Cargo.toml
Normal file
17
crates/latchd-core/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "latchd-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
rand = "0.9"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
signal-hook = "0.3"
|
||||
thiserror = "2.0"
|
||||
toml = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
unicode-width = "0.2"
|
||||
97
crates/latchd-core/src/accounting.rs
Normal file
97
crates/latchd-core/src/accounting.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Linux UTMPX accounting with idempotent RAII cleanup.
|
||||
|
||||
use std::{io, mem, os::raw::c_char, ptr};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// A live user-process record in UTMPX.
|
||||
pub struct UtmpxGuard {
|
||||
record: libc::utmpx,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl UtmpxGuard {
|
||||
/// Adds a user-process record for a child and returns its cleanup guard.
|
||||
pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result<Self, Error> {
|
||||
let mut record: libc::utmpx = unsafe { mem::zeroed() };
|
||||
record.ut_type = libc::USER_PROCESS;
|
||||
record.ut_pid = pid;
|
||||
write_field(&mut record.ut_line, &format!("tty{tty}"));
|
||||
write_field(&mut record.ut_id, &format!("l{tty}"));
|
||||
write_field(&mut record.ut_user, username);
|
||||
write_timestamp(&mut record);
|
||||
put_record(&record, "write UTMPX login record")?;
|
||||
Ok(Self {
|
||||
record,
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the corresponding dead-process record. Calling it twice is safe.
|
||||
pub fn close(&mut self) -> Result<(), Error> {
|
||||
if self.closed {
|
||||
return Ok(());
|
||||
}
|
||||
self.record.ut_type = libc::DEAD_PROCESS;
|
||||
self.record.ut_user.fill(0);
|
||||
self.record.ut_host.fill(0);
|
||||
write_timestamp(&mut self.record);
|
||||
put_record(&self.record, "write UTMPX logout record")?;
|
||||
self.closed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UtmpxGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_field<const N: usize>(field: &mut [c_char; N], value: &str) {
|
||||
field.fill(0);
|
||||
for (destination, byte) in field.iter_mut().zip(value.as_bytes().iter().copied()) {
|
||||
*destination = byte as c_char;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_timestamp(record: &mut libc::utmpx) {
|
||||
let seconds = unsafe { libc::time(ptr::null_mut()) };
|
||||
record.ut_tv.tv_sec = seconds as _;
|
||||
record.ut_tv.tv_usec = 0;
|
||||
}
|
||||
|
||||
fn put_record(record: &libc::utmpx, operation: &'static str) -> Result<(), Error> {
|
||||
unsafe {
|
||||
libc::setutxent();
|
||||
let result = libc::pututxline(record);
|
||||
libc::endutxent();
|
||||
if result.is_null() {
|
||||
return Err(Error::Session(format!(
|
||||
"{operation}: {}",
|
||||
io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_writer_truncates_without_panicking() {
|
||||
let mut field = [0 as c_char; 4];
|
||||
write_field(&mut field, "abcdef");
|
||||
assert_eq!(
|
||||
field,
|
||||
[
|
||||
b'a' as c_char,
|
||||
b'b' as c_char,
|
||||
b'c' as c_char,
|
||||
b'd' as c_char
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
237
crates/latchd-core/src/accounts.rs
Normal file
237
crates/latchd-core/src/accounts.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! NSS account discovery and advisory faillock status.
|
||||
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::AccountsConfig,
|
||||
error::{Error, ResultExt},
|
||||
};
|
||||
|
||||
/// An account that can be offered by the login screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
/// Canonical username.
|
||||
pub username: String,
|
||||
/// Numeric user ID.
|
||||
pub uid: u32,
|
||||
/// Primary group ID.
|
||||
pub gid: u32,
|
||||
/// Home directory.
|
||||
pub home: PathBuf,
|
||||
/// Login shell.
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
/// A non-authoritative account lockout summary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FaillockStatus {
|
||||
/// Whether the faillock helper returned a usable result.
|
||||
pub available: bool,
|
||||
/// Number of observed failed attempts.
|
||||
pub failures: u32,
|
||||
/// Configured failure threshold, when discoverable.
|
||||
pub max_tries: Option<u32>,
|
||||
/// Remaining attempts before the advisory threshold.
|
||||
pub remaining: Option<u32>,
|
||||
/// Whether the advisory threshold has been reached.
|
||||
pub locked_out: bool,
|
||||
}
|
||||
|
||||
/// Enumerates interactive accounts using NSS and deterministic ordering.
|
||||
pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
|
||||
let mut accounts = Vec::new();
|
||||
unsafe {
|
||||
libc::setpwent();
|
||||
loop {
|
||||
let entry = libc::getpwent();
|
||||
if entry.is_null() {
|
||||
break;
|
||||
}
|
||||
let passwd = &*entry;
|
||||
let Some(username) = c_string(passwd.pw_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(home) = c_path(passwd.pw_dir) else {
|
||||
continue;
|
||||
};
|
||||
let Some(shell) = c_path(passwd.pw_shell) else {
|
||||
continue;
|
||||
};
|
||||
if !eligible(&username, passwd.pw_uid, &home, &shell, config) {
|
||||
continue;
|
||||
}
|
||||
accounts.push(Identity {
|
||||
username,
|
||||
uid: passwd.pw_uid,
|
||||
gid: passwd.pw_gid,
|
||||
home,
|
||||
shell,
|
||||
});
|
||||
}
|
||||
libc::endpwent();
|
||||
}
|
||||
accounts.sort_by(|left, right| left.username.cmp(&right.username));
|
||||
accounts
|
||||
}
|
||||
|
||||
/// Runs the configured helper and parses its stable numeric output.
|
||||
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
|
||||
let helper = &config.faillock_path;
|
||||
if !helper.is_absolute() {
|
||||
return Err(Error::Config("faillock_path must be absolute".into()));
|
||||
}
|
||||
if !trusted_helper(helper) {
|
||||
return Ok(FaillockStatus::default());
|
||||
}
|
||||
let mut child = Command::new(helper)
|
||||
.args(["--user", username, "--legacy-output"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.io_context("start faillock query")?;
|
||||
let deadline = Instant::now() + Duration::from_millis(750);
|
||||
loop {
|
||||
if child
|
||||
.try_wait()
|
||||
.io_context("poll faillock query")?
|
||||
.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok(FaillockStatus::default());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let output = child.wait_with_output().io_context("read faillock query")?;
|
||||
if !output.status.success() {
|
||||
return Ok(FaillockStatus::default());
|
||||
}
|
||||
let failures = parse_failure_count(&output.stdout);
|
||||
let max_tries = read_deny(&config.faillock_config);
|
||||
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
||||
Ok(FaillockStatus {
|
||||
available: true,
|
||||
failures,
|
||||
max_tries,
|
||||
remaining,
|
||||
locked_out: remaining.is_some_and(|value| value == 0),
|
||||
})
|
||||
}
|
||||
|
||||
fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool {
|
||||
if uid < config.minimum_uid || config.excluded_users.iter().any(|value| value == username) {
|
||||
return false;
|
||||
}
|
||||
if shell == Path::new("/bin/false")
|
||||
|| shell == Path::new("/usr/sbin/nologin")
|
||||
|| shell == Path::new("/sbin/nologin")
|
||||
|| !shell.is_absolute()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(metadata) = std::fs::symlink_metadata(home) else {
|
||||
return false;
|
||||
};
|
||||
metadata.is_dir()
|
||||
&& (metadata.uid() == uid || metadata.uid() == 0)
|
||||
&& metadata.mode() & 0o022 == 0
|
||||
}
|
||||
|
||||
fn c_string(pointer: *const libc::c_char) -> Option<String> {
|
||||
(!pointer.is_null())
|
||||
.then(|| {
|
||||
unsafe { CStr::from_ptr(pointer) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn c_path(pointer: *const libc::c_char) -> Option<PathBuf> {
|
||||
c_string(pointer)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn parse_failure_count(bytes: &[u8]) -> u32 {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().next())
|
||||
.find_map(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_deny(path: &Path) -> Option<u32> {
|
||||
if !trusted_helper(path) {
|
||||
return None;
|
||||
}
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
contents.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
(key.trim() == "deny")
|
||||
.then(|| value.trim().parse().ok())
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn trusted_helper(path: &Path) -> bool {
|
||||
let Ok(metadata) = std::fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
metadata.is_file()
|
||||
&& metadata.uid() == 0
|
||||
&& metadata.mode() & 0o022 == 0
|
||||
&& (metadata.mode() & 0o111 != 0 || path.ends_with("faillock.conf"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_failure_count() {
|
||||
assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4);
|
||||
assert_eq!(parse_failure_count(b""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_non_login_shells_and_untrusted_homes() {
|
||||
let config = AccountsConfig::default();
|
||||
assert!(!eligible(
|
||||
"service",
|
||||
1000,
|
||||
Path::new("/tmp"),
|
||||
Path::new("/bin/false"),
|
||||
&config,
|
||||
));
|
||||
assert!(!eligible(
|
||||
"service",
|
||||
1000,
|
||||
Path::new("/definitely/missing"),
|
||||
Path::new("/bin/sh"),
|
||||
&config,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unavailable_faillock_helper_is_advisory() {
|
||||
let config = AccountsConfig {
|
||||
faillock_path: PathBuf::from("/bin/false"),
|
||||
..AccountsConfig::default()
|
||||
};
|
||||
let status = faillock_status("alice", &config).expect("false helper should be handled");
|
||||
assert_eq!(status, FaillockStatus::default());
|
||||
}
|
||||
}
|
||||
380
crates/latchd-core/src/config.rs
Normal file
380
crates/latchd-core/src/config.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
] {
|
||||
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());
|
||||
}
|
||||
}
|
||||
68
crates/latchd-core/src/error.rs
Normal file
68
crates/latchd-core/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::io;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// The application-wide error type.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// A command-line or configuration value was invalid.
|
||||
#[error("configuration: {0}")]
|
||||
Config(String),
|
||||
/// A filesystem operation failed.
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
/// The operation being performed.
|
||||
context: String,
|
||||
/// The underlying error.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
/// TOML could not be decoded.
|
||||
#[error("parse {path}: {source}")]
|
||||
Toml {
|
||||
/// The file being parsed.
|
||||
path: String,
|
||||
/// The parser error.
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
/// PAM rejected or could not complete an operation.
|
||||
#[error("PAM {operation} failed with status {status}: {message}")]
|
||||
Pam {
|
||||
/// The PAM operation.
|
||||
operation: &'static str,
|
||||
/// The numeric PAM status.
|
||||
status: i32,
|
||||
/// The human-readable PAM message.
|
||||
message: String,
|
||||
},
|
||||
/// A session process failed.
|
||||
#[error("session: {0}")]
|
||||
Session(String),
|
||||
/// The terminal could not be initialized or restored.
|
||||
#[error("terminal: {0}")]
|
||||
Terminal(String),
|
||||
/// An authentication worker request was cancelled or disconnected.
|
||||
#[error("authentication worker: {0}")]
|
||||
Worker(String),
|
||||
}
|
||||
|
||||
/// Adds a stable operation label to an I/O error.
|
||||
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
||||
Error::Io {
|
||||
context: context.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a failed result into an application error with context.
|
||||
pub trait ResultExt<T> {
|
||||
/// Adds context to an I/O operation.
|
||||
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
|
||||
}
|
||||
|
||||
impl<T> ResultExt<T> for io::Result<T> {
|
||||
fn io_context(self, context: impl Into<String>) -> Result<T, Error> {
|
||||
self.map_err(|source| io_error(context, source))
|
||||
}
|
||||
}
|
||||
15
crates/latchd-core/src/lib.rs
Normal file
15
crates/latchd-core/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Core configuration, account, session, and accounting contracts for latchd.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
/// Linux UTMPX accounting primitives.
|
||||
pub mod accounting;
|
||||
/// NSS account discovery and faillock status.
|
||||
pub mod accounts;
|
||||
/// Validated application configuration.
|
||||
pub mod config;
|
||||
/// Structured application errors.
|
||||
pub mod error;
|
||||
/// Session discovery and process lifecycle.
|
||||
pub mod session;
|
||||
861
crates/latchd-core/src/session.rs
Normal file
861
crates/latchd-core/src/session.rs
Normal file
@@ -0,0 +1,861 @@
|
||||
//! Session discovery, environment construction, and child lifecycle.
|
||||
|
||||
use std::{
|
||||
ffi::{CString, OsStr},
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
os::unix::{
|
||||
fs::{MetadataExt, OpenOptionsExt},
|
||||
io::AsRawFd,
|
||||
process::CommandExt,
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
accounting::UtmpxGuard,
|
||||
accounts::Identity,
|
||||
config::Config,
|
||||
error::{Error, ResultExt, io_error},
|
||||
};
|
||||
use rand::RngCore;
|
||||
use signal_hook::{consts::SIGUSR1, flag};
|
||||
|
||||
/// A session family supported by latchd.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionKind {
|
||||
/// A login shell on the current virtual terminal.
|
||||
Tty,
|
||||
/// An X11 desktop session.
|
||||
X11,
|
||||
/// A Wayland desktop session.
|
||||
Wayland,
|
||||
}
|
||||
|
||||
/// A discoverable desktop or script session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionDescriptor {
|
||||
/// Session family.
|
||||
pub kind: SessionKind,
|
||||
/// User-facing title.
|
||||
pub title: String,
|
||||
/// Program and arguments, without shell interpretation.
|
||||
pub argv: Vec<String>,
|
||||
/// XDG current-desktop value.
|
||||
pub current_desktop: Option<String>,
|
||||
/// XDG session-desktop value.
|
||||
pub session_desktop: Option<String>,
|
||||
}
|
||||
|
||||
/// A fully materialized session request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionSpec {
|
||||
/// Session family.
|
||||
pub kind: SessionKind,
|
||||
/// Session title for logs.
|
||||
pub title: String,
|
||||
/// Program and arguments.
|
||||
pub argv: Vec<String>,
|
||||
/// Explicit child environment.
|
||||
pub environment: Vec<(String, String)>,
|
||||
/// Working directory.
|
||||
pub working_directory: PathBuf,
|
||||
/// Private stdout/stderr log for graphical sessions.
|
||||
pub client_log_path: PathBuf,
|
||||
/// Login shell for TTY fallback.
|
||||
pub shell: PathBuf,
|
||||
/// Virtual terminal number.
|
||||
pub tty: u8,
|
||||
/// X11 configuration when this is an X11 session.
|
||||
pub x11: Option<X11Runtime>,
|
||||
}
|
||||
|
||||
/// Runtime X11 parameters passed to a session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct X11Runtime {
|
||||
/// X display name.
|
||||
pub display: String,
|
||||
/// X server command.
|
||||
pub server_path: PathBuf,
|
||||
/// Xauthority command.
|
||||
pub xauth_path: PathBuf,
|
||||
/// X server timeout.
|
||||
pub timeout: Duration,
|
||||
/// X server log path.
|
||||
pub log_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Discovers configured sessions in stable directory order.
|
||||
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
|
||||
let mut sessions = Vec::new();
|
||||
scan_desktop_dir(
|
||||
&config.x11.sessions_path,
|
||||
SessionKind::X11,
|
||||
&config.initial_path,
|
||||
&mut sessions,
|
||||
);
|
||||
scan_desktop_dir(
|
||||
&config.wayland.sessions_path,
|
||||
SessionKind::Wayland,
|
||||
&config.initial_path,
|
||||
&mut sessions,
|
||||
);
|
||||
scan_script_dir(&config.x11.scripts_path, SessionKind::X11, &mut sessions);
|
||||
scan_script_dir(
|
||||
&config.wayland.scripts_path,
|
||||
SessionKind::Wayland,
|
||||
&mut sessions,
|
||||
);
|
||||
if sessions.is_empty() || config.environment_switcher.include_tty_shell {
|
||||
sessions.push(SessionDescriptor {
|
||||
kind: SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
});
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Checks configured binaries and discovered session entries without starting them.
|
||||
pub fn preflight(config: &Config) -> Result<(), Error> {
|
||||
let shell = secure_executable(&config.system_shell, "configured system shell")?;
|
||||
if !shell.is_file() {
|
||||
return Err(Error::Config(format!(
|
||||
"configured system shell is not a regular file: {}",
|
||||
shell.display()
|
||||
)));
|
||||
}
|
||||
let sessions = discover(config);
|
||||
for session in &sessions {
|
||||
if session.kind == SessionKind::Tty {
|
||||
continue;
|
||||
}
|
||||
let Some(program) = session.argv.first() else {
|
||||
return Err(Error::Config(format!(
|
||||
"session {} has no executable",
|
||||
session.title
|
||||
)));
|
||||
};
|
||||
let executable = find_executable(program, &config.initial_path).ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"session {} executable is not available: {program}",
|
||||
session.title
|
||||
))
|
||||
})?;
|
||||
secure_executable(&executable, "session executable")?;
|
||||
}
|
||||
if sessions
|
||||
.iter()
|
||||
.any(|session| session.kind == SessionKind::X11)
|
||||
{
|
||||
secure_executable(&config.x11.server_path, "X11 server")?;
|
||||
secure_executable(&config.x11.xauth_path, "xauth")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_executable(program: &str, path: &str) -> Option<PathBuf> {
|
||||
let candidate = Path::new(program);
|
||||
if candidate.is_absolute() {
|
||||
return candidate.exists().then(|| candidate.to_owned());
|
||||
}
|
||||
path.split(':')
|
||||
.map(|directory| Path::new(directory).join(program))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
fn secure_executable(path: &Path, label: &str) -> Result<PathBuf, Error> {
|
||||
let resolved =
|
||||
fs::canonicalize(path).io_context(format!("resolve {label} {}", path.display()))?;
|
||||
let metadata = fs::symlink_metadata(&resolved)
|
||||
.io_context(format!("inspect {label} {}", resolved.display()))?;
|
||||
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"{label} must resolve to a root-owned, non-writable regular file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if metadata.mode() & 0o111 == 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"{label} is not executable: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Builds an explicit environment and process specification for one identity.
|
||||
pub fn build_spec(
|
||||
identity: &Identity,
|
||||
config: &Config,
|
||||
descriptor: &SessionDescriptor,
|
||||
) -> Result<SessionSpec, Error> {
|
||||
let mut environment = inherited_safe_environment();
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"HOME",
|
||||
identity.home.to_string_lossy().into_owned(),
|
||||
);
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"SHELL",
|
||||
identity.shell.to_string_lossy().into_owned(),
|
||||
);
|
||||
insert_env(&mut environment, "USER", identity.username.clone());
|
||||
insert_env(&mut environment, "LOGNAME", identity.username.clone());
|
||||
insert_env(&mut environment, "PATH", config.initial_path.clone());
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"PWD",
|
||||
identity.home.to_string_lossy().into_owned(),
|
||||
);
|
||||
insert_env(&mut environment, "XDG_SESSION_CLASS", "user".into());
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_SESSION_TYPE",
|
||||
match descriptor.kind {
|
||||
SessionKind::Tty => "tty",
|
||||
SessionKind::X11 => "x11",
|
||||
SessionKind::Wayland => "wayland",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
insert_env(&mut environment, "XDG_SEAT", "seat0".into());
|
||||
insert_env(&mut environment, "XDG_VTNR", config.tty.to_string());
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_RUNTIME_DIR",
|
||||
format!("/run/user/{}", identity.uid),
|
||||
);
|
||||
insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into());
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_CONFIG_HOME",
|
||||
identity.home.join(".config").to_string_lossy().into_owned(),
|
||||
);
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_CACHE_HOME",
|
||||
identity.home.join(".cache").to_string_lossy().into_owned(),
|
||||
);
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_DATA_HOME",
|
||||
identity
|
||||
.home
|
||||
.join(".local/share")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_STATE_HOME",
|
||||
identity
|
||||
.home
|
||||
.join(".local/state")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_DATA_DIRS",
|
||||
"/usr/local/share:/usr/share".into(),
|
||||
);
|
||||
insert_env(&mut environment, "XDG_CONFIG_DIRS", "/etc/xdg".into());
|
||||
|
||||
let x11 = if descriptor.kind == SessionKind::X11 {
|
||||
insert_env(&mut environment, "DISPLAY", config.x11.display.clone());
|
||||
Some(X11Runtime {
|
||||
display: config.x11.display.clone(),
|
||||
server_path: config.x11.server_path.clone(),
|
||||
xauth_path: config.x11.xauth_path.clone(),
|
||||
timeout: Duration::from_secs(config.x11.server_timeout_secs as u64),
|
||||
log_path: config.x11.server_log_path.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(value) = descriptor.current_desktop.as_deref() {
|
||||
insert_env(&mut environment, "XDG_CURRENT_DESKTOP", value.to_owned());
|
||||
}
|
||||
if let Some(value) = descriptor.session_desktop.as_deref() {
|
||||
insert_env(&mut environment, "XDG_SESSION_DESKTOP", value.to_owned());
|
||||
}
|
||||
|
||||
let mut argv = if descriptor.kind == SessionKind::Tty {
|
||||
Vec::new()
|
||||
} else if descriptor.argv.is_empty() {
|
||||
return Err(Error::Session(format!(
|
||||
"session {} has no executable",
|
||||
descriptor.title
|
||||
)));
|
||||
} else {
|
||||
descriptor.argv.clone()
|
||||
};
|
||||
if let Some(program) = argv.first_mut() {
|
||||
if !Path::new(program).is_absolute() {
|
||||
let resolved = find_executable(program, &config.initial_path).ok_or_else(|| {
|
||||
Error::Session(format!(
|
||||
"session {} executable is not available: {program}",
|
||||
descriptor.title
|
||||
))
|
||||
})?;
|
||||
*program = resolved.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
if !identity.shell.is_absolute() {
|
||||
return Err(Error::Session(
|
||||
"account shell must be an absolute path".into(),
|
||||
));
|
||||
}
|
||||
Ok(SessionSpec {
|
||||
kind: descriptor.kind,
|
||||
title: descriptor.title.clone(),
|
||||
argv,
|
||||
environment,
|
||||
working_directory: identity.home.clone(),
|
||||
client_log_path: config.client_log_path.clone(),
|
||||
shell: identity.shell.clone(),
|
||||
tty: config.tty,
|
||||
x11,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a user session and owns all child process cleanup.
|
||||
pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
|
||||
let home_metadata = fs::symlink_metadata(&identity.home)
|
||||
.map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?;
|
||||
if !home_metadata.is_dir()
|
||||
|| (home_metadata.uid() != identity.uid && home_metadata.uid() != 0)
|
||||
|| home_metadata.mode() & 0o022 != 0
|
||||
{
|
||||
return Err(Error::Session(format!(
|
||||
"home directory is missing or insecure: {}",
|
||||
identity.home.display()
|
||||
)));
|
||||
}
|
||||
if spec.kind == SessionKind::X11 {
|
||||
return run_x11(identity, spec);
|
||||
}
|
||||
let mut command = user_command(identity, spec)?;
|
||||
let mut child = command.spawn().io_context("spawn user session")?;
|
||||
let mut accounting = match UtmpxGuard::start(&identity.username, spec.tty, child.id() as _) {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => {
|
||||
let _ = terminate_child(&mut child);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let status = match child.wait().io_context("wait for user session") {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
let _ = terminate_child(&mut child);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if !status.success() {
|
||||
let _ = accounting.close();
|
||||
return Err(Error::Session(format!(
|
||||
"{} exited with {status}",
|
||||
spec.title
|
||||
)));
|
||||
}
|
||||
accounting.close()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn user_command(identity: &Identity, spec: &SessionSpec) -> Result<Command, Error> {
|
||||
let (program, args): (&OsStr, &[String]) = if spec.kind == SessionKind::Tty {
|
||||
(spec.shell.as_os_str(), &[] as &[String])
|
||||
} else {
|
||||
let program = spec
|
||||
.argv
|
||||
.first()
|
||||
.ok_or_else(|| Error::Session("empty session argv".into()))?;
|
||||
(OsStr::new(program), &spec.argv[1..])
|
||||
};
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(&spec.working_directory)
|
||||
.env_clear()
|
||||
.envs(spec.environment.iter().map(|(key, value)| (key, value)))
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
if spec.kind != SessionKind::Tty {
|
||||
let output = private_log(&spec.client_log_path)?;
|
||||
let error = output.try_clone().io_context("clone client log")?;
|
||||
command
|
||||
.stdout(Stdio::from(output))
|
||||
.stderr(Stdio::from(error));
|
||||
}
|
||||
install_user_credentials(&mut command, identity, spec.kind == SessionKind::Tty)?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn install_user_credentials(
|
||||
command: &mut Command,
|
||||
identity: &Identity,
|
||||
controlling_tty: bool,
|
||||
) -> Result<(), Error> {
|
||||
let username = CString::new(identity.username.as_str())
|
||||
.map_err(|_| Error::Session("username contains NUL".into()))?;
|
||||
let uid = identity.uid;
|
||||
let gid = identity.gid;
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if controlling_tty && libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if libc::initgroups(username.as_ptr(), gid) == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if libc::setgid(gid) == -1 || libc::setuid(uid) == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_x11(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
|
||||
let runtime = spec
|
||||
.x11
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Session("missing X11 runtime configuration".into()))?;
|
||||
let authority = create_xauthority(identity, runtime)?;
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
flag::register(SIGUSR1, Arc::clone(&ready))
|
||||
.map_err(|error| Error::Session(format!("register X11 readiness signal: {error}")))?;
|
||||
let x_log = private_log(&runtime.log_path)?;
|
||||
let x_log_err = x_log.try_clone().io_context("clone Xorg log")?;
|
||||
let mut server = Command::new(&runtime.server_path);
|
||||
let vt = format!("vt{}", spec.tty);
|
||||
server
|
||||
.args([runtime.display.as_str(), vt.as_str()])
|
||||
.arg("-auth")
|
||||
.arg(authority.path())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(x_log))
|
||||
.stderr(Stdio::from(x_log_err));
|
||||
unsafe {
|
||||
server.pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut xserver = server.spawn().io_context("spawn X server")?;
|
||||
let deadline = Instant::now() + runtime.timeout;
|
||||
while !ready.load(Ordering::Acquire) {
|
||||
if let Some(status) = xserver.try_wait().io_context("poll X server")? {
|
||||
return Err(Error::Session(format!("X server exited with {status}")));
|
||||
}
|
||||
if runtime.timeout != Duration::ZERO && Instant::now() >= deadline {
|
||||
terminate_child(&mut xserver)?;
|
||||
return Err(Error::Session("X server readiness timeout".into()));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
let mut environment = spec.environment.clone();
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XAUTHORITY",
|
||||
authority.path().to_string_lossy().into_owned(),
|
||||
);
|
||||
let mut client_spec = spec.clone();
|
||||
client_spec.environment = environment;
|
||||
let result = user_command(identity, &client_spec).and_then(|mut client| {
|
||||
let mut child = client.spawn().io_context("spawn X11 client")?;
|
||||
match child.wait().io_context("wait for X11 client") {
|
||||
Ok(status) => Ok(status),
|
||||
Err(error) => {
|
||||
let _ = terminate_child(&mut child);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
});
|
||||
let cleanup = terminate_child(&mut xserver);
|
||||
cleanup?;
|
||||
let result = result?;
|
||||
if !result.success() {
|
||||
return Err(Error::Session(format!("X11 client exited with {result}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct AuthorityFile(PathBuf);
|
||||
|
||||
impl AuthorityFile {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthorityFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_xauthority(identity: &Identity, runtime: &X11Runtime) -> Result<AuthorityFile, Error> {
|
||||
let mut cookie = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut cookie);
|
||||
let path = identity
|
||||
.home
|
||||
.join(format!(".Xauthority.latchd-{}", std::process::id()));
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.io_context(format!("create {}", path.display()))?;
|
||||
let owner = unsafe { libc::fchown(file.as_raw_fd(), identity.uid, identity.gid) };
|
||||
if owner == -1 {
|
||||
let error = io::Error::last_os_error();
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(io_error(format!("chown {}", path.display()), error));
|
||||
}
|
||||
drop(file);
|
||||
let mut command = Command::new(&runtime.xauth_path);
|
||||
command
|
||||
.arg("-f")
|
||||
.arg(&path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if let Err(error) = install_user_credentials(&mut command, identity, false) {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(error);
|
||||
}
|
||||
let mut child = match command.spawn().io_context("spawn xauth") {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
if let Err(error) = writeln!(stdin, "add {} . {}", runtime.display, hex_cookie(&cookie))
|
||||
.io_context("write xauth command")
|
||||
{
|
||||
let _ = terminate_child(&mut child);
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let status = match child.wait().io_context("wait for xauth") {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if !status.success() {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(Error::Session(format!("xauth exited with {status}")));
|
||||
}
|
||||
Ok(AuthorityFile(path))
|
||||
}
|
||||
|
||||
fn hex_cookie(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn terminate_child(child: &mut Child) -> Result<(), Error> {
|
||||
if child.try_wait().io_context("poll child")?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as i32), libc::SIGTERM);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
if child
|
||||
.try_wait()
|
||||
.io_context("wait for child termination")?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
child.wait().io_context("reap killed child")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn private_log(path: &Path) -> Result<File, 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())))?;
|
||||
let metadata = fs::symlink_metadata(parent)
|
||||
.io_context(format!("inspect log directory {}", parent.display()))?;
|
||||
if !metadata.is_dir() || metadata.uid() != 0 || 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()))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn inherited_safe_environment() -> Vec<(String, String)> {
|
||||
std::env::vars()
|
||||
.filter(|(key, _)| key == "TERM" || key == "LANG" || key.starts_with("LC_"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn insert_env(environment: &mut Vec<(String, String)>, key: &str, value: String) {
|
||||
if let Some((_, existing)) = environment.iter_mut().find(|(name, _)| name == key) {
|
||||
*existing = value;
|
||||
} else {
|
||||
environment.push((key.into(), value));
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_desktop_dir(
|
||||
path: &Path,
|
||||
kind: SessionKind,
|
||||
session_path: &str,
|
||||
output: &mut Vec<SessionDescriptor>,
|
||||
) {
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
if entry.path().extension().and_then(|value| value.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
let Ok(metadata) = fs::symlink_metadata(entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_file() || metadata.mode() & 0o022 != 0 || metadata.uid() != 0 {
|
||||
continue;
|
||||
}
|
||||
let Ok(raw) = fs::read_to_string(entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let fields = desktop_fields(&raw);
|
||||
if fields
|
||||
.get("Hidden")
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
|
||||
|| fields
|
||||
.get("NoDisplay")
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if fields
|
||||
.get("Type")
|
||||
.is_some_and(|value| !value.eq_ignore_ascii_case("Application"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(try_exec) = fields.get("TryExec")
|
||||
&& find_executable(try_exec, session_path).is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(exec) = fields.get("Exec").and_then(|value| parse_argv(value).ok()) else {
|
||||
continue;
|
||||
};
|
||||
if exec.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = fields
|
||||
.get("Name")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned());
|
||||
let (current_desktop, session_desktop) = fields
|
||||
.get("DesktopNames")
|
||||
.map(|value| {
|
||||
(
|
||||
Some(value.replace(';', ":")),
|
||||
value.split(';').next().map(str::to_owned),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
output.push(SessionDescriptor {
|
||||
kind,
|
||||
title,
|
||||
argv: exec,
|
||||
current_desktop,
|
||||
session_desktop,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_script_dir(path: &Path, kind: SessionKind, output: &mut Vec<SessionDescriptor>) {
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_file()
|
||||
|| metadata.mode() & 0o111 == 0
|
||||
|| metadata.mode() & 0o022 != 0
|
||||
|| metadata.uid() != 0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let file_name = entry.file_name();
|
||||
let Some(title) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
output.push(SessionDescriptor {
|
||||
kind,
|
||||
title: title.into(),
|
||||
argv: vec![path.to_string_lossy().into_owned()],
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_fields(raw: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut fields = std::collections::HashMap::new();
|
||||
let mut in_desktop = false;
|
||||
for line in raw.lines().map(str::trim) {
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
in_desktop = line == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if !in_desktop || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
fields.insert(key.trim().into(), value.trim().into());
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn parse_argv(value: &str) -> Result<Vec<String>, Error> {
|
||||
let mut output = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
for character in value.chars() {
|
||||
if escaped {
|
||||
current.push(character);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match (quote, character) {
|
||||
(_, '\\') => escaped = true,
|
||||
(Some(expected), character) if expected == character => quote = None,
|
||||
(None, '\'' | '"') => quote = Some(character),
|
||||
(None, character) if character.is_whitespace() => {
|
||||
if !current.is_empty() {
|
||||
output.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
(_, character) => current.push(character),
|
||||
}
|
||||
}
|
||||
if escaped || quote.is_some() {
|
||||
return Err(Error::Session("unterminated session command quote".into()));
|
||||
}
|
||||
if !current.is_empty() {
|
||||
output.push(current);
|
||||
}
|
||||
if output.iter().any(|argument| argument.contains('%')) {
|
||||
return Err(Error::Session(
|
||||
"desktop Exec field codes are not supported".into(),
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[test]
|
||||
fn parses_argv_without_shell_expansion() {
|
||||
let argv = parse_argv("/bin/example --title 'hello world'").expect("valid argv");
|
||||
assert_eq!(argv, ["/bin/example", "--title", "hello world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_field_codes() {
|
||||
assert!(parse_argv("foo %f").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_width_is_not_byte_length() {
|
||||
assert_eq!(UnicodeWidthStr::width("界"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_does_not_copy_display_or_xauthority() {
|
||||
let identity = Identity {
|
||||
username: "alice".into(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: "/home/alice".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
};
|
||||
let descriptor = SessionDescriptor {
|
||||
kind: SessionKind::Wayland,
|
||||
title: "test".into(),
|
||||
argv: vec!["/bin/true".into()],
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
};
|
||||
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
|
||||
assert!(
|
||||
spec.environment
|
||||
.iter()
|
||||
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
|
||||
);
|
||||
}
|
||||
}
|
||||
16
crates/latchd-tui/Cargo.toml
Normal file
16
crates/latchd-tui/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "latchd-tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] }
|
||||
latchd-auth = { path = "../latchd-auth" }
|
||||
latchd-core = { path = "../latchd-core" }
|
||||
ratatui = "0.30.2"
|
||||
tracing = "0.1"
|
||||
unicode-width = "0.2"
|
||||
zeroize = "1.8"
|
||||
267
crates/latchd-tui/src/background.rs
Normal file
267
crates/latchd-tui/src/background.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
//! Low-cost terminal wallpaper and procedural ambient animation.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use image::{ImageReader, RgbImage, imageops::FilterType};
|
||||
use latchd_core::{
|
||||
config::VisualConfig,
|
||||
error::{Error, ResultExt},
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
prelude::{Buffer, Color, Rect},
|
||||
};
|
||||
|
||||
const MAX_IMAGE_SIDE: u32 = 480;
|
||||
const MAX_SOURCE_SIDE: u32 = 8_192;
|
||||
const MAX_SOURCE_PIXELS: u64 = 16_777_216;
|
||||
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
|
||||
|
||||
/// Background state owned by the TUI thread.
|
||||
pub(crate) struct Background {
|
||||
wallpaper: Option<RgbImage>,
|
||||
automata: Automata,
|
||||
enabled: bool,
|
||||
animations: bool,
|
||||
reduced_motion: bool,
|
||||
last_tick: Instant,
|
||||
}
|
||||
|
||||
impl Background {
|
||||
/// Loads a bounded wallpaper image and prepares the procedural layer.
|
||||
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
|
||||
let wallpaper = config
|
||||
.wallpaper_path
|
||||
.as_deref()
|
||||
.map(|path| load_wallpaper(path, config.max_wallpaper_bytes))
|
||||
.transpose()?;
|
||||
Ok(Self {
|
||||
wallpaper,
|
||||
automata: Automata::default(),
|
||||
enabled: config.background_enabled,
|
||||
animations: config.animations,
|
||||
reduced_motion: config.reduced_motion,
|
||||
last_tick: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn tick_if_due(&mut self, now: Instant) -> bool {
|
||||
if !self.enabled
|
||||
|| !self.animations
|
||||
|| self.reduced_motion
|
||||
|| now.duration_since(self.last_tick) < FRAME_INTERVAL
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.automata.tick();
|
||||
self.last_tick = now;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_enabled(&mut self) -> bool {
|
||||
self.enabled = !self.enabled;
|
||||
self.enabled
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_motion(&mut self) -> bool {
|
||||
self.reduced_motion = !self.reduced_motion;
|
||||
self.reduced_motion
|
||||
}
|
||||
|
||||
pub(crate) fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, ascii: bool, no_color: bool) {
|
||||
if !self.enabled || area.width < 80 || area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
self.automata
|
||||
.resize(area.width as usize, area.height as usize);
|
||||
let buffer = frame.buffer_mut();
|
||||
draw_wallpaper(buffer, area, self.wallpaper.as_ref(), no_color);
|
||||
draw_automata(buffer, area, &self.automata, ascii, no_color);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_wallpaper(path: &Path, max_bytes: u64) -> Result<RgbImage, Error> {
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).io_context(format!("inspect wallpaper {}", path.display()))?;
|
||||
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"wallpaper must be a root-owned, non-group-writable regular file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if metadata.len() > max_bytes {
|
||||
return Err(Error::Config(format!(
|
||||
"wallpaper exceeds visual.max_wallpaper_bytes: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let reader = ImageReader::open(path)
|
||||
.io_context(format!("open wallpaper {}", path.display()))?
|
||||
.with_guessed_format()
|
||||
.io_context(format!("inspect wallpaper format {}", path.display()))?;
|
||||
let (width, height) = reader.into_dimensions().map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"inspect wallpaper dimensions {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_SOURCE_SIDE
|
||||
|| height > MAX_SOURCE_SIDE
|
||||
|| u64::from(width).saturating_mul(u64::from(height)) > MAX_SOURCE_PIXELS
|
||||
{
|
||||
return Err(Error::Config(format!(
|
||||
"wallpaper dimensions exceed {MAX_SOURCE_SIDE}x{MAX_SOURCE_SIDE} or {MAX_SOURCE_PIXELS} pixels"
|
||||
)));
|
||||
}
|
||||
let image = ImageReader::open(path)
|
||||
.io_context(format!("open wallpaper {}", path.display()))?
|
||||
.with_guessed_format()
|
||||
.io_context(format!("inspect wallpaper format {}", path.display()))?
|
||||
.decode()
|
||||
.map_err(|error| Error::Config(format!("decode wallpaper {}: {error}", path.display())))?;
|
||||
if image.width() == 0 || image.height() == 0 {
|
||||
return Err(Error::Config(
|
||||
"wallpaper dimensions must be non-zero".into(),
|
||||
));
|
||||
}
|
||||
let image = image.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, FilterType::Triangle);
|
||||
Ok(image.to_rgb8())
|
||||
}
|
||||
|
||||
fn draw_wallpaper(buffer: &mut Buffer, area: Rect, wallpaper: Option<&RgbImage>, no_color: bool) {
|
||||
let Some(image) = wallpaper else { return };
|
||||
if no_color {
|
||||
return;
|
||||
}
|
||||
let width = image.width().max(1);
|
||||
let height = image.height().max(1);
|
||||
for y in 0..area.height {
|
||||
for x in 0..area.width {
|
||||
let source_x = (u32::from(x) * width / u32::from(area.width)).min(width - 1);
|
||||
let source_y = (u32::from(y) * height / u32::from(area.height)).min(height - 1);
|
||||
let pixel = image.get_pixel(source_x, source_y).0;
|
||||
if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) {
|
||||
cell.set_symbol(" ");
|
||||
cell.set_bg(Color::Rgb(pixel[0] / 3, pixel[1] / 3, pixel[2] / 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_automata(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
automata: &Automata,
|
||||
ascii: bool,
|
||||
no_color: bool,
|
||||
) {
|
||||
for y in 0..area.height as usize {
|
||||
for x in 0..area.width as usize {
|
||||
if !automata.alive(x, y) {
|
||||
continue;
|
||||
}
|
||||
let x = x as u16;
|
||||
let y = y as u16;
|
||||
if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) {
|
||||
cell.set_symbol(if ascii { "." } else { "·" });
|
||||
if !no_color {
|
||||
cell.set_fg(Color::Rgb(60, 100, 120));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Automata {
|
||||
width: usize,
|
||||
height: usize,
|
||||
current: Vec<u8>,
|
||||
next: Vec<u8>,
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
impl Automata {
|
||||
fn resize(&mut self, width: usize, height: usize) {
|
||||
if self.width == width && self.height == height {
|
||||
return;
|
||||
}
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.current = vec![0; width.saturating_mul(height)];
|
||||
self.next = vec![0; width.saturating_mul(height)];
|
||||
self.seed = self.seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
for index in 0..self.current.len() {
|
||||
self.current[index] = u8::from(self.random() % 11 == 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
if self.width == 0 || self.height == 0 {
|
||||
return;
|
||||
}
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
let neighbors = self.neighbors(x, y);
|
||||
let index = y * self.width + x;
|
||||
self.next[index] =
|
||||
u8::from(neighbors == 3 || (neighbors == 2 && self.current[index] == 1));
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut self.current, &mut self.next);
|
||||
}
|
||||
|
||||
fn neighbors(&self, x: usize, y: usize) -> u8 {
|
||||
let mut count = 0;
|
||||
for dy in [self.height.wrapping_sub(1), 0, 1] {
|
||||
for dx in [self.width.wrapping_sub(1), 0, 1] {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let nx = (x + dx) % self.width;
|
||||
let ny = (y + dy) % self.height;
|
||||
count += self.current[ny * self.width + nx];
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn alive(&self, x: usize, y: usize) -> bool {
|
||||
self.current
|
||||
.get(y.saturating_mul(self.width).saturating_add(x))
|
||||
.copied()
|
||||
== Some(1)
|
||||
}
|
||||
|
||||
fn random(&mut self) -> u64 {
|
||||
let mut value = self.seed;
|
||||
value ^= value << 13;
|
||||
value ^= value >> 7;
|
||||
value ^= value << 17;
|
||||
self.seed = value;
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automata_is_bounded_and_progresses() {
|
||||
let mut automata = Automata::default();
|
||||
automata.resize(8, 4);
|
||||
assert_eq!(automata.current.len(), 32);
|
||||
automata.tick();
|
||||
assert_eq!(automata.current.len(), 32);
|
||||
}
|
||||
}
|
||||
901
crates/latchd-tui/src/lib.rs
Normal file
901
crates/latchd-tui/src/lib.rs
Normal file
@@ -0,0 +1,901 @@
|
||||
//! Ratatui login screen and deterministic state machine.
|
||||
|
||||
use std::{
|
||||
sync::mpsc::{self, Receiver},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::{
|
||||
DefaultTerminal,
|
||||
prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use latchd_auth::{AuthService, AuthTicket};
|
||||
use latchd_core::{
|
||||
accounts::{self, FaillockStatus, Identity},
|
||||
config::Config,
|
||||
error::{Error, ResultExt},
|
||||
session::{self, SessionDescriptor},
|
||||
};
|
||||
|
||||
mod background;
|
||||
use background::Background;
|
||||
|
||||
const MIN_WIDTH: u16 = 60;
|
||||
const MIN_HEIGHT: u16 = 16;
|
||||
const MAX_INPUT: usize = 256;
|
||||
|
||||
/// The result of one completed login-screen interaction.
|
||||
pub struct LoginSelection {
|
||||
/// Authenticated PAM ticket.
|
||||
pub ticket: AuthTicket,
|
||||
/// Selected session descriptor.
|
||||
pub session: SessionDescriptor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Sessions,
|
||||
Username,
|
||||
Password,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AuthState {
|
||||
Idle,
|
||||
Pending(Receiver<Result<AuthTicket, Error>>),
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum NoticeLevel {
|
||||
Info,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Notice {
|
||||
message: String,
|
||||
level: NoticeLevel,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
struct App {
|
||||
config: Config,
|
||||
auth: AuthService,
|
||||
preview: bool,
|
||||
sessions: Vec<SessionDescriptor>,
|
||||
accounts: Vec<Identity>,
|
||||
account_index: Option<usize>,
|
||||
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
|
||||
faillock_status: Option<FaillockStatus>,
|
||||
selected: usize,
|
||||
focus: Focus,
|
||||
username: String,
|
||||
password: Zeroizing<String>,
|
||||
status: String,
|
||||
auth_state: AuthState,
|
||||
show_help: bool,
|
||||
power_confirmation: Option<usize>,
|
||||
should_quit: bool,
|
||||
ascii: bool,
|
||||
no_color: bool,
|
||||
background: Background,
|
||||
notice: Option<Notice>,
|
||||
animation_frame: usize,
|
||||
reduced_motion: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(
|
||||
config: Config,
|
||||
auth: AuthService,
|
||||
preview: bool,
|
||||
sessions: Vec<SessionDescriptor>,
|
||||
accounts: Vec<Identity>,
|
||||
background: Background,
|
||||
) -> Self {
|
||||
let username = accounts
|
||||
.first()
|
||||
.map(|account| account.username.clone())
|
||||
.unwrap_or_default();
|
||||
let account_index = (!accounts.is_empty()).then_some(0);
|
||||
let reduced_motion = config.visual.reduced_motion;
|
||||
Self {
|
||||
config,
|
||||
auth,
|
||||
preview,
|
||||
sessions,
|
||||
accounts,
|
||||
account_index,
|
||||
faillock_receiver: None,
|
||||
faillock_status: None,
|
||||
selected: 0,
|
||||
focus: Focus::Username,
|
||||
username,
|
||||
password: Zeroizing::new(String::new()),
|
||||
status: "Enter your username".into(),
|
||||
auth_state: AuthState::Idle,
|
||||
show_help: false,
|
||||
power_confirmation: None,
|
||||
should_quit: false,
|
||||
ascii: std::env::var_os("LATCHD_ASCII").is_some(),
|
||||
no_color: std::env::var_os("NO_COLOR").is_some(),
|
||||
background,
|
||||
notice: None,
|
||||
animation_frame: 0,
|
||||
reduced_motion,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
|
||||
loop {
|
||||
self.poll_faillock();
|
||||
let now = Instant::now();
|
||||
if self.background.tick_if_due(now) {
|
||||
self.animation_frame = self.animation_frame.wrapping_add(1);
|
||||
}
|
||||
if self
|
||||
.notice
|
||||
.as_ref()
|
||||
.is_some_and(|notice| notice.expires <= now)
|
||||
{
|
||||
self.notice = None;
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| self.draw(frame))
|
||||
.map_err(|error| Error::Terminal(error.to_string()))?;
|
||||
if let Some(result) = self.poll_auth() {
|
||||
match result {
|
||||
Ok(ticket) => {
|
||||
let selected = self
|
||||
.sessions
|
||||
.get(self.selected)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Session("no session selected".into()))?;
|
||||
return Ok(Some(LoginSelection {
|
||||
ticket,
|
||||
session: selected,
|
||||
}));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "authentication attempt failed");
|
||||
self.auth_state = AuthState::Failed;
|
||||
self.password.clear();
|
||||
self.status = "Authentication failed".into();
|
||||
self.notify("Authentication failed", NoticeLevel::Warning);
|
||||
self.start_faillock_query();
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.should_quit {
|
||||
return Ok(None);
|
||||
}
|
||||
if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? {
|
||||
if let Event::Key(key) = event::read().io_context("read terminal event")? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
self.handle_key(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_auth(&mut self) -> Option<Result<AuthTicket, Error>> {
|
||||
let AuthState::Pending(receiver) = &self.auth_state else {
|
||||
return None;
|
||||
};
|
||||
match receiver.try_recv() {
|
||||
Ok(result) => Some(result),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => Some(Err(Error::Worker(
|
||||
"authentication task disconnected".into(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_faillock(&mut self) {
|
||||
let Some(receiver) = &self.faillock_receiver else {
|
||||
return;
|
||||
};
|
||||
match receiver.try_recv() {
|
||||
Ok(Ok(status)) => {
|
||||
self.faillock_status = Some(status);
|
||||
self.faillock_receiver = None;
|
||||
if status.available {
|
||||
if status.locked_out {
|
||||
self.notify(
|
||||
"Account has reached the advisory faillock threshold",
|
||||
NoticeLevel::Warning,
|
||||
);
|
||||
} else if status.failures > 0 {
|
||||
self.notify(
|
||||
&format!("{} failed attempt(s) recorded", status.failures),
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::debug!(%error, "faillock query failed");
|
||||
self.faillock_receiver = None;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_faillock_query(&mut self) {
|
||||
let username = self.username.clone();
|
||||
if username.is_empty() {
|
||||
self.faillock_receiver = None;
|
||||
self.faillock_status = None;
|
||||
return;
|
||||
}
|
||||
let config = self.config.accounts.clone();
|
||||
let (sender, receiver) = mpsc::sync_channel(1);
|
||||
self.faillock_receiver = Some(receiver);
|
||||
let task = thread::Builder::new()
|
||||
.name("latchd-faillock".into())
|
||||
.spawn(move || {
|
||||
let result = accounts::faillock_status(&username, &config);
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
if let Err(error) = task {
|
||||
tracing::debug!(%error, "could not start faillock query");
|
||||
self.faillock_receiver = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(&mut self, message: &str, level: NoticeLevel) {
|
||||
if self.config.visual.notifications {
|
||||
self.notice = Some(Notice {
|
||||
message: message.to_owned(),
|
||||
level,
|
||||
expires: Instant::now() + Duration::from_secs(4),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
self.should_quit = true;
|
||||
return;
|
||||
}
|
||||
if matches!(self.auth_state, AuthState::Pending(_)) {
|
||||
if key.code == KeyCode::Esc {
|
||||
self.status = "Authentication is still in progress".into();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.show_help {
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
|
||||
self.show_help = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
KeyCode::Char('n') => {
|
||||
self.next_account();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
self.previous_account();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
let enabled = self.background.toggle_enabled();
|
||||
self.notify(
|
||||
if enabled {
|
||||
"Ambient background enabled"
|
||||
} else {
|
||||
"Ambient background disabled"
|
||||
},
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
let reduced = self.background.toggle_motion();
|
||||
self.reduced_motion = reduced;
|
||||
self.notify(
|
||||
if reduced {
|
||||
"Reduced motion enabled"
|
||||
} else {
|
||||
"Reduced motion disabled"
|
||||
},
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(index) = self.power_confirmation {
|
||||
if key.code == KeyCode::Enter {
|
||||
self.run_power_action(index);
|
||||
} else if key.code == KeyCode::Esc {
|
||||
self.power_confirmation = None;
|
||||
self.status = "Power action cancelled".into();
|
||||
}
|
||||
return;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('?') => self.show_help = true,
|
||||
KeyCode::Esc => {
|
||||
self.password.clear();
|
||||
self.status = "Login cancelled".into();
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => self.next_focus(),
|
||||
KeyCode::BackTab | KeyCode::Up => self.previous_focus(),
|
||||
KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(),
|
||||
KeyCode::Right if self.focus == Focus::Sessions => self.next_session(),
|
||||
KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(),
|
||||
KeyCode::F(number) => self.confirm_power_action(number),
|
||||
KeyCode::Backspace => self.backspace(),
|
||||
KeyCode::Char(character)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
self.insert(character)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_power_action(&mut self, number: u8) {
|
||||
if self.preview {
|
||||
self.status = "Preview mode — power actions are disabled".into();
|
||||
return;
|
||||
}
|
||||
let key = format!("F{number}");
|
||||
if let Some((index, action)) = self
|
||||
.config
|
||||
.power_controls
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, action)| action.key.eq_ignore_ascii_case(&key))
|
||||
{
|
||||
self.power_confirmation = Some(index);
|
||||
self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_power_action(&mut self, index: usize) {
|
||||
if self.preview {
|
||||
self.power_confirmation = None;
|
||||
self.status = "Preview mode — power actions are disabled".into();
|
||||
return;
|
||||
}
|
||||
let Some(action) = self.config.power_controls.entries.get(index).cloned() else {
|
||||
self.power_confirmation = None;
|
||||
self.status = "Power action is no longer available".into();
|
||||
return;
|
||||
};
|
||||
if !action.program.is_absolute() {
|
||||
self.power_confirmation = None;
|
||||
self.status = "Power action must use an absolute program path".into();
|
||||
return;
|
||||
}
|
||||
match std::process::Command::new(&action.program)
|
||||
.args(&action.args)
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => {
|
||||
self.power_confirmation = None;
|
||||
self.status = format!("Started {}", action.hint);
|
||||
}
|
||||
Err(error) => {
|
||||
self.power_confirmation = None;
|
||||
self.status = format!("Could not start {}: {error}", action.hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_authentication(&mut self) {
|
||||
if self.preview {
|
||||
self.status = "Preview mode — authentication and session startup are disabled".into();
|
||||
return;
|
||||
}
|
||||
if self.username.is_empty() || self.password.is_empty() {
|
||||
self.status = "Username and password are required".into();
|
||||
return;
|
||||
}
|
||||
let username = self.username.clone();
|
||||
let password = self.password.as_bytes().to_vec();
|
||||
let service = self.config.pam_service.clone();
|
||||
let auth = self.auth.clone();
|
||||
let (sender, receiver) = mpsc::sync_channel(0);
|
||||
let task = thread::Builder::new()
|
||||
.name("latchd-auth-attempt".into())
|
||||
.spawn(move || {
|
||||
let result = auth.authenticate(username, password, service);
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
match task {
|
||||
Ok(_) => {
|
||||
self.auth_state = AuthState::Pending(receiver);
|
||||
self.status = "Authenticating…".into();
|
||||
self.password.clear();
|
||||
}
|
||||
Err(error) => self.status = format!("Could not start authentication: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, character: char) {
|
||||
if self.focus == Focus::Username {
|
||||
self.account_index = None;
|
||||
}
|
||||
let target = match self.focus {
|
||||
Focus::Username => &mut self.username,
|
||||
Focus::Password => &mut self.password,
|
||||
Focus::Sessions => return,
|
||||
};
|
||||
if target.chars().count() < MAX_INPUT {
|
||||
target.push(character);
|
||||
}
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
if self.focus == Focus::Username {
|
||||
self.account_index = None;
|
||||
}
|
||||
let target = match self.focus {
|
||||
Focus::Username => &mut self.username,
|
||||
Focus::Password => &mut self.password,
|
||||
Focus::Sessions => return,
|
||||
};
|
||||
target.pop();
|
||||
}
|
||||
|
||||
fn next_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Focus::Sessions => Focus::Username,
|
||||
Focus::Username => Focus::Password,
|
||||
Focus::Password => Focus::Sessions,
|
||||
};
|
||||
self.update_hint();
|
||||
}
|
||||
|
||||
fn previous_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Focus::Sessions => Focus::Password,
|
||||
Focus::Username => Focus::Sessions,
|
||||
Focus::Password => Focus::Username,
|
||||
};
|
||||
self.update_hint();
|
||||
}
|
||||
|
||||
fn update_hint(&mut self) {
|
||||
self.status = match self.focus {
|
||||
Focus::Sessions => "Choose a session with ←/→".into(),
|
||||
Focus::Username => "Enter your username".into(),
|
||||
Focus::Password => "Enter your password and press Enter".into(),
|
||||
};
|
||||
}
|
||||
|
||||
fn next_session(&mut self) {
|
||||
if !self.sessions.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.sessions.len();
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_session(&mut self) {
|
||||
if !self.sessions.is_empty() {
|
||||
self.selected = self
|
||||
.selected
|
||||
.checked_sub(1)
|
||||
.unwrap_or(self.sessions.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn next_account(&mut self) {
|
||||
self.select_account(1);
|
||||
}
|
||||
|
||||
fn previous_account(&mut self) {
|
||||
self.select_account(-1);
|
||||
}
|
||||
|
||||
fn select_account(&mut self, delta: isize) {
|
||||
if self.accounts.is_empty() {
|
||||
self.notify(
|
||||
"No eligible accounts discovered; enter a username manually",
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let current = self.account_index.unwrap_or(0) as isize;
|
||||
let length = self.accounts.len() as isize;
|
||||
let index = (current + delta).rem_euclid(length) as usize;
|
||||
self.account_index = Some(index);
|
||||
self.username = self.accounts[index].username.clone();
|
||||
self.password.clear();
|
||||
self.auth_state = AuthState::Idle;
|
||||
self.faillock_status = None;
|
||||
self.status = format!("Selected account {}", self.username);
|
||||
self.start_faillock_query();
|
||||
self.notify(
|
||||
&format!("Selected account {}", self.username),
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut ratatui::Frame) {
|
||||
let area = frame.area();
|
||||
self.background.draw(frame, area, self.ascii, self.no_color);
|
||||
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}"
|
||||
))
|
||||
.alignment(ratatui::prelude::Alignment::Center),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
let title = Paragraph::new(Line::from(vec![
|
||||
Span::styled("latchd", self.style_accent()),
|
||||
Span::raw(" secure terminal login"),
|
||||
]))
|
||||
.alignment(ratatui::prelude::Alignment::Center);
|
||||
frame.render_widget(title, vertical[0]);
|
||||
|
||||
let body = if area.width < 80 {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(5), Constraint::Length(8)])
|
||||
.split(vertical[1])
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
|
||||
.split(vertical[1])
|
||||
};
|
||||
let session_area = body[0];
|
||||
let form_area = if body.len() == 2 {
|
||||
body[1]
|
||||
} else {
|
||||
vertical[1]
|
||||
};
|
||||
let session_items: Vec<ListItem> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, session)| {
|
||||
let marker = if index == self.selected {
|
||||
if self.ascii { ">" } else { "▶" }
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
ListItem::new(format!(
|
||||
"{marker} {}",
|
||||
truncate_cells(
|
||||
&session.title,
|
||||
self.config.environment_switcher.max_display_length
|
||||
)
|
||||
))
|
||||
.style(if index == self.selected {
|
||||
self.style_selected()
|
||||
} else {
|
||||
self.style_muted()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sessions =
|
||||
List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions));
|
||||
frame.render_widget(sessions, session_area);
|
||||
|
||||
let fields = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(form_area);
|
||||
let account_title = self.account_title();
|
||||
let username = Paragraph::new(self.username.as_str())
|
||||
.block(self.block(&account_title, self.focus == Focus::Username));
|
||||
let password_marker = if self.ascii { "*" } else { "•" };
|
||||
let password = Paragraph::new(password_marker.repeat(self.password.chars().count()))
|
||||
.block(self.block("Password", self.focus == Focus::Password));
|
||||
frame.render_widget(username, fields[0]);
|
||||
frame.render_widget(password, fields[1]);
|
||||
let status = self.status_line();
|
||||
frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help",
|
||||
),
|
||||
vertical[2],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new("PAM authentication • session output stays off the login screen"),
|
||||
vertical[3],
|
||||
);
|
||||
if self.show_help {
|
||||
self.draw_help(frame, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let popup = centered_rect(70, 50, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help")
|
||||
.block(self.block("Help", false)).wrap(Wrap { trim: true }), popup);
|
||||
}
|
||||
|
||||
fn block(&self, title: &str, focused: bool) -> Block<'static> {
|
||||
let mut block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_owned())
|
||||
.border_style(if focused {
|
||||
self.style_accent()
|
||||
} else {
|
||||
self.style_muted()
|
||||
});
|
||||
if !self.no_color {
|
||||
block = block.style(Style::default().bg(ratatui::prelude::Color::Black));
|
||||
}
|
||||
block
|
||||
}
|
||||
|
||||
fn account_title(&self) -> String {
|
||||
self.account_index
|
||||
.map(|index| format!("Username {}/{}", index + 1, self.accounts.len()))
|
||||
.unwrap_or_else(|| "Username".into())
|
||||
}
|
||||
|
||||
fn status_line(&self) -> String {
|
||||
let mut status = self.status.clone();
|
||||
if matches!(self.auth_state, AuthState::Pending(_)) {
|
||||
let spinner = if self.config.visual.animations && !self.reduced_motion {
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
.get(self.animation_frame % 10)
|
||||
.copied()
|
||||
.unwrap_or("⠋")
|
||||
} else if self.ascii {
|
||||
"|"
|
||||
} else {
|
||||
"…"
|
||||
};
|
||||
status = format!("{spinner} Authenticating…");
|
||||
}
|
||||
if let Some(lockout) = self.faillock_status {
|
||||
if lockout.locked_out {
|
||||
status
|
||||
.push_str(" • advisory faillock threshold reached; PAM remains authoritative");
|
||||
} else if lockout.failures > 0 {
|
||||
let remaining = lockout
|
||||
.remaining
|
||||
.map_or_else(|| "unknown".into(), |value| value.to_string());
|
||||
status.push_str(&format!(
|
||||
" • {} failed attempt(s), {remaining} remaining",
|
||||
lockout.failures
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(notice) = &self.notice {
|
||||
let marker = match notice.level {
|
||||
NoticeLevel::Info => {
|
||||
if self.ascii {
|
||||
"[i]"
|
||||
} else {
|
||||
"ℹ"
|
||||
}
|
||||
}
|
||||
NoticeLevel::Warning => {
|
||||
if self.ascii {
|
||||
"[!]"
|
||||
} else {
|
||||
"⚠"
|
||||
}
|
||||
}
|
||||
};
|
||||
status.push_str(&format!(" {marker} {}", notice.message));
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn style_accent(&self) -> Style {
|
||||
if self.no_color {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Cyan)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
}
|
||||
}
|
||||
fn style_selected(&self) -> Style {
|
||||
if self.no_color {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Yellow)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
}
|
||||
}
|
||||
fn style_muted(&self) -> Style {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::DIM)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the login UI and restores the terminal on every return path.
|
||||
pub fn run(
|
||||
config: Config,
|
||||
auth: AuthService,
|
||||
preview: bool,
|
||||
) -> Result<Option<LoginSelection>, Error> {
|
||||
let sessions = session::discover(&config);
|
||||
let accounts = if config.accounts.enumerate {
|
||||
accounts::discover(&config.accounts)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let background = Background::load(&config.visual)?;
|
||||
let mut app = App::new(config, auth, preview, sessions, accounts, background);
|
||||
app.account_index = (!app.accounts.is_empty()).then_some(0);
|
||||
app.start_faillock_query();
|
||||
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
|
||||
}
|
||||
|
||||
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
Constraint::Percentage(height_percent),
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
])
|
||||
.split(area);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
Constraint::Percentage(width_percent),
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
fn truncate_cells(value: &str, width: usize) -> String {
|
||||
if UnicodeWidthStr::width(value) <= width {
|
||||
return value.into();
|
||||
}
|
||||
let mut output = String::new();
|
||||
let mut used = 0;
|
||||
for character in value.chars() {
|
||||
let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1);
|
||||
if used + character_width + 1 > width {
|
||||
break;
|
||||
}
|
||||
output.push(character);
|
||||
used += character_width;
|
||||
}
|
||||
output.push('…');
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
#[test]
|
||||
fn truncation_respects_terminal_cells() {
|
||||
assert_eq!(truncate_cells("界界界", 5), "界界…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_the_login_screen_at_the_floor() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
vec![SessionDescriptor {
|
||||
kind: session::SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame))
|
||||
.expect("frame should render");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let rendered = buffer
|
||||
.content()
|
||||
.chunks(buffer.area.width as usize)
|
||||
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("latchd"));
|
||||
assert!(rendered.contains("Username"));
|
||||
assert!(rendered.contains("Password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_truthful_too_small_state() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
let backend = TestBackend::new(59, 15);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame))
|
||||
.expect("frame should render");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let rendered = buffer
|
||||
.content()
|
||||
.chunks(buffer.area.width as usize)
|
||||
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("terminal too small"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_accounts_clears_password_and_auth_state() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let accounts = vec![
|
||||
Identity {
|
||||
username: "alice".into(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: "/home/alice".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
},
|
||||
Identity {
|
||||
username: "bob".into(),
|
||||
uid: 1001,
|
||||
gid: 1001,
|
||||
home: "/home/bob".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
},
|
||||
];
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
Vec::new(),
|
||||
accounts,
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.password = Zeroizing::new("secret".into());
|
||||
app.next_account();
|
||||
assert_eq!(app.username, "bob");
|
||||
assert!(app.password.is_empty());
|
||||
assert!(matches!(app.auth_state, AuthState::Idle));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user