feat: complete session preferences and PAM fixture gates
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Fixed Wayland compositors launched through user services, including Niri, by
|
||||||
|
providing the selected session type, desktop, seat, and VT to PAM before
|
||||||
|
`pam_open_session`.
|
||||||
|
- Session children now receive allowlisted runtime values produced by PAM,
|
||||||
|
including the real logind session ID, runtime directory, and user D-Bus
|
||||||
|
address. Latchd no longer fabricates `XDG_SESSION_ID=latchd`.
|
||||||
|
- Extended the disposable PAM fixture to verify both sides of the handoff:
|
||||||
|
selected session metadata entering PAM and PAM-generated values reaching the
|
||||||
|
session child.
|
||||||
|
|
||||||
## 0.5.0 — PAM password policy flow
|
## 0.5.0 — PAM password policy flow
|
||||||
|
|
||||||
- Added PAM password-change handling for accounts that return
|
- Added PAM password-change handling for accounts that return
|
||||||
@@ -11,8 +23,12 @@
|
|||||||
current-password/new-password prompt sequence.
|
current-password/new-password prompt sequence.
|
||||||
- Added redacted structured audit events for authentication, password changes,
|
- Added redacted structured audit events for authentication, password changes,
|
||||||
and session handoff. Raw PAM messages and password material are excluded.
|
and session handoff. Raw PAM messages and password material are excluded.
|
||||||
- Added TUI and core policy-transition tests; the workspace now contains 33
|
- Added TUI, core, and preference tests; the workspace now contains 37
|
||||||
Rust unit tests.
|
Rust unit tests.
|
||||||
|
- Added bounded, atomic last-session preferences per account with owner-only
|
||||||
|
file permissions and a safe fallback when the state file is unavailable.
|
||||||
|
- Added disposable PAM deny-policy fault-injection tests. Docker runs them as
|
||||||
|
root; ordinary host runs skip them without touching the host PAM policy.
|
||||||
|
|
||||||
## 0.4.0 — Advanced login workflow
|
## 0.4.0 — Advanced login workflow
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ RUN cargo fmt --all -- --check \
|
|||||||
&& RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \
|
&& RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \
|
||||||
&& cargo build --locked --release \
|
&& cargo build --locked --release \
|
||||||
&& ./scripts/test-cli.sh target/release/latchd \
|
&& ./scripts/test-cli.sh target/release/latchd \
|
||||||
&& ./scripts/test-system-e2e.sh target/release/latchd
|
&& ./scripts/test-system-e2e.sh target/release/latchd \
|
||||||
|
&& install -m 0664 /dev/null /run/utmp \
|
||||||
|
&& ./scripts/test-pam-fixture.sh
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -48,7 +48,7 @@ RED :=
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: all deps build release-build test check fmt lint doc audit docker-test \
|
.PHONY: all deps build release-build test check fmt lint doc audit docker-test \
|
||||||
integration-test system-e2e system-e2e-strict \
|
integration-test pam-fixture-test system-e2e system-e2e-strict \
|
||||||
package release release-check version install uninstall clean run-preview \
|
package release release-check version install uninstall clean run-preview \
|
||||||
legacy-build legacy-check enable update help
|
legacy-build legacy-check enable update help
|
||||||
|
|
||||||
@@ -98,6 +98,9 @@ docker-test: ## Run the pinned Docker verification suite
|
|||||||
integration-test: build ## Exercise safe, non-authenticating CLI paths
|
integration-test: build ## Exercise safe, non-authenticating CLI paths
|
||||||
@./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)'
|
@./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)'
|
||||||
|
|
||||||
|
pam-fixture-test: ## Run the disposable PAM fault-injection tests when privileged
|
||||||
|
@./scripts/test-pam-fixture.sh
|
||||||
|
|
||||||
system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges
|
system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges
|
||||||
@./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)'
|
@./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)'
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for refe
|
|||||||
- Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision.
|
- Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision.
|
||||||
- Runs PAM password changes for accounts that require a new password before login.
|
- Runs PAM password changes for accounts that require a new password before login.
|
||||||
- Lets you switch users and sessions without leaving the login screen.
|
- Lets you switch users and sessions without leaving the login screen.
|
||||||
|
- Remembers each account's last successful session choice in a root-owned state file.
|
||||||
- Finds X11 and Wayland desktop entries and can include a TTY shell.
|
- Finds X11 and Wayland desktop entries and can include a TTY shell.
|
||||||
- Starts sessions with a cleared, explicit environment and drops privileges before running the user process.
|
- Starts sessions with a cleared, explicit environment and drops privileges before running the user process.
|
||||||
|
- Registers the selected desktop, session type, seat, and VT with PAM/logind,
|
||||||
|
then imports only approved PAM runtime values into the user session.
|
||||||
- Records user sessions with UTMPX when the platform provides it.
|
- Records user sessions with UTMPX when the platform provides it.
|
||||||
- Provides a Ratatui TUI with filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion support.
|
- Provides a Ratatui TUI with filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion support.
|
||||||
- Runs safely in preview mode for UI work and configuration checks.
|
- Runs safely in preview mode for UI work and configuration checks.
|
||||||
@@ -72,7 +75,8 @@ The complete example is in [`extra/config.toml`](extra/config.toml). Important s
|
|||||||
|
|
||||||
- `[accounts]` controls NSS discovery, the faillock helper, faillock policy, and `/etc/shadow` account-age checks.
|
- `[accounts]` controls NSS discovery, the faillock helper, faillock policy, and `/etc/shadow` account-age checks.
|
||||||
- `[x11]` and `[wayland]` select desktop-entry and administrator script directories.
|
- `[x11]` and `[wayland]` select desktop-entry and administrator script directories.
|
||||||
- `[environment_switcher]` controls session choices and the optional TTY shell.
|
- `[environment_switcher]` controls session choices, the optional TTY shell,
|
||||||
|
and the owner-checked last-session preference file.
|
||||||
- `[visual]` controls wallpaper size, animation, notifications, and reduced motion.
|
- `[visual]` controls wallpaper size, animation, notifications, and reduced motion.
|
||||||
- `[power_controls]` contains absolute program paths and argv arrays. Commands are never passed through a shell.
|
- `[power_controls]` contains absolute program paths and argv arrays. Commands are never passed through a shell.
|
||||||
|
|
||||||
@@ -107,6 +111,7 @@ make check
|
|||||||
make audit
|
make audit
|
||||||
make docker-test
|
make docker-test
|
||||||
make integration-test
|
make integration-test
|
||||||
|
make pam-fixture-test # runs when root is available; otherwise reports SKIP
|
||||||
make system-e2e
|
make system-e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
//! PAM authentication with one dedicated worker thread.
|
//! PAM authentication with one dedicated worker thread.
|
||||||
//!
|
//!
|
||||||
//! PAM handles are intentionally never moved out of the worker. The public
|
//! The worker owns all PAM handles. Public tickets contain only an opaque ID.
|
||||||
//! 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::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
@@ -35,6 +33,17 @@ const PAM_ERROR_MSG: c_int = 3;
|
|||||||
const PAM_TEXT_INFO: c_int = 4;
|
const PAM_TEXT_INFO: c_int = 4;
|
||||||
const PAM_ESTABLISH_CRED: c_int = 0x0002;
|
const PAM_ESTABLISH_CRED: c_int = 0x0002;
|
||||||
const PAM_DELETE_CRED: c_int = 0x0004;
|
const PAM_DELETE_CRED: c_int = 0x0004;
|
||||||
|
const PAM_TTY: c_int = 3;
|
||||||
|
const PAM_CHILD_ENVIRONMENT_KEYS: [&CStr; 8] = [
|
||||||
|
c"XDG_SESSION_CLASS",
|
||||||
|
c"XDG_SESSION_TYPE",
|
||||||
|
c"XDG_SESSION_DESKTOP",
|
||||||
|
c"XDG_SEAT",
|
||||||
|
c"XDG_VTNR",
|
||||||
|
c"XDG_RUNTIME_DIR",
|
||||||
|
c"XDG_SESSION_ID",
|
||||||
|
c"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
];
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
struct PamHandle {
|
struct PamHandle {
|
||||||
@@ -83,6 +92,12 @@ unsafe extern "C" {
|
|||||||
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||||
#[link_name = "pam_setcred"]
|
#[link_name = "pam_setcred"]
|
||||||
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||||
|
#[link_name = "pam_set_item"]
|
||||||
|
fn pam_set_item(pamh: *mut PamHandle, item_type: c_int, item: *const c_void) -> c_int;
|
||||||
|
#[link_name = "pam_putenv"]
|
||||||
|
fn pam_putenv(pamh: *mut PamHandle, name_value: *const c_char) -> c_int;
|
||||||
|
#[link_name = "pam_getenv"]
|
||||||
|
fn pam_getenv(pamh: *mut PamHandle, name: *const c_char) -> *const c_char;
|
||||||
#[link_name = "pam_open_session"]
|
#[link_name = "pam_open_session"]
|
||||||
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||||
#[link_name = "pam_close_session"]
|
#[link_name = "pam_close_session"]
|
||||||
@@ -101,7 +116,6 @@ pub struct AuthTicket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AuthTicket {
|
impl AuthTicket {
|
||||||
/// Returns the account identity associated with this ticket.
|
|
||||||
pub fn identity(&self) -> &Identity {
|
pub fn identity(&self) -> &Identity {
|
||||||
&self.identity
|
&self.identity
|
||||||
}
|
}
|
||||||
@@ -146,7 +160,6 @@ pub struct AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
/// Starts the PAM worker.
|
|
||||||
pub fn new() -> Result<Self, Error> {
|
pub fn new() -> Result<Self, Error> {
|
||||||
let (commands, requests) = sync_channel::<Command>(1);
|
let (commands, requests) = sync_channel::<Command>(1);
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
@@ -156,7 +169,6 @@ impl AuthService {
|
|||||||
Ok(Self { commands })
|
Ok(Self { commands })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts one authentication transaction without blocking the UI thread.
|
|
||||||
pub fn authenticate(
|
pub fn authenticate(
|
||||||
&self,
|
&self,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -182,9 +194,8 @@ impl AuthService {
|
|||||||
|
|
||||||
/// Changes an expired account password through the configured PAM policy.
|
/// Changes an expired account password through the configured PAM policy.
|
||||||
///
|
///
|
||||||
/// PAM owns password-strength, history, and token validation. The current
|
/// PAM enforces password strength, history, and token rules.
|
||||||
/// and replacement passwords remain zeroizing buffers for the entire
|
/// Zeroizing buffers hold both passwords and errors never include them.
|
||||||
/// worker transaction and are never returned in an error.
|
|
||||||
pub fn change_password(
|
pub fn change_password(
|
||||||
&self,
|
&self,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -220,7 +231,6 @@ impl AuthService {
|
|||||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
|
.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> {
|
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
|
||||||
let id = ticket.id;
|
let id = ticket.id;
|
||||||
ticket.owner = None;
|
ticket.owner = None;
|
||||||
@@ -312,30 +322,7 @@ fn worker(requests: Receiver<Command>) {
|
|||||||
}
|
}
|
||||||
Command::Run { id, spec, response } => {
|
Command::Run { id, spec, response } => {
|
||||||
let result = match sessions.remove(&id) {
|
let result = match sessions.remove(&id) {
|
||||||
Some(mut session) => {
|
Some(session) => run_authenticated_session(session, spec),
|
||||||
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())),
|
None => Err(Error::Worker("unknown authentication ticket".into())),
|
||||||
};
|
};
|
||||||
let _ = response.send(result);
|
let _ = response.send(result);
|
||||||
@@ -347,6 +334,86 @@ fn worker(requests: Receiver<Command>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_authenticated_session(mut session: PamSession, mut spec: SessionSpec) -> Result<(), Error> {
|
||||||
|
if let Err(error) = configure_pam_session(session.handle, &spec) {
|
||||||
|
let _ = session.close(PAM_SYSTEM_ERR);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
// SAFETY: This worker owns the live handle until `PamSession::close`.
|
||||||
|
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);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
session.opened = true;
|
||||||
|
|
||||||
|
let run = apply_pam_environment(session.handle, &mut spec).and_then(|()| {
|
||||||
|
session.identity.as_ref().map_or_else(
|
||||||
|
|| Err(Error::Worker("ticket has no account identity".into())),
|
||||||
|
|identity| session::run_user_session(identity, &spec),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let close = session.close(if run.is_ok() {
|
||||||
|
PAM_SUCCESS
|
||||||
|
} else {
|
||||||
|
PAM_SYSTEM_ERR
|
||||||
|
});
|
||||||
|
match (run, close) {
|
||||||
|
(Err(error), _) | (Ok(()), Err(error)) => Err(error),
|
||||||
|
(Ok(()), Ok(())) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_pam_session(handle: *mut PamHandle, spec: &SessionSpec) -> Result<(), Error> {
|
||||||
|
let tty = CString::new(spec.pam_tty())
|
||||||
|
.map_err(|_| Error::Session("PAM terminal name contains NUL".into()))?;
|
||||||
|
// SAFETY: The worker owns the live handle. `tty` is NUL-terminated, and PAM copies the value.
|
||||||
|
let result = unsafe { pam_set_item(handle, PAM_TTY, tty.as_ptr().cast()) };
|
||||||
|
if result != PAM_SUCCESS {
|
||||||
|
return Err(pam_error(handle, "pam_set_item(PAM_TTY)", result));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in spec.pam_environment() {
|
||||||
|
let assignment = CString::new(format!("{key}={value}"))
|
||||||
|
.map_err(|_| Error::Session("PAM session environment contains NUL".into()))?;
|
||||||
|
// SAFETY: The worker owns the live handle. PAM copies the NUL-terminated assignment.
|
||||||
|
let result = unsafe { pam_putenv(handle, assignment.as_ptr()) };
|
||||||
|
if result != PAM_SUCCESS {
|
||||||
|
return Err(pam_error(handle, "pam_putenv", result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_pam_environment(handle: *mut PamHandle, spec: &mut SessionSpec) -> Result<(), Error> {
|
||||||
|
for name in PAM_CHILD_ENVIRONMENT_KEYS {
|
||||||
|
// SAFETY: The worker owns the live handle. Each name is a static C string.
|
||||||
|
let value = unsafe { pam_getenv(handle, name.as_ptr()) };
|
||||||
|
if value.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// SAFETY: PAM owns this C string. Copy it before the next PAM call.
|
||||||
|
let value = unsafe { CStr::from_ptr(value) }
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| Error::Session("PAM session environment is not UTF-8".into()))?
|
||||||
|
.to_owned();
|
||||||
|
let key = name
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| Error::Session("PAM environment key is not UTF-8".into()))?;
|
||||||
|
if let Some((_, existing)) = spec
|
||||||
|
.environment
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(existing, _)| existing == key)
|
||||||
|
{
|
||||||
|
*existing = value;
|
||||||
|
} else {
|
||||||
|
spec.environment.push((key.to_owned(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn authenticate(
|
fn authenticate(
|
||||||
username: &str,
|
username: &str,
|
||||||
password: Zeroizing<Vec<u8>>,
|
password: Zeroizing<Vec<u8>>,
|
||||||
@@ -378,8 +445,7 @@ fn authenticate(
|
|||||||
&mut handle,
|
&mut handle,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
// Reconstitute the allocation on every path after pam_start. PAM copies the
|
// SAFETY: `data` came from `Box::into_raw`. PAM borrows `appdata_ptr`.
|
||||||
// conversation structure, but does not own appdata_ptr.
|
|
||||||
let conversation = unsafe { Box::from_raw(data) };
|
let conversation = unsafe { Box::from_raw(data) };
|
||||||
if started != PAM_SUCCESS {
|
if started != PAM_SUCCESS {
|
||||||
let error = pam_error(handle, "pam_start", started);
|
let error = pam_error(handle, "pam_start", started);
|
||||||
@@ -431,7 +497,7 @@ fn authenticate(
|
|||||||
}
|
}
|
||||||
session.credentials_established = true;
|
session.credentials_established = true;
|
||||||
|
|
||||||
// Authentication is complete. Do not retain the password for the session.
|
// Erase the password before the PAM session remains open.
|
||||||
session.conversation.password.zeroize();
|
session.conversation.password.zeroize();
|
||||||
let identity = session
|
let identity = session
|
||||||
.identity
|
.identity
|
||||||
|
|||||||
133
crates/latchd-auth/tests/pam_fixture.rs
Normal file
133
crates/latchd-auth/tests/pam_fixture.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
use latchd_auth::AuthService;
|
||||||
|
use latchd_core::{
|
||||||
|
accounts::Identity,
|
||||||
|
config::Config,
|
||||||
|
error::Error,
|
||||||
|
session::{self, SessionDescriptor, SessionKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn fixture_service() -> String {
|
||||||
|
env::var("LATCHD_PAM_FIXTURE_SERVICE")
|
||||||
|
.expect("LATCHD_PAM_FIXTURE_SERVICE must point at the disposable PAM fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_fixture_service() -> String {
|
||||||
|
env::var("LATCHD_PAM_SESSION_FIXTURE_SERVICE")
|
||||||
|
.expect("LATCHD_PAM_SESSION_FIXTURE_SERVICE must point at the disposable PAM fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_sanitized_pam_failure(result: Result<(), Error>, secret: &str) {
|
||||||
|
let error = result.expect_err("the fault-injection PAM fixture must reject the operation");
|
||||||
|
match error {
|
||||||
|
Error::Pam {
|
||||||
|
status, message, ..
|
||||||
|
} => {
|
||||||
|
assert_ne!(status, 0);
|
||||||
|
assert!(!message.contains(secret));
|
||||||
|
}
|
||||||
|
_ => panic!("fault-injection fixture returned a non-PAM error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||||
|
fn authentication_fault_is_rejected_without_secret_leakage() {
|
||||||
|
let service = AuthService::new().expect("PAM worker should start");
|
||||||
|
let secret = "fixture-auth-secret";
|
||||||
|
let result = service.authenticate("root".into(), secret.as_bytes().to_vec(), fixture_service());
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => panic!("pam_deny fixture must reject authentication"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
match error {
|
||||||
|
Error::Pam {
|
||||||
|
status, message, ..
|
||||||
|
} => {
|
||||||
|
assert_ne!(status, 0);
|
||||||
|
assert!(!message.contains(secret));
|
||||||
|
}
|
||||||
|
_ => panic!("fault-injection fixture returned a non-PAM error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||||
|
fn password_change_fault_is_rejected_without_secret_leakage() {
|
||||||
|
let service = AuthService::new().expect("PAM worker should start");
|
||||||
|
assert_sanitized_pam_failure(
|
||||||
|
service.change_password(
|
||||||
|
"root".into(),
|
||||||
|
b"fixture-old-secret".to_vec(),
|
||||||
|
b"fixture-new-secret".to_vec(),
|
||||||
|
fixture_service(),
|
||||||
|
),
|
||||||
|
"fixture-new-secret",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||||
|
fn selected_session_metadata_reaches_pam_before_open() {
|
||||||
|
let service = AuthService::new().expect("PAM worker should start");
|
||||||
|
let ticket = service
|
||||||
|
.authenticate("root".into(), Vec::new(), session_fixture_service())
|
||||||
|
.expect("permit fixture should authenticate root");
|
||||||
|
let identity = Identity {
|
||||||
|
username: "root".into(),
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
home: "/root".into(),
|
||||||
|
shell: "/bin/sh".into(),
|
||||||
|
};
|
||||||
|
let descriptor = SessionDescriptor {
|
||||||
|
kind: SessionKind::Wayland,
|
||||||
|
title: "Niri fixture".into(),
|
||||||
|
argv: vec!["/bin/true".into()],
|
||||||
|
current_desktop: Some("niri".into()),
|
||||||
|
session_desktop: Some("niri".into()),
|
||||||
|
};
|
||||||
|
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
|
||||||
|
.expect("fixture session should build");
|
||||||
|
|
||||||
|
service
|
||||||
|
.run_session(ticket, spec)
|
||||||
|
.expect("PAM must receive the selected graphical session metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||||
|
fn pam_runtime_environment_reaches_the_session_child() {
|
||||||
|
let service = AuthService::new().expect("PAM worker should start");
|
||||||
|
let ticket = service
|
||||||
|
.authenticate("root".into(), Vec::new(), session_fixture_service())
|
||||||
|
.expect("permit fixture should authenticate root");
|
||||||
|
let identity = Identity {
|
||||||
|
username: "root".into(),
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
home: "/root".into(),
|
||||||
|
shell: "/bin/sh".into(),
|
||||||
|
};
|
||||||
|
let descriptor = SessionDescriptor {
|
||||||
|
kind: SessionKind::Wayland,
|
||||||
|
title: "PAM environment fixture".into(),
|
||||||
|
argv: vec![
|
||||||
|
"/bin/sh".into(),
|
||||||
|
"-c".into(),
|
||||||
|
"test \"$XDG_SESSION_ID\" = fixture-session && \
|
||||||
|
test \"$XDG_RUNTIME_DIR\" = /tmp/latchd-runtime-fixture && \
|
||||||
|
test \"$DBUS_SESSION_BUS_ADDRESS\" = unix:path=/tmp/latchd-runtime-fixture/bus"
|
||||||
|
.into(),
|
||||||
|
],
|
||||||
|
current_desktop: Some("niri".into()),
|
||||||
|
session_desktop: Some("niri".into()),
|
||||||
|
};
|
||||||
|
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
|
||||||
|
.expect("fixture session should build");
|
||||||
|
|
||||||
|
service
|
||||||
|
.run_session(ticket, spec)
|
||||||
|
.expect("the child must receive runtime values produced by PAM");
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ use std::{io, mem, os::raw::c_char, ptr};
|
|||||||
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
/// A live user-process record in UTMPX.
|
|
||||||
pub struct UtmpxGuard {
|
pub struct UtmpxGuard {
|
||||||
record: libc::utmpx,
|
record: libc::utmpx,
|
||||||
closed: bool,
|
closed: bool,
|
||||||
|
|||||||
@@ -14,90 +14,61 @@ use crate::{
|
|||||||
error::{Error, ResultExt},
|
error::{Error, ResultExt},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// An account that can be offered by the login screen.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Identity {
|
pub struct Identity {
|
||||||
/// Canonical username.
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
/// Numeric user ID.
|
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
/// Primary group ID.
|
|
||||||
pub gid: u32,
|
pub gid: u32,
|
||||||
/// Home directory.
|
|
||||||
pub home: PathBuf,
|
pub home: PathBuf,
|
||||||
/// Login shell.
|
|
||||||
pub shell: PathBuf,
|
pub shell: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Effective account state determined from the login shell and shadow aging data.
|
/// Effective account state determined from the login shell and shadow aging data.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AccountState {
|
pub enum AccountState {
|
||||||
/// Account can be offered for login.
|
|
||||||
Active,
|
Active,
|
||||||
/// Password is disabled by the shadow lock marker.
|
|
||||||
Locked,
|
Locked,
|
||||||
/// Password must be changed before login can continue.
|
|
||||||
PasswordExpired,
|
PasswordExpired,
|
||||||
/// Account expiration date has elapsed.
|
|
||||||
AccountExpired,
|
AccountExpired,
|
||||||
/// Password inactivity period has elapsed.
|
|
||||||
AccountInactive,
|
AccountInactive,
|
||||||
/// Login shell explicitly disallows interactive sessions.
|
|
||||||
NoLoginShell,
|
NoLoginShell,
|
||||||
/// Shadow data was not available or could not be parsed.
|
/// Shadow data was not available or could not be parsed.
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-sensitive password and account aging information.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct AccountStatus {
|
pub struct AccountStatus {
|
||||||
/// Effective state used by the chooser and diagnostics.
|
|
||||||
pub state: AccountState,
|
pub state: AccountState,
|
||||||
/// Day the password was last changed, if present.
|
|
||||||
pub password_changed_day: Option<i64>,
|
pub password_changed_day: Option<i64>,
|
||||||
/// Day the password expires, if the policy specifies one.
|
|
||||||
pub password_expiry_day: Option<i64>,
|
pub password_expiry_day: Option<i64>,
|
||||||
/// Day the account expires, if specified.
|
|
||||||
pub account_expiry_day: Option<i64>,
|
pub account_expiry_day: Option<i64>,
|
||||||
/// Day after which an expired password disables the account.
|
|
||||||
pub inactive_after_day: Option<i64>,
|
pub inactive_after_day: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountStatus {
|
impl AccountStatus {
|
||||||
/// Returns whether this account may be presented as an active login choice.
|
|
||||||
pub const fn is_login_allowed(self) -> bool {
|
pub const fn is_login_allowed(self) -> bool {
|
||||||
matches!(self.state, AccountState::Active)
|
matches!(self.state, AccountState::Active)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An identity paired with its non-sensitive account policy state.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AccountRecord {
|
pub struct AccountRecord {
|
||||||
/// Discovered NSS identity.
|
|
||||||
pub identity: Identity,
|
pub identity: Identity,
|
||||||
/// Current local account status.
|
|
||||||
pub status: AccountStatus,
|
pub status: AccountStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A currently logged-in process obtained from UTMPX.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ActiveSession {
|
pub struct ActiveSession {
|
||||||
/// Login name associated with the session.
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
/// UTMPX line, such as `tty2` or `pts/0`.
|
|
||||||
pub terminal: String,
|
pub terminal: String,
|
||||||
/// Session leader PID when reported by UTMPX.
|
|
||||||
pub pid: libc::pid_t,
|
pub pid: libc::pid_t,
|
||||||
/// Remote host, if supplied by the session producer.
|
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime session-management backend detected on this host.
|
/// Runtime session-management backend detected on this host.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SessionBackend {
|
pub enum SessionBackend {
|
||||||
/// systemd-logind runtime markers are available.
|
|
||||||
SystemdLogind,
|
SystemdLogind,
|
||||||
/// UTMPX is available as a conservative fallback.
|
|
||||||
Utmpx,
|
Utmpx,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +84,10 @@ struct ShadowEntry {
|
|||||||
/// A non-authoritative account lockout summary.
|
/// A non-authoritative account lockout summary.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub struct FaillockStatus {
|
pub struct FaillockStatus {
|
||||||
/// Whether the faillock helper returned a usable result.
|
|
||||||
pub available: bool,
|
pub available: bool,
|
||||||
/// Number of observed failed attempts.
|
|
||||||
pub failures: u32,
|
pub failures: u32,
|
||||||
/// Configured failure threshold, when discoverable.
|
|
||||||
pub max_tries: Option<u32>,
|
pub max_tries: Option<u32>,
|
||||||
/// Remaining attempts before the advisory threshold.
|
|
||||||
pub remaining: Option<u32>,
|
pub remaining: Option<u32>,
|
||||||
/// Whether the advisory threshold has been reached.
|
|
||||||
pub locked_out: bool,
|
pub locked_out: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,12 +257,8 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
|
|||||||
Ok(faillock_status_from_output(&output.stdout, max_tries))
|
Ok(faillock_status_from_output(&output.stdout, max_tries))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts stable helper output into an advisory lockout status.
|
/// Parses the legacy count format and current rows with usernames or timestamps.
|
||||||
///
|
/// Malformed output reports zero failures and does not control authentication.
|
||||||
/// This parser accepts both the legacy `count timestamp` format and modern
|
|
||||||
/// rows containing a username prefix or timestamp columns. It intentionally
|
|
||||||
/// treats malformed output as zero failures instead of making authentication
|
|
||||||
/// fail open or fail closed.
|
|
||||||
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
|
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
|
||||||
let failures = parse_failure_count(output);
|
let failures = parse_failure_count(output);
|
||||||
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
//! Redacted security audit events.
|
//! Redacted security audit events.
|
||||||
//!
|
//!
|
||||||
//! Audit records deliberately contain no password, token, PAM message, or
|
//! Audit records exclude secrets, PAM messages, and command arguments.
|
||||||
//! command-line detail. Callers can send the fields to a structured logger
|
|
||||||
//! without accidentally turning an authentication failure into a secret dump.
|
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
@@ -11,11 +9,8 @@ use crate::error::Error;
|
|||||||
/// The security operation that produced an audit event.
|
/// The security operation that produced an audit event.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AuditAction {
|
pub enum AuditAction {
|
||||||
/// A PAM authentication attempt.
|
|
||||||
Authentication,
|
Authentication,
|
||||||
/// A PAM password-change transaction.
|
|
||||||
PasswordChange,
|
PasswordChange,
|
||||||
/// A user session handoff.
|
|
||||||
Session,
|
Session,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +27,9 @@ impl fmt::Display for AuditAction {
|
|||||||
/// The outcome of a security operation.
|
/// The outcome of a security operation.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AuditOutcome {
|
pub enum AuditOutcome {
|
||||||
/// The operation was requested.
|
|
||||||
Started,
|
Started,
|
||||||
/// The operation completed successfully.
|
|
||||||
Succeeded,
|
Succeeded,
|
||||||
/// The operation was rejected or failed.
|
|
||||||
Failed,
|
Failed,
|
||||||
/// PAM requires a password change before proceeding.
|
|
||||||
PasswordChangeRequired,
|
PasswordChangeRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,21 +47,13 @@ impl fmt::Display for AuditOutcome {
|
|||||||
/// A safe category for a PAM failure, without exposing PAM's free-form text.
|
/// A safe category for a PAM failure, without exposing PAM's free-form text.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PamReason {
|
pub enum PamReason {
|
||||||
/// Password or other authentication material was rejected.
|
|
||||||
AuthenticationRejected,
|
AuthenticationRejected,
|
||||||
/// PAM could not reach its backing authentication service.
|
|
||||||
AuthenticationUnavailable,
|
AuthenticationUnavailable,
|
||||||
/// PAM reports that the account has expired.
|
|
||||||
AccountExpired,
|
AccountExpired,
|
||||||
/// PAM requires a password change.
|
|
||||||
PasswordChangeRequired,
|
PasswordChangeRequired,
|
||||||
/// PAM reports that the retry limit was reached.
|
|
||||||
TooManyAttempts,
|
TooManyAttempts,
|
||||||
/// PAM conversation or token handling failed.
|
|
||||||
Conversation,
|
Conversation,
|
||||||
/// A PAM/system operation failed.
|
|
||||||
System,
|
System,
|
||||||
/// An unclassified PAM status was returned.
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +75,14 @@ impl fmt::Display for PamReason {
|
|||||||
/// A structured event whose fields are safe to send to an audit logger.
|
/// A structured event whose fields are safe to send to an audit logger.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct AuditEvent<'a> {
|
pub struct AuditEvent<'a> {
|
||||||
/// Security operation.
|
|
||||||
pub action: AuditAction,
|
pub action: AuditAction,
|
||||||
/// Operation outcome.
|
|
||||||
pub outcome: AuditOutcome,
|
pub outcome: AuditOutcome,
|
||||||
/// Account name supplied by the user; never a password or token.
|
/// Account name supplied by the user; never a password or token.
|
||||||
pub account: &'a str,
|
pub account: &'a str,
|
||||||
/// Sanitized PAM failure category, when applicable.
|
|
||||||
pub pam_reason: Option<PamReason>,
|
pub pam_reason: Option<PamReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> AuditEvent<'a> {
|
impl<'a> AuditEvent<'a> {
|
||||||
/// Creates an event for an operation that has just started.
|
|
||||||
pub const fn started(action: AuditAction, account: &'a str) -> Self {
|
pub const fn started(action: AuditAction, account: &'a str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
action,
|
action,
|
||||||
@@ -113,7 +92,6 @@ impl<'a> AuditEvent<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a successful event.
|
|
||||||
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
|
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
action,
|
action,
|
||||||
@@ -123,7 +101,6 @@ impl<'a> AuditEvent<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a failure event with a sanitized PAM category.
|
|
||||||
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
|
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
|
||||||
Self {
|
Self {
|
||||||
action,
|
action,
|
||||||
|
|||||||
@@ -9,33 +9,20 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::error::{Error, ResultExt};
|
use crate::error::{Error, ResultExt};
|
||||||
|
|
||||||
/// Validated application configuration.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Virtual terminal number used by the service.
|
|
||||||
pub tty: u8,
|
pub tty: u8,
|
||||||
/// Main log file.
|
|
||||||
pub main_log_path: PathBuf,
|
pub main_log_path: PathBuf,
|
||||||
/// Per-session log file.
|
|
||||||
pub client_log_path: PathBuf,
|
pub client_log_path: PathBuf,
|
||||||
/// PAM service name.
|
|
||||||
pub pam_service: String,
|
pub pam_service: String,
|
||||||
/// Default shell used only when the account has no shell value.
|
|
||||||
pub system_shell: PathBuf,
|
pub system_shell: PathBuf,
|
||||||
/// Session PATH.
|
|
||||||
pub initial_path: String,
|
pub initial_path: String,
|
||||||
/// X11 settings.
|
|
||||||
pub x11: X11Config,
|
pub x11: X11Config,
|
||||||
/// Wayland settings.
|
|
||||||
pub wayland: WaylandConfig,
|
pub wayland: WaylandConfig,
|
||||||
/// Environment switcher settings.
|
|
||||||
pub environment_switcher: SwitcherConfig,
|
pub environment_switcher: SwitcherConfig,
|
||||||
/// Power actions shown by the login screen.
|
|
||||||
pub power_controls: PowerControls,
|
pub power_controls: PowerControls,
|
||||||
/// Account enumeration and advisory lockout settings.
|
|
||||||
pub accounts: AccountsConfig,
|
pub accounts: AccountsConfig,
|
||||||
/// TUI-only background, animation, and notification settings.
|
|
||||||
pub visual: VisualConfig,
|
pub visual: VisualConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,21 +45,14 @@ impl Default for Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Account discovery and faillock settings.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct AccountsConfig {
|
pub struct AccountsConfig {
|
||||||
/// Minimum UID shown by automatic account discovery.
|
|
||||||
pub minimum_uid: u32,
|
pub minimum_uid: u32,
|
||||||
/// Usernames excluded from the chooser.
|
|
||||||
pub excluded_users: Vec<String>,
|
pub excluded_users: Vec<String>,
|
||||||
/// Whether the chooser lists NSS accounts.
|
|
||||||
pub enumerate: bool,
|
pub enumerate: bool,
|
||||||
/// Absolute faillock helper path.
|
|
||||||
pub faillock_path: PathBuf,
|
pub faillock_path: PathBuf,
|
||||||
/// faillock configuration used to read the deny threshold.
|
|
||||||
pub faillock_config: PathBuf,
|
pub faillock_config: PathBuf,
|
||||||
/// Shadow database used for local password and account aging checks.
|
|
||||||
pub shadow_path: PathBuf,
|
pub shadow_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,21 +69,14 @@ impl Default for AccountsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visual effects used only by the terminal login screen.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct VisualConfig {
|
pub struct VisualConfig {
|
||||||
/// Optional root-owned image used as a low-contrast terminal wallpaper.
|
|
||||||
pub wallpaper_path: Option<PathBuf>,
|
pub wallpaper_path: Option<PathBuf>,
|
||||||
/// Enable the wallpaper and procedural background layer.
|
|
||||||
pub background_enabled: bool,
|
pub background_enabled: bool,
|
||||||
/// Advance animated background and spinner frames.
|
|
||||||
pub animations: bool,
|
pub animations: bool,
|
||||||
/// Show transient in-TUI notifications for user-visible events.
|
|
||||||
pub notifications: bool,
|
pub notifications: bool,
|
||||||
/// Disable motion while retaining static visual styling.
|
|
||||||
pub reduced_motion: bool,
|
pub reduced_motion: bool,
|
||||||
/// Maximum wallpaper file size accepted before decoding.
|
|
||||||
pub max_wallpaper_bytes: u64,
|
pub max_wallpaper_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,23 +93,15 @@ impl Default for VisualConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// X11 session configuration.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct X11Config {
|
pub struct X11Config {
|
||||||
/// X display name.
|
|
||||||
pub display: String,
|
pub display: String,
|
||||||
/// X server executable.
|
|
||||||
pub server_path: PathBuf,
|
pub server_path: PathBuf,
|
||||||
/// Xauthority executable.
|
|
||||||
pub xauth_path: PathBuf,
|
pub xauth_path: PathBuf,
|
||||||
/// X server startup timeout.
|
|
||||||
pub server_timeout_secs: u16,
|
pub server_timeout_secs: u16,
|
||||||
/// X server log.
|
|
||||||
pub server_log_path: PathBuf,
|
pub server_log_path: PathBuf,
|
||||||
/// Desktop-entry directory.
|
|
||||||
pub sessions_path: PathBuf,
|
pub sessions_path: PathBuf,
|
||||||
/// Custom script directory.
|
|
||||||
pub scripts_path: PathBuf,
|
pub scripts_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +119,10 @@ impl Default for X11Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wayland session configuration.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct WaylandConfig {
|
pub struct WaylandConfig {
|
||||||
/// Desktop-entry directory.
|
|
||||||
pub sessions_path: PathBuf,
|
pub sessions_path: PathBuf,
|
||||||
/// Custom script directory.
|
|
||||||
pub scripts_path: PathBuf,
|
pub scripts_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +135,13 @@ impl Default for WaylandConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session switcher settings.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct SwitcherConfig {
|
pub struct SwitcherConfig {
|
||||||
/// Include the fallback TTY shell in the chooser.
|
|
||||||
pub include_tty_shell: bool,
|
pub include_tty_shell: bool,
|
||||||
/// Maximum visible title width.
|
|
||||||
pub max_display_length: usize,
|
pub max_display_length: usize,
|
||||||
|
pub remember_last_session: bool,
|
||||||
|
pub last_session_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SwitcherConfig {
|
impl Default for SwitcherConfig {
|
||||||
@@ -188,29 +149,24 @@ impl Default for SwitcherConfig {
|
|||||||
Self {
|
Self {
|
||||||
include_tty_shell: false,
|
include_tty_shell: false,
|
||||||
max_display_length: 18,
|
max_display_length: 18,
|
||||||
|
remember_last_session: true,
|
||||||
|
last_session_path: PathBuf::from("/var/lib/latchd/last-session.toml"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Power actions available to the login screen.
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct PowerControls {
|
pub struct PowerControls {
|
||||||
/// Built-in or administrator-defined actions.
|
|
||||||
pub entries: Vec<PowerAction>,
|
pub entries: Vec<PowerAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A power action represented as an executable argv vector.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PowerAction {
|
pub struct PowerAction {
|
||||||
/// Displayed key, such as `F1`.
|
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// Displayed label.
|
|
||||||
pub hint: String,
|
pub hint: String,
|
||||||
/// Executable path.
|
|
||||||
pub program: PathBuf,
|
pub program: PathBuf,
|
||||||
/// Arguments passed without shell interpretation.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -287,6 +243,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
|
|||||||
"environment_switcher.max_display_length must be greater than zero".into(),
|
"environment_switcher.max_display_length must be greater than zero".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if config.environment_switcher.remember_last_session
|
||||||
|
&& !config.environment_switcher.last_session_path.is_absolute()
|
||||||
|
{
|
||||||
|
return Err(Error::Config(
|
||||||
|
"environment_switcher.last_session_path must be absolute".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
for (name, path) in [
|
for (name, path) in [
|
||||||
("main_log_path", &config.main_log_path),
|
("main_log_path", &config.main_log_path),
|
||||||
("client_log_path", &config.client_log_path),
|
("client_log_path", &config.client_log_path),
|
||||||
|
|||||||
@@ -2,47 +2,32 @@ use std::io;
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// The application-wide error type.
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
/// A command-line or configuration value was invalid.
|
|
||||||
#[error("configuration: {0}")]
|
#[error("configuration: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
/// A filesystem operation failed.
|
|
||||||
#[error("{context}: {source}")]
|
#[error("{context}: {source}")]
|
||||||
Io {
|
Io {
|
||||||
/// The operation being performed.
|
|
||||||
context: String,
|
context: String,
|
||||||
/// The underlying error.
|
|
||||||
#[source]
|
#[source]
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
/// TOML could not be decoded.
|
|
||||||
#[error("parse {path}: {source}")]
|
#[error("parse {path}: {source}")]
|
||||||
Toml {
|
Toml {
|
||||||
/// The file being parsed.
|
|
||||||
path: String,
|
path: String,
|
||||||
/// The parser error.
|
|
||||||
#[source]
|
#[source]
|
||||||
source: toml::de::Error,
|
source: toml::de::Error,
|
||||||
},
|
},
|
||||||
/// PAM rejected or could not complete an operation.
|
|
||||||
#[error("PAM {operation} failed with status {status}: {message}")]
|
#[error("PAM {operation} failed with status {status}: {message}")]
|
||||||
Pam {
|
Pam {
|
||||||
/// The PAM operation.
|
|
||||||
operation: &'static str,
|
operation: &'static str,
|
||||||
/// The numeric PAM status.
|
|
||||||
status: i32,
|
status: i32,
|
||||||
/// The human-readable PAM message.
|
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
/// A session process failed.
|
|
||||||
#[error("session: {0}")]
|
#[error("session: {0}")]
|
||||||
Session(String),
|
Session(String),
|
||||||
/// The terminal could not be initialized or restored.
|
|
||||||
#[error("terminal: {0}")]
|
#[error("terminal: {0}")]
|
||||||
Terminal(String),
|
Terminal(String),
|
||||||
/// An authentication worker request was cancelled or disconnected.
|
|
||||||
#[error("authentication worker: {0}")]
|
#[error("authentication worker: {0}")]
|
||||||
Worker(String),
|
Worker(String),
|
||||||
}
|
}
|
||||||
@@ -62,7 +47,6 @@ impl Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a stable operation label to an I/O error.
|
|
||||||
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
||||||
Error::Io {
|
Error::Io {
|
||||||
context: context.into(),
|
context: context.into(),
|
||||||
@@ -70,9 +54,7 @@ pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a failed result into an application error with context.
|
|
||||||
pub trait ResultExt<T> {
|
pub trait ResultExt<T> {
|
||||||
/// Adds context to an I/O operation.
|
|
||||||
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
|
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
//! Core configuration, account, session, and accounting contracts for latchd.
|
//! Core configuration, account, session, and accounting contracts for latchd.
|
||||||
|
|
||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
#![warn(missing_docs)]
|
|
||||||
|
|
||||||
/// Linux UTMPX accounting primitives.
|
|
||||||
pub mod accounting;
|
pub mod accounting;
|
||||||
/// NSS account discovery and faillock status.
|
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
/// Redacted, structured security audit events.
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
/// Validated application configuration.
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
/// Structured application errors.
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
/// Session discovery and process lifecycle.
|
pub mod preferences;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
253
crates/latchd-core/src/preferences.rs
Normal file
253
crates/latchd-core/src/preferences.rs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
//! Bounded, owner-checked persistence for last-used session choices.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::Write,
|
||||||
|
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||||
|
path::Path,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{Error, ResultExt, io_error},
|
||||||
|
session::{SessionDescriptor, SessionKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ENTRIES: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LastUsedEntry {
|
||||||
|
pub username: String,
|
||||||
|
pub kind: SessionKind,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct LastUsedPreferences {
|
||||||
|
entries: Vec<LastUsedEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LastUsedPreferences {
|
||||||
|
/// Records a successful selection, moving the newest entry to the front.
|
||||||
|
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
|
||||||
|
if username.is_empty() || session.title.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.entries.retain(|entry| entry.username != username);
|
||||||
|
self.entries.insert(
|
||||||
|
0,
|
||||||
|
LastUsedEntry {
|
||||||
|
username: username.to_owned(),
|
||||||
|
kind: session.kind,
|
||||||
|
title: session.title.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.entries.truncate(MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the exact previously selected session for an account.
|
||||||
|
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
|
||||||
|
let entry = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == username)?;
|
||||||
|
sessions
|
||||||
|
.iter()
|
||||||
|
.position(|session| session.kind == entry.kind && session.title == entry.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> &[LastUsedEntry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads preferences from an owner-checked TOML file.
|
||||||
|
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(Error::Config("last-session path must be absolute".into()));
|
||||||
|
}
|
||||||
|
let metadata = match fs::symlink_metadata(path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(LastUsedPreferences::default());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(io_error(format!("inspect {}", path.display()), error)),
|
||||||
|
};
|
||||||
|
if !trusted_file(&metadata) {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"last-session file must be an owner-only regular file: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
|
||||||
|
let preferences =
|
||||||
|
toml::from_str::<LastUsedPreferences>(&raw).map_err(|source| Error::Toml {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
validate(&preferences, path)?;
|
||||||
|
Ok(preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically saves preferences with mode `0600` under a trusted directory.
|
||||||
|
pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(Error::Config("last-session path must be absolute".into()));
|
||||||
|
}
|
||||||
|
validate(preferences, path)?;
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"last-session path has no parent: {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent).io_context(format!("create {}", parent.display()))?;
|
||||||
|
let parent_metadata =
|
||||||
|
fs::symlink_metadata(parent).io_context(format!("inspect {}", parent.display()))?;
|
||||||
|
if !trusted_directory(&parent_metadata) {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"last-session directory must be owner-owned and non-writable: {}",
|
||||||
|
parent.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(preferences)
|
||||||
|
.map_err(|error| Error::Config(format!("serialize last-session preferences: {error}")))?;
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| duration.as_nanos());
|
||||||
|
let temporary = parent.join(format!(
|
||||||
|
".{}.tmp.{}.{}",
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("last-session"),
|
||||||
|
std::process::id(),
|
||||||
|
nonce
|
||||||
|
));
|
||||||
|
let write_result = (|| -> Result<(), Error> {
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW)
|
||||||
|
.open(&temporary)
|
||||||
|
.io_context(format!("create {}", temporary.display()))?;
|
||||||
|
file.write_all(content.as_bytes())
|
||||||
|
.io_context(format!("write {}", temporary.display()))?;
|
||||||
|
file.sync_all()
|
||||||
|
.io_context(format!("sync {}", temporary.display()))?;
|
||||||
|
fs::rename(&temporary, path).io_context(format!("replace {}", path.display()))?;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||||
|
.io_context(format!("secure {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if write_result.is_err() {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
}
|
||||||
|
write_result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(preferences: &LastUsedPreferences, path: &Path) -> Result<(), Error> {
|
||||||
|
if preferences.entries.len() > MAX_ENTRIES {
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"last-session file contains more than {MAX_ENTRIES} entries: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if preferences
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.username.is_empty() || entry.title.is_empty())
|
||||||
|
{
|
||||||
|
return Err(Error::Config(format!(
|
||||||
|
"last-session entries must have a username and title: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusted_file(metadata: &std::fs::Metadata) -> bool {
|
||||||
|
metadata.is_file() && metadata.uid() == effective_uid() && metadata.mode() & 0o077 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusted_directory(metadata: &std::fs::Metadata) -> bool {
|
||||||
|
metadata.is_dir() && metadata.uid() == effective_uid() && metadata.mode() & 0o022 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_uid() -> u32 {
|
||||||
|
// SAFETY: geteuid has no pointer arguments or memory preconditions.
|
||||||
|
unsafe { libc::geteuid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn descriptor(kind: SessionKind, title: &str) -> SessionDescriptor {
|
||||||
|
SessionDescriptor {
|
||||||
|
kind,
|
||||||
|
title: title.into(),
|
||||||
|
argv: Vec::new(),
|
||||||
|
current_desktop: None,
|
||||||
|
session_desktop: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_path() -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"latchd-preferences-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| duration.as_nanos())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_successful_session_is_preferred_per_account() {
|
||||||
|
let wayland = descriptor(SessionKind::Wayland, "Sway");
|
||||||
|
let tty = descriptor(SessionKind::Tty, "TTY shell");
|
||||||
|
let mut preferences = LastUsedPreferences::default();
|
||||||
|
preferences.remember("alice", &wayland);
|
||||||
|
assert_eq!(
|
||||||
|
preferences.preferred_index("alice", &[tty.clone(), wayland]),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
preferences.remember("alice", &tty);
|
||||||
|
assert_eq!(
|
||||||
|
preferences.preferred_index("alice", &[tty, descriptor(SessionKind::Wayland, "Sway")]),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert_eq!(preferences.entries()[0].username, "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferences_round_trip_atomically_with_private_permissions() {
|
||||||
|
let directory = test_path();
|
||||||
|
let path = directory.join("last-session.toml");
|
||||||
|
let session = descriptor(SessionKind::X11, "GNOME");
|
||||||
|
let mut preferences = LastUsedPreferences::default();
|
||||||
|
preferences.remember("alice", &session);
|
||||||
|
save(&path, &preferences).expect("preference save should succeed");
|
||||||
|
let loaded = load(&path).expect("preference load should succeed");
|
||||||
|
assert_eq!(loaded, preferences);
|
||||||
|
let mode = fs::symlink_metadata(&path)
|
||||||
|
.expect("preference metadata should exist")
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o077, 0);
|
||||||
|
let _ = fs::remove_dir_all(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_preferences_are_a_safe_empty_store() {
|
||||||
|
let path = test_path().join("missing.toml");
|
||||||
|
assert_eq!(
|
||||||
|
load(&path).expect("missing preferences are optional"),
|
||||||
|
LastUsedPreferences::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,72 +26,71 @@ use crate::{
|
|||||||
error::{Error, ResultExt, io_error},
|
error::{Error, ResultExt, io_error},
|
||||||
};
|
};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use signal_hook::{consts::SIGUSR1, flag};
|
use signal_hook::{consts::SIGUSR1, flag};
|
||||||
|
|
||||||
/// A session family supported by latchd.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum SessionKind {
|
pub enum SessionKind {
|
||||||
/// A login shell on the current virtual terminal.
|
|
||||||
Tty,
|
Tty,
|
||||||
/// An X11 desktop session.
|
|
||||||
X11,
|
X11,
|
||||||
/// A Wayland desktop session.
|
|
||||||
Wayland,
|
Wayland,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A discoverable desktop or script session.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SessionDescriptor {
|
pub struct SessionDescriptor {
|
||||||
/// Session family.
|
|
||||||
pub kind: SessionKind,
|
pub kind: SessionKind,
|
||||||
/// User-facing title.
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// Program and arguments, without shell interpretation.
|
/// Program and arguments, without shell interpretation.
|
||||||
pub argv: Vec<String>,
|
pub argv: Vec<String>,
|
||||||
/// XDG current-desktop value.
|
|
||||||
pub current_desktop: Option<String>,
|
pub current_desktop: Option<String>,
|
||||||
/// XDG session-desktop value.
|
|
||||||
pub session_desktop: Option<String>,
|
pub session_desktop: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully materialized session request.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SessionSpec {
|
pub struct SessionSpec {
|
||||||
/// Session family.
|
|
||||||
pub kind: SessionKind,
|
pub kind: SessionKind,
|
||||||
/// Session title for logs.
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// Program and arguments.
|
|
||||||
pub argv: Vec<String>,
|
pub argv: Vec<String>,
|
||||||
/// Explicit child environment.
|
|
||||||
pub environment: Vec<(String, String)>,
|
pub environment: Vec<(String, String)>,
|
||||||
/// Working directory.
|
|
||||||
pub working_directory: PathBuf,
|
pub working_directory: PathBuf,
|
||||||
/// Private stdout/stderr log for graphical sessions.
|
|
||||||
pub client_log_path: PathBuf,
|
pub client_log_path: PathBuf,
|
||||||
/// Login shell for TTY fallback.
|
|
||||||
pub shell: PathBuf,
|
pub shell: PathBuf,
|
||||||
/// Virtual terminal number.
|
|
||||||
pub tty: u8,
|
pub tty: u8,
|
||||||
/// X11 configuration when this is an X11 session.
|
|
||||||
pub x11: Option<X11Runtime>,
|
pub x11: Option<X11Runtime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime X11 parameters passed to a session.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct X11Runtime {
|
pub struct X11Runtime {
|
||||||
/// X display name.
|
|
||||||
pub display: String,
|
pub display: String,
|
||||||
/// X server command.
|
|
||||||
pub server_path: PathBuf,
|
pub server_path: PathBuf,
|
||||||
/// Xauthority command.
|
|
||||||
pub xauth_path: PathBuf,
|
pub xauth_path: PathBuf,
|
||||||
/// X server timeout.
|
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
/// X server log path.
|
|
||||||
pub log_path: PathBuf,
|
pub log_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAM_SESSION_ENVIRONMENT_KEYS: [&str; 5] = [
|
||||||
|
"XDG_SESSION_CLASS",
|
||||||
|
"XDG_SESSION_TYPE",
|
||||||
|
"XDG_SESSION_DESKTOP",
|
||||||
|
"XDG_SEAT",
|
||||||
|
"XDG_VTNR",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl SessionSpec {
|
||||||
|
pub fn pam_tty(&self) -> String {
|
||||||
|
format!("tty{}", self.tty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected-session metadata that PAM and logind must see.
|
||||||
|
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||||
|
self.environment
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| PAM_SESSION_ENVIRONMENT_KEYS.contains(&key.as_str()))
|
||||||
|
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Discovers configured sessions in stable directory order.
|
/// Discovers configured sessions in stable directory order.
|
||||||
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
|
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
|
||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
@@ -236,7 +235,6 @@ pub fn build_spec(
|
|||||||
"XDG_RUNTIME_DIR",
|
"XDG_RUNTIME_DIR",
|
||||||
format!("/run/user/{}", identity.uid),
|
format!("/run/user/{}", identity.uid),
|
||||||
);
|
);
|
||||||
insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into());
|
|
||||||
insert_env(
|
insert_env(
|
||||||
&mut environment,
|
&mut environment,
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
@@ -858,4 +856,40 @@ mod tests {
|
|||||||
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
|
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_wayland_metadata_is_ready_for_pam() {
|
||||||
|
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: "Niri".into(),
|
||||||
|
argv: vec!["/usr/bin/niri-session".into()],
|
||||||
|
current_desktop: Some("niri".into()),
|
||||||
|
session_desktop: Some("niri".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
|
||||||
|
let pam_environment = spec
|
||||||
|
.pam_environment()
|
||||||
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
|
|
||||||
|
assert_eq!(spec.pam_tty(), "tty2");
|
||||||
|
assert_eq!(pam_environment.get("XDG_SESSION_CLASS"), Some(&"user"));
|
||||||
|
assert_eq!(pam_environment.get("XDG_SESSION_TYPE"), Some(&"wayland"));
|
||||||
|
assert_eq!(pam_environment.get("XDG_SESSION_DESKTOP"), Some(&"niri"));
|
||||||
|
assert_eq!(pam_environment.get("XDG_SEAT"), Some(&"seat0"));
|
||||||
|
assert_eq!(pam_environment.get("XDG_VTNR"), Some(&"2"));
|
||||||
|
assert!(
|
||||||
|
spec.environment
|
||||||
|
.iter()
|
||||||
|
.all(|(key, _)| key != "XDG_SESSION_ID"),
|
||||||
|
"logind, not latchd, owns the session identifier"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const MAX_ANIMATION_WIDTH: usize = 160;
|
|||||||
const MAX_ANIMATION_HEIGHT: usize = 64;
|
const MAX_ANIMATION_HEIGHT: usize = 64;
|
||||||
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
|
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
|
||||||
|
|
||||||
/// Background state owned by the TUI thread.
|
|
||||||
pub(crate) struct Background {
|
pub(crate) struct Background {
|
||||||
wallpaper: Option<RgbImage>,
|
wallpaper: Option<RgbImage>,
|
||||||
automata: Automata,
|
automata: Automata,
|
||||||
@@ -34,7 +33,6 @@ pub(crate) struct Background {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Background {
|
impl Background {
|
||||||
/// Loads a bounded wallpaper image and prepares the procedural layer.
|
|
||||||
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
|
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
|
||||||
let wallpaper = config
|
let wallpaper = config
|
||||||
.wallpaper_path
|
.wallpaper_path
|
||||||
@@ -191,8 +189,7 @@ struct Automata {
|
|||||||
|
|
||||||
impl Automata {
|
impl Automata {
|
||||||
fn resize(&mut self, width: usize, height: usize) {
|
fn resize(&mut self, width: usize, height: usize) {
|
||||||
// Keep procedural state bounded even when a terminal reports an
|
// Cap the state size. Some terminal emulators report extreme dimensions.
|
||||||
// unusually large window. Rendering remains clipped to this layer.
|
|
||||||
let width = width.min(MAX_ANIMATION_WIDTH);
|
let width = width.min(MAX_ANIMATION_WIDTH);
|
||||||
let height = height.min(MAX_ANIMATION_HEIGHT);
|
let height = height.min(MAX_ANIMATION_HEIGHT);
|
||||||
if self.width == width && self.height == height {
|
if self.width == width && self.height == height {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use latchd_core::{
|
|||||||
audit::{AuditAction, AuditEvent},
|
audit::{AuditAction, AuditEvent},
|
||||||
config::Config,
|
config::Config,
|
||||||
error::{Error, ResultExt},
|
error::{Error, ResultExt},
|
||||||
|
preferences::{self, LastUsedPreferences},
|
||||||
session::{self, SessionDescriptor},
|
session::{self, SessionDescriptor},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,11 +42,8 @@ fn unknown_account_status() -> AccountStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The result of one completed login-screen interaction.
|
|
||||||
pub struct LoginSelection {
|
pub struct LoginSelection {
|
||||||
/// Authenticated PAM ticket.
|
|
||||||
pub ticket: AuthTicket,
|
pub ticket: AuthTicket,
|
||||||
/// Selected session descriptor.
|
|
||||||
pub session: SessionDescriptor,
|
pub session: SessionDescriptor,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +128,8 @@ struct App {
|
|||||||
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
|
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
|
||||||
refreshing: bool,
|
refreshing: bool,
|
||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
|
preferences: LastUsedPreferences,
|
||||||
|
preference_path: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -181,6 +181,8 @@ impl App {
|
|||||||
refresh_receiver: None,
|
refresh_receiver: None,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
|
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
|
||||||
|
preferences: LastUsedPreferences::default(),
|
||||||
|
preference_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +260,7 @@ impl App {
|
|||||||
.get(self.selected)
|
.get(self.selected)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| Error::Session("no session selected".into()))?;
|
.ok_or_else(|| Error::Session("no session selected".into()))?;
|
||||||
|
self.remember_selected_session(&selected);
|
||||||
Ok(Some(LoginSelection {
|
Ok(Some(LoginSelection {
|
||||||
ticket,
|
ticket,
|
||||||
session: selected,
|
session: selected,
|
||||||
@@ -334,6 +337,25 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_preferred_session(&mut self) {
|
||||||
|
if let Some(index) = self
|
||||||
|
.preferences
|
||||||
|
.preferred_index(&self.username, &self.sessions)
|
||||||
|
{
|
||||||
|
self.selected = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_selected_session(&mut self, session: &SessionDescriptor) {
|
||||||
|
self.preferences.remember(&self.username, session);
|
||||||
|
let Some(path) = self.preference_path.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = preferences::save(&path, &self.preferences) {
|
||||||
|
tracing::debug!(%error, "could not persist last-session preference");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn poll_faillock(&mut self) {
|
fn poll_faillock(&mut self) {
|
||||||
let Some(receiver) = &self.faillock_receiver else {
|
let Some(receiver) = &self.faillock_receiver else {
|
||||||
return;
|
return;
|
||||||
@@ -995,6 +1017,7 @@ impl App {
|
|||||||
self.auth_blocked_until = None;
|
self.auth_blocked_until = None;
|
||||||
self.faillock_status = None;
|
self.faillock_status = None;
|
||||||
self.status = format!("Selected account {}", self.username);
|
self.status = format!("Selected account {}", self.username);
|
||||||
|
self.apply_preferred_session();
|
||||||
self.start_faillock_query();
|
self.start_faillock_query();
|
||||||
self.notify(
|
self.notify(
|
||||||
&format!("Selected account {}", self.username),
|
&format!("Selected account {}", self.username),
|
||||||
@@ -1368,9 +1391,25 @@ pub fn run(
|
|||||||
(Vec::new(), Vec::new())
|
(Vec::new(), Vec::new())
|
||||||
};
|
};
|
||||||
let background = Background::load(&config.visual)?;
|
let background = Background::load(&config.visual)?;
|
||||||
|
let preference_path = config
|
||||||
|
.environment_switcher
|
||||||
|
.remember_last_session
|
||||||
|
.then(|| config.environment_switcher.last_session_path.clone());
|
||||||
|
let preferences =
|
||||||
|
preference_path
|
||||||
|
.as_deref()
|
||||||
|
.map_or_else(LastUsedPreferences::default, |path| {
|
||||||
|
preferences::load(path).unwrap_or_else(|error| {
|
||||||
|
tracing::debug!(%error, "could not load last-session preferences");
|
||||||
|
LastUsedPreferences::default()
|
||||||
|
})
|
||||||
|
});
|
||||||
let mut app = App::new(config, auth, preview, sessions, accounts, background);
|
let mut app = App::new(config, auth, preview, sessions, accounts, background);
|
||||||
app.account_statuses = account_statuses;
|
app.account_statuses = account_statuses;
|
||||||
app.account_index = (!app.accounts.is_empty()).then_some(0);
|
app.account_index = (!app.accounts.is_empty()).then_some(0);
|
||||||
|
app.preferences = preferences;
|
||||||
|
app.preference_path = preference_path;
|
||||||
|
app.apply_preferred_session();
|
||||||
app.start_faillock_query();
|
app.start_faillock_query();
|
||||||
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
|
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
|
||||||
}
|
}
|
||||||
@@ -1654,6 +1693,54 @@ mod tests {
|
|||||||
assert!(rendered.contains("New password"));
|
assert!(rendered.contains("New password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_session_preference_follows_account_and_persists_success() {
|
||||||
|
let auth = AuthService::new().expect("PAM worker should start");
|
||||||
|
let sessions = vec![
|
||||||
|
SessionDescriptor {
|
||||||
|
kind: session::SessionKind::Tty,
|
||||||
|
title: "TTY shell".into(),
|
||||||
|
argv: Vec::new(),
|
||||||
|
current_desktop: None,
|
||||||
|
session_desktop: None,
|
||||||
|
},
|
||||||
|
SessionDescriptor {
|
||||||
|
kind: session::SessionKind::Wayland,
|
||||||
|
title: "Sway".into(),
|
||||||
|
argv: vec!["sway".into()],
|
||||||
|
current_desktop: None,
|
||||||
|
session_desktop: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut app = App::new(
|
||||||
|
Config::default(),
|
||||||
|
auth,
|
||||||
|
true,
|
||||||
|
sessions,
|
||||||
|
vec![Identity {
|
||||||
|
username: "alice".into(),
|
||||||
|
uid: 1000,
|
||||||
|
gid: 1000,
|
||||||
|
home: "/home/alice".into(),
|
||||||
|
shell: "/bin/sh".into(),
|
||||||
|
}],
|
||||||
|
Background::load(&Config::default().visual).expect("default background should load"),
|
||||||
|
);
|
||||||
|
app.preferences.remember("alice", &app.sessions[1]);
|
||||||
|
app.apply_preferred_session();
|
||||||
|
assert_eq!(app.selected, 1);
|
||||||
|
|
||||||
|
let directory =
|
||||||
|
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
|
||||||
|
app.preference_path = Some(directory.join("last-session.toml"));
|
||||||
|
let selected = app.sessions[1].clone();
|
||||||
|
app.remember_selected_session(&selected);
|
||||||
|
let loaded = preferences::load(&directory.join("last-session.toml"))
|
||||||
|
.expect("selected session should persist");
|
||||||
|
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
|
||||||
|
let _ = std::fs::remove_dir_all(directory);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filtering_is_smart_case_and_selects_a_matching_session() {
|
fn filtering_is_smart_case_and_selects_a_matching_session() {
|
||||||
let auth = AuthService::new().expect("PAM worker should start");
|
let auth = AuthService::new().expect("PAM worker should start");
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ Latchd has two test layers:
|
|||||||
* `make integration-test` builds the binary and exercises metadata, TOML
|
* `make integration-test` builds the binary and exercises metadata, TOML
|
||||||
validation, preflight, and the non-authenticating preview path. It does not
|
validation, preflight, and the non-authenticating preview path. It does not
|
||||||
need PAM credentials, root, a VT, X11, or Wayland.
|
need PAM credentials, root, a VT, X11, or Wayland.
|
||||||
|
* `make pam-fixture-test` installs unique temporary PAM services only when run
|
||||||
|
as root, executes authentication/password-change fault tests and the session
|
||||||
|
metadata/environment handoff tests, then removes the services on exit.
|
||||||
|
Non-root runs report `SKIP`.
|
||||||
* `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and
|
* `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and
|
||||||
systemd-logind capabilities. Missing capabilities are reported as `SKIP`,
|
systemd-logind capabilities. Missing capabilities are reported as `SKIP`,
|
||||||
never as a successful test. Use `make system-e2e-strict` only in a prepared
|
never as a successful test. Use `make system-e2e-strict` only in a prepared
|
||||||
@@ -22,6 +26,7 @@ For a local release-style run:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make integration-test
|
make integration-test
|
||||||
|
make pam-fixture-test
|
||||||
make docker-test
|
make docker-test
|
||||||
make system-e2e
|
make system-e2e
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# latchd configuration. All paths are validated before the login screen starts.
|
|
||||||
|
|
||||||
tty = 2
|
tty = 2
|
||||||
main_log_path = "/var/log/latchd.log"
|
main_log_path = "/var/log/latchd.log"
|
||||||
client_log_path = "/var/log/latchd.client.log"
|
client_log_path = "/var/log/latchd.client.log"
|
||||||
@@ -23,10 +21,10 @@ scripts_path = "/etc/latchd/wayland"
|
|||||||
[environment_switcher]
|
[environment_switcher]
|
||||||
include_tty_shell = false
|
include_tty_shell = false
|
||||||
max_display_length = 18
|
max_display_length = 18
|
||||||
|
remember_last_session = true
|
||||||
|
last_session_path = "/var/lib/latchd/last-session.toml"
|
||||||
|
|
||||||
# Power actions use an executable path and argv, never a shell command string.
|
# Power actions use an executable and arguments. Latchd does not use a shell.
|
||||||
# Keep this section empty unless the installation explicitly wants console
|
|
||||||
# power controls.
|
|
||||||
[power_controls]
|
[power_controls]
|
||||||
entries = []
|
entries = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// auth — faillock integration.
|
|
||||||
//
|
|
||||||
// Reads /etc/security/faillock.conf for deny count, executes
|
|
||||||
// faillock --user to get tally, supports --reset via admin bypass.
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
// auth — PAM authentication with CGO bindings.
|
// Keep each PAM transaction on one OS thread. PAM modules can use thread-local state.
|
||||||
//
|
|
||||||
// Uses runtime.LockOSThread to prevent goroutine migration during
|
|
||||||
// PAM transactions, as PAM modules rely on thread-local storage.
|
|
||||||
//
|
|
||||||
// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs.
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -12,11 +7,11 @@ package auth
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
// Match the //export-generated signature.
|
// Match the signature that cgo generates for //export.
|
||||||
extern int latchd_pam_conv(int, struct pam_message **,
|
extern int latchd_pam_conv(int, struct pam_message **,
|
||||||
struct pam_response **, void *);
|
struct pam_response **, void *);
|
||||||
|
|
||||||
// Store a uintptr_t handle in C heap so the Go GC never sees a Go pointer.
|
// Store the numeric handle in C memory. Do not pass a Go pointer to PAM.
|
||||||
static void *storeHandle(uintptr_t h) {
|
static void *storeHandle(uintptr_t h) {
|
||||||
uintptr_t *p = malloc(sizeof(uintptr_t));
|
uintptr_t *p = malloc(sizeof(uintptr_t));
|
||||||
if (p) *p = h;
|
if (p) *p = h;
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// auth — UTMPX user accounting (glibc only).
|
|
||||||
//
|
|
||||||
// Mirrors vendor/src/auth/utmpx.rs.
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
// Helper: assign seconds + microseconds to utmpx's ut_tv field.
|
// Let the C compiler convert values for either native or compatibility ut_tv fields.
|
||||||
// The C compiler handles the type conversion, so this works whether
|
|
||||||
// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct
|
|
||||||
// (__uint32_t tv_sec / __int32_t tv_usec).
|
|
||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {
|
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ func LoadVariables(filename string) (map[string]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read %s: %w", filename, err)
|
return nil, fmt.Errorf("read %s: %w", filename, err)
|
||||||
}
|
}
|
||||||
// Parse a flat TOML table of string values.
|
|
||||||
vars := make(map[string]string)
|
vars := make(map[string]string)
|
||||||
var table map[string]any
|
var table map[string]any
|
||||||
if err := toml.Unmarshal(raw, &table); err != nil {
|
if err := toml.Unmarshal(raw, &table); err != nil {
|
||||||
@@ -180,8 +179,6 @@ func LoadVariables(filename string) (map[string]string, error) {
|
|||||||
return vars, nil
|
return vars, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── variable substitution ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
func resolveVars(in string, vars map[string]string) string {
|
func resolveVars(in string, vars map[string]string) string {
|
||||||
for k, v := range vars {
|
for k, v := range vars {
|
||||||
in = strings.ReplaceAll(in, "$"+k, v)
|
in = strings.ReplaceAll(in, "$"+k, v)
|
||||||
@@ -189,8 +186,6 @@ func resolveVars(in string, vars map[string]string) string {
|
|||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── embedded default config ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
const defaultTOML = `
|
const defaultTOML = `
|
||||||
tty = 2
|
tty = 2
|
||||||
main_log_path = "/var/log/latchd.log"
|
main_log_path = "/var/log/latchd.log"
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"latchd/tui/bg"
|
"latchd/tui/bg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── pixelated user avatar ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
var avatarArt = []string{
|
var avatarArt = []string{
|
||||||
` ▄▄▄▄▄ `,
|
` ▄▄▄▄▄ `,
|
||||||
` ▄███████▄ `,
|
` ▄███████▄ `,
|
||||||
@@ -31,8 +29,6 @@ var avatarArt = []string{
|
|||||||
` ██ ██ `,
|
` ██ ██ `,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── messages ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
|
|
||||||
type authResult struct {
|
type authResult struct {
|
||||||
@@ -57,8 +53,6 @@ const (
|
|||||||
modeAdminPass
|
modeAdminPass
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── result (sent back to main on successful login) ──────────────────────
|
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
@@ -67,8 +61,6 @@ type Result struct {
|
|||||||
Preview bool
|
Preview bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── model ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
preview bool
|
preview bool
|
||||||
@@ -137,8 +129,6 @@ func (m *Model) Init() tea.Cmd {
|
|||||||
return tea.Batch(tick(), tea.EnterAltScreen)
|
return tea.Batch(tick(), tea.EnterAltScreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── update ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -222,8 +212,6 @@ func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── keys ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
k := msg.String()
|
k := msg.String()
|
||||||
|
|
||||||
@@ -392,8 +380,6 @@ func (m *Model) selectedEnv() session.PostLoginEnv {
|
|||||||
return m.envs[m.selIdx]
|
return m.envs[m.selIdx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── commands ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (m *Model) loginCmd() tea.Cmd {
|
func (m *Model) loginCmd() tea.Cmd {
|
||||||
if m.preview {
|
if m.preview {
|
||||||
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
|
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
|
||||||
@@ -422,14 +408,10 @@ func (m *Model) adminBypassCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── queries ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (m *Model) Result() *Result { return m.result }
|
func (m *Model) Result() *Result { return m.result }
|
||||||
func (m *Model) Quitting() bool { return m.quitting }
|
func (m *Model) Quitting() bool { return m.quitting }
|
||||||
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
|
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
|
||||||
|
|
||||||
// ── view ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
accent = lipgloss.Color("208") // orange
|
accent = lipgloss.Color("208") // orange
|
||||||
subtle = lipgloss.Color("8") // grey
|
subtle = lipgloss.Color("8") // grey
|
||||||
@@ -527,7 +509,6 @@ func overlayCenter(w, h int, content string) string {
|
|||||||
func (m *Model) renderLayout() string {
|
func (m *Model) renderLayout() string {
|
||||||
clock := m.renderClock()
|
clock := m.renderClock()
|
||||||
|
|
||||||
// top bar: power hints on left, clock on right
|
|
||||||
var topParts []string
|
var topParts []string
|
||||||
topParts = append(topParts, m.renderPowerHints())
|
topParts = append(topParts, m.renderPowerHints())
|
||||||
if clock != "" {
|
if clock != "" {
|
||||||
@@ -535,24 +516,19 @@ func (m *Model) renderLayout() string {
|
|||||||
}
|
}
|
||||||
top := strings.Join(topParts, " ")
|
top := strings.Join(topParts, " ")
|
||||||
|
|
||||||
// avatar + form side by side
|
|
||||||
avatar := m.renderAvatar()
|
avatar := m.renderAvatar()
|
||||||
form := m.renderForm()
|
form := m.renderForm()
|
||||||
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
||||||
|
|
||||||
// DE switcher with specifier label
|
|
||||||
sw := m.renderSwitcher()
|
sw := m.renderSwitcher()
|
||||||
|
|
||||||
// caps lock
|
|
||||||
caps := m.renderCapsLock()
|
caps := m.renderCapsLock()
|
||||||
|
|
||||||
// admin unlock
|
|
||||||
var admin string
|
var admin string
|
||||||
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
||||||
admin = m.renderAdminForm()
|
admin = m.renderAdminForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
// status
|
|
||||||
st := m.renderStatus()
|
st := m.renderStatus()
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -655,7 +631,6 @@ func (m *Model) renderSwitcher() string {
|
|||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
// session specifier (like "x11", "wayland")
|
|
||||||
spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
|
spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
|
||||||
|
|
||||||
if cfg.ShowMovers && m.selIdx > 0 {
|
if cfg.ShowMovers && m.selIdx > 0 {
|
||||||
@@ -679,7 +654,6 @@ func (m *Model) renderSwitcher() string {
|
|||||||
|
|
||||||
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
|
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
|
||||||
|
|
||||||
// Dot indicators
|
|
||||||
var dots []string
|
var dots []string
|
||||||
for i := 0; i < len(m.envs); i++ {
|
for i := 0; i < len(m.envs); i++ {
|
||||||
if i == m.selIdx {
|
if i == m.selIdx {
|
||||||
@@ -747,8 +721,6 @@ func (m *Model) renderStatus() string {
|
|||||||
return st.Align(lipgloss.Center).Width(70).Render(m.status)
|
return st.Align(lipgloss.Center).Width(70).Render(m.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── caps lock detection ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const kdgkbled = 0x4B64
|
const kdgkbled = 0x4B64
|
||||||
|
|
||||||
func capsLockOn() bool {
|
func capsLockOn() bool {
|
||||||
@@ -762,8 +734,6 @@ func capsLockOn() bool {
|
|||||||
return leds&0x04 != 0
|
return leds&0x04 != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── field editing ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func handleField(k string, buf *string, cur, scroll *int) {
|
func handleField(k string, buf *string, cur, scroll *int) {
|
||||||
switch {
|
switch {
|
||||||
case k == "backspace" || k == "ctrl+h":
|
case k == "backspace" || k == "ctrl+h":
|
||||||
@@ -846,8 +816,6 @@ func abs(n int) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tick ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func tick() tea.Cmd {
|
func tick() tea.Cmd {
|
||||||
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
|
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// latchd — TUI display/login manager for GNU/Linux and BSD.
|
|
||||||
//
|
|
||||||
// Supports TTY, X11, and Wayland sessions with PAM authentication.
|
|
||||||
// Go rewrite of Lemurs (Rust source under vendor/).
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -193,8 +189,6 @@ func setupLog(path string) {
|
|||||||
log.SetFlags(log.LstdFlags)
|
log.SetFlags(log.LstdFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── chvt ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const vtA = 0x5606
|
const vtA = 0x5606
|
||||||
const vtW = 0x5607
|
const vtW = 0x5607
|
||||||
const kbt = 0x4B33
|
const kbt = 0x4B33
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// session — environment variable setup for latchd sessions.
|
|
||||||
//
|
|
||||||
// Mirrors vendor/src/post_login/env_variables.rs.
|
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// session — process execution and session spawning.
|
|
||||||
//
|
|
||||||
// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs.
|
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// session — environment scanning from xsessions, wayland-sessions,
|
|
||||||
// and custom script directories. Includes desktop entry parser.
|
|
||||||
//
|
|
||||||
// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry.
|
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
package session
|
package session
|
||||||
|
|
||||||
// Wayland session spawning is handled in exec.go/spawnWayland.
|
|
||||||
// This file exists for future Wayland-specific compositor logic.
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// session — X11 server setup.
|
|
||||||
//
|
|
||||||
// Mirrors vendor/src/post_login/x.rs:setup_x.
|
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -47,7 +44,7 @@ func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, er
|
|||||||
dd = "0" + dd
|
dd = "0" + dd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore SIGUSR1 so Xorg sends it when ready.
|
// Xorg sends SIGUSR1 only if the parent ignores it during startup.
|
||||||
signal.Ignore(syscall.SIGUSR1)
|
signal.Ignore(syscall.SIGUSR1)
|
||||||
|
|
||||||
cmd := exec.Command(cfg.SystemShell, "-c",
|
cmd := exec.Command(cfg.SystemShell, "-c",
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
// bg — cellular automata background for the TUI.
|
// The renderer packs two cell rows into each Unicode half-block row.
|
||||||
//
|
// Two buffers let Tick update the grid without an allocation per frame.
|
||||||
// Conway's Game of Life rendered at 30 Hz using Unicode half-block
|
|
||||||
// characters (▀) to double the effective vertical resolution.
|
|
||||||
// The grid is double-buffered for zero-allocation ticking, and
|
|
||||||
// render output is built in a pre-allocated strings.Builder so the
|
|
||||||
|
|
||||||
package bg
|
package bg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,7 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bright orange (accent) for alive cells, dark grey for dead.
|
|
||||||
const (
|
const (
|
||||||
fgAlive = "\x1b[38;2;255;127;0m"
|
fgAlive = "\x1b[38;2;255;127;0m"
|
||||||
fgDead = "\x1b[38;2;30;30;30m"
|
fgDead = "\x1b[38;2;30;30;30m"
|
||||||
@@ -92,8 +86,6 @@ func (a *Automata) count(g []uint8, x, y, w, h int) uint8 {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame returns the current generation as a single string suitable for
|
|
||||||
// writing to the alternate screen.
|
|
||||||
func (a *Automata) Frame() string {
|
func (a *Automata) Frame() string {
|
||||||
a.buf.Reset()
|
a.buf.Reset()
|
||||||
src := a.a
|
src := a.a
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Exercise the non-interactive CLI contract without authenticating a user.
|
# Use --preview so this test does not need PAM, a VT, or a display server.
|
||||||
#
|
|
||||||
# This suite intentionally uses --preview. It validates TOML parsing, command
|
|
||||||
# line validation, build metadata, and the preflight path without requiring a
|
|
||||||
# PAM secret, a controlling VT, or a running display server.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
binary=${1:-target/debug/latchd}
|
binary=${1:-target/debug/latchd}
|
||||||
|
|||||||
93
scripts/test-pam-fixture.sh
Executable file
93
scripts/test-pam-fixture.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "pam-fixture: SKIP (root is required to install a temporary PAM policy)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
service="latchd-fixture-$$"
|
||||||
|
session_service="latchd-session-fixture-$$"
|
||||||
|
policy="/etc/pam.d/$service"
|
||||||
|
session_policy="/etc/pam.d/$session_service"
|
||||||
|
checker="/tmp/latchd-pam-session-check-$$"
|
||||||
|
module_source="/tmp/latchd-pam-session-env-$$.c"
|
||||||
|
module="/tmp/latchd-pam-session-env-$$.so"
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$policy" "$session_policy" "$checker" "$module_source" "$module"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
cat >"$policy" <<'PAM'
|
||||||
|
auth required pam_deny.so
|
||||||
|
account required pam_permit.so
|
||||||
|
password required pam_deny.so
|
||||||
|
session required pam_permit.so
|
||||||
|
PAM
|
||||||
|
|
||||||
|
cat >"$checker" <<'SH'
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
[ "${PAM_TTY:-}" = "tty2" ]
|
||||||
|
[ "${XDG_SESSION_CLASS:-}" = "user" ]
|
||||||
|
[ "${XDG_SESSION_TYPE:-}" = "wayland" ]
|
||||||
|
[ "${XDG_SESSION_DESKTOP:-}" = "niri" ]
|
||||||
|
[ "${XDG_SEAT:-}" = "seat0" ]
|
||||||
|
[ "${XDG_VTNR:-}" = "2" ]
|
||||||
|
SH
|
||||||
|
chmod 700 "$checker"
|
||||||
|
|
||||||
|
cat >"$module_source" <<'C'
|
||||||
|
#include <security/pam_appl.h>
|
||||||
|
#include <security/pam_modules.h>
|
||||||
|
|
||||||
|
PAM_EXTERN int pam_sm_open_session(
|
||||||
|
pam_handle_t *pamh,
|
||||||
|
int flags,
|
||||||
|
int argc,
|
||||||
|
const char **argv
|
||||||
|
) {
|
||||||
|
int result;
|
||||||
|
(void)flags;
|
||||||
|
(void)argc;
|
||||||
|
(void)argv;
|
||||||
|
|
||||||
|
result = pam_putenv(pamh, "XDG_SESSION_ID=fixture-session");
|
||||||
|
if (result != PAM_SUCCESS) return result;
|
||||||
|
result = pam_putenv(pamh, "XDG_RUNTIME_DIR=/tmp/latchd-runtime-fixture");
|
||||||
|
if (result != PAM_SUCCESS) return result;
|
||||||
|
return pam_putenv(
|
||||||
|
pamh,
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/latchd-runtime-fixture/bus"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PAM_EXTERN int pam_sm_close_session(
|
||||||
|
pam_handle_t *pamh,
|
||||||
|
int flags,
|
||||||
|
int argc,
|
||||||
|
const char **argv
|
||||||
|
) {
|
||||||
|
(void)pamh;
|
||||||
|
(void)flags;
|
||||||
|
(void)argc;
|
||||||
|
(void)argv;
|
||||||
|
return PAM_SUCCESS;
|
||||||
|
}
|
||||||
|
C
|
||||||
|
${CC:-cc} -shared -fPIC "$module_source" -o "$module" -lpam
|
||||||
|
chmod 700 "$module"
|
||||||
|
|
||||||
|
cat >"$session_policy" <<PAM
|
||||||
|
auth required pam_permit.so
|
||||||
|
account required pam_permit.so
|
||||||
|
password required pam_deny.so
|
||||||
|
session required $module
|
||||||
|
session required pam_exec.so $checker
|
||||||
|
PAM
|
||||||
|
|
||||||
|
LATCHD_PAM_FIXTURE_SERVICE="$service" \
|
||||||
|
LATCHD_PAM_SESSION_FIXTURE_SERVICE="$session_service" \
|
||||||
|
cargo test --locked -p latchd-auth --test pam_fixture -- --ignored
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Probe host capabilities needed for a real login-manager deployment.
|
# Report unavailable capabilities as SKIP. In strict mode, each SKIP is an error.
|
||||||
#
|
|
||||||
# This script never reports an unavailable capability as a pass. On an
|
|
||||||
# ordinary container it prints SKIP and exits successfully; --strict turns
|
|
||||||
# every SKIP into a failure for a prepared VM or privileged runner.
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
binary=${1:-}
|
binary=${1:-}
|
||||||
@@ -47,8 +43,7 @@ else
|
|||||||
skip "latchd binary not supplied; capability-only probe"
|
skip "latchd binary not supplied; capability-only probe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# This is deliberately a runtime capability check, not an authentication
|
# This checks the PAM runtime only. Authentication requires a test account and policy.
|
||||||
# claim. Authentication needs a disposable VM user and a test PAM policy.
|
|
||||||
if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then
|
if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then
|
||||||
pass "PAM runtime library is available"
|
pass "PAM runtime library is available"
|
||||||
else
|
else
|
||||||
@@ -70,16 +65,14 @@ else
|
|||||||
skip "faillock helper is unavailable"
|
skip "faillock helper is unavailable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A real VT is a character device and must be allocated by the host/VM. A
|
# A pseudo-terminal does not test VT allocation by the host or VM.
|
||||||
# pseudo-terminal from CI is not equivalent and is intentionally not accepted.
|
|
||||||
if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then
|
if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then
|
||||||
pass "Linux virtual terminal device is available"
|
pass "Linux virtual terminal device is available"
|
||||||
else
|
else
|
||||||
skip "Linux virtual terminal is unavailable (container or non-console host)"
|
skip "Linux virtual terminal is unavailable (container or non-console host)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Xvfb is a genuine X server and is safe to exercise without host display
|
# Xvfb tests X11 without access to the host display.
|
||||||
# access. If it is absent, this is a skip rather than a synthetic success.
|
|
||||||
if need_command Xvfb; then
|
if need_command Xvfb; then
|
||||||
display_number=$((100 + ($$ % 500)))
|
display_number=$((100 + ($$ % 500)))
|
||||||
display=":$display_number"
|
display=":$display_number"
|
||||||
@@ -113,8 +106,7 @@ else
|
|||||||
skip "Xvfb is unavailable"
|
skip "Xvfb is unavailable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A Wayland socket is meaningful only when a compositor owns it. Do not claim
|
# A compositor must own the Wayland socket. Installed client libraries are not sufficient.
|
||||||
# Wayland support merely because a client library or executable is installed.
|
|
||||||
wayland_socket=
|
wayland_socket=
|
||||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
|
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
|
||||||
case "$WAYLAND_DISPLAY" in
|
case "$WAYLAND_DISPLAY" in
|
||||||
@@ -128,8 +120,7 @@ else
|
|||||||
skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)"
|
skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# systemd-logind is not normally PID 1 in a Docker container. loginctl output
|
# Check loginctl only when systemd-logind is available.
|
||||||
# is checked only when the manager and seat are actually reachable.
|
|
||||||
if need_command loginctl && [ -d /run/systemd/system ]; then
|
if need_command loginctl && [ -d /run/systemd/system ]; then
|
||||||
if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then
|
if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then
|
||||||
pass "systemd-logind seat0 is reachable"
|
pass "systemd-logind seat0 is reachable"
|
||||||
|
|||||||
Reference in New Issue
Block a user