diff --git a/.gitignore b/.gitignore index ad0f3de..8fc0a10 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ FEATURE_CHECKLIST.md IMPLEMENTATION_CHECKLIST.md audit_code.md audit_rust_code.md +.latchd-work/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 650b6e8..78c2226 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -40,6 +40,8 @@ rust-verify: stage: verify script: - make check COLOR=0 + - make integration-test COLOR=0 + - make system-e2e COLOR=0 - make version COLOR=0 artifacts: when: always diff --git a/CHANGELOG.md b/CHANGELOG.md index 9774d9d..7bfcbca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.4.0 — Advanced login workflow + +- Added account policy inspection for locked passwords, password expiry, + account expiry, inactivity, and missing shadow data without exposing hashes. +- Added active UTMPX session discovery and systemd-logind capability detection + with an explicit UTMPX fallback. +- Improved faillock parsing for legacy, username-prefixed, and timestamped + helper output. PAM remains authoritative for every login decision. +- Added smart-case filtering for users and sessions, non-blocking Ctrl-R discovery + refresh, contextual footer hints, high-contrast mode, and policy notices in + the TUI. +- Added bounded local authentication backoff after repeated failures and reset + it when switching accounts. +- Added CLI integration tests and host capability probes. Docker now runs the + release binary through the safe CLI and capability suites. +- Added documented strict VM validation for PAM policy, VT, X11, Wayland, and + systemd-logind; missing capabilities are reported as skips in ordinary CI. +- Bumped the workspace release version to 0.4.0. + ## 0.3.0 — Rust workspace release preparation - Rust is the supported login manager; the Go/cgo prototype is quarantined at diff --git a/Cargo.lock b/Cargo.lock index 784beac..ff6f0c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,7 +782,7 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] name = "latchd" -version = "0.3.0" +version = "0.4.0" dependencies = [ "clap", "color-eyre", @@ -796,7 +796,7 @@ dependencies = [ [[package]] name = "latchd-auth" -version = "0.3.0" +version = "0.4.0" dependencies = [ "latchd-core", "libc", @@ -805,7 +805,7 @@ dependencies = [ [[package]] name = "latchd-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "libc", "rand 0.9.5", @@ -818,7 +818,7 @@ dependencies = [ [[package]] name = "latchd-tui" -version = "0.3.0" +version = "0.4.0" dependencies = [ "crossterm", "image", diff --git a/Cargo.toml b/Cargo.toml index efd13ff..9883fa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/latchd-core", "crates/latchd-auth", "crates/latchd-tui"] resolver = "3" [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.88" license = "Apache-2.0" diff --git a/Dockerfile.test b/Dockerfile.test index 7bb4a08..4f543f7 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -13,8 +13,12 @@ COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ COPY build.rs ./ COPY rust ./rust COPY crates ./crates +COPY scripts ./scripts RUN cargo fmt --all -- --check \ - && cargo test --locked --all-targets \ - && cargo clippy --locked --all-targets --all-features -- -D warnings \ - && RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps + && cargo test --locked --workspace --all-targets \ + && cargo clippy --locked --workspace --all-targets --all-features -- -D warnings \ + && RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \ + && cargo build --locked --release \ + && ./scripts/test-cli.sh target/release/latchd \ + && ./scripts/test-system-e2e.sh target/release/latchd diff --git a/Makefile b/Makefile index e2248a3..322d4bf 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ RED := endif .PHONY: all deps build release-build test check fmt lint doc audit docker-test \ + integration-test system-e2e system-e2e-strict \ package release release-check version install uninstall clean run-preview \ legacy-build legacy-check enable update help @@ -94,6 +95,15 @@ check: fmt test lint doc ## Run the complete local verification gate docker-test: ## Run the pinned Docker verification suite @./scripts/test-docker.sh +integration-test: build ## Exercise safe, non-authenticating CLI paths + @./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)' + +system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges + @./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)' + +system-e2e-strict: ## Require all real login-manager capabilities (prepared VM only) + @./scripts/test-system-e2e.sh --strict '$(BUILD_DIR)/$(BINARY)' + release-check: ## Validate a release version and optional tag @[[ '$(VERSION)' =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$$ ]] || { echo 'invalid VERSION: $(VERSION)'; exit 1; } @cargo_version="$$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"; \ @@ -125,7 +135,7 @@ package: release-check build ## Create a deterministic install archive and check '$(PACKAGE_FILE)' "$$checksum" '$(VERSION)' > '$(DIST_DIR)/release.env' @printf '$(GREEN)created$(RESET) %s\n' '$(PACKAGE_FILE)' -release: check audit docker-test package ## Run all gates and produce release artifacts +release: check audit integration-test system-e2e docker-test package ## Run all gates and produce release artifacts install: build ## Install the binary, config, PAM policy, and systemd unit @install -Dm755 '$(BUILD_DIR)/$(BINARY)' '$(BINDIR)/$(BINARY)' diff --git a/README.md b/README.md index 9e862cf..42d2cbc 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,142 @@ # latchd -`latchd` is a Linux/PAM terminal login manager written in Rust. It presents a keyboard-first Ratatui login screen on a virtual terminal, authenticates through PAM, and starts an isolated TTY, X11, or Wayland session with an explicit per-user environment. +Want a TUI-based login manager because you are tired of GDM or Plasma Login? Latchd gives you a small, keyboard-first login screen on a Linux virtual terminal. It uses PAM for authentication and can start a TTY shell, an X11 desktop, or a Wayland desktop. -The Rust binary is the supported implementation. The original Go/cgo tree under `legacy/go/` is retained as a quarantined comparison artifact and is not built by the default targets. +Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for reference and is not built or shipped. -## Security model +## What it does -- The service requires root for real sessions and refuses to start from an existing desktop session. -- PAM work is serialized on one dedicated worker thread. PAM handles never cross that thread boundary; authentication tickets are opaque RAII-owned transactions. -- Password buffers are zeroized and are never placed in argv, logs, or the rendered UI. -- Session environments are rebuilt from an allowlist for every login. Shell commands are argv vectors; no configured command is passed through a shell. -- Desktop files and session scripts must be root-owned, regular, non-group-writable files. Account shells such as `false` and `nologin` are rejected. -- Xauthority cookies are generated in a `0600` file owned by the account, supplied to `xauth` through stdin, and removed by an RAII cleanup guard. -- Main, graphical-session, and Xorg logs are private (`0600`) and opened without following symlinks. -- Power actions are an explicit absolute-path allowlist and require confirmation. They are disabled in `--preview` mode. +- Finds normal login accounts through NSS. +- Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision. +- Lets you switch users and sessions without leaving the login screen. +- 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. +- 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. +- Runs safely in preview mode for UI work and configuration checks. -The implementation intentionally does not provide an administrator unlock shortcut: account lockout and authorization remain the responsibility of the configured PAM policy. +The real login handoff still needs a Linux system with PAM, a virtual terminal, and the display/session services you want to use. The Docker tests cover builds, unit tests, rendering, configuration, and capability probes; they do not pretend to be a real VT or systemd-logind session. -## Build and test +## Build -Requirements: Rust 1.88 or newer, a Linux PAM development package, and a C toolchain. Debian/Ubuntu packages are `libpam0g-dev`, `libpam-modules`, and `build-essential`. +On Debian or Ubuntu, install the build dependencies first: -```bash -make deps -make build -make test # unit tests and rustdoc warnings -make lint # Clippy with warnings denied -make audit # RustSec dependency advisory scan -make docker-test # the same checks in a pinned Debian container -make package # deterministic install archive plus SHA-256 -make release # all local gates, Docker, and package output +```sh +sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules ``` -`make build` installs the release binary at `build/latchd`. The reproducible test image is defined by [Dockerfile.test](Dockerfile.test) and does not require `--privileged`. +Then run: -## Releases +```sh +make check +make docker-test +make integration-test +``` -The supported release pipeline is GitLab CI in [`.gitlab-ci.yml`](.gitlab-ci.yml). -Every branch and merge request runs formatting, tests, Clippy, rustdoc, a -RustSec dependency audit, and the Docker suite. Docker-in-Docker requires a trusted privileged GitLab Runner with -the runner client-certificate volume configured; untrusted fork pipelines -should not be assigned to that runner. A SemVer tag -such as `v0.3.0` additionally builds a deterministic -Linux archive containing the binary, systemd unit, PAM policy, configuration, -license, changelog, build metadata, and a SHA-256 checksum, then publishes it -as a GitLab Release. The tag version must match the workspace version. CI -creates the GitLab Release only after both Rust and Docker verification pass. +`make build` writes the optimized binary to `build/latchd`. `make package` creates a deterministic archive and SHA-256 file in `dist/`. -Release builds use `SOURCE_DATE_EPOCH`, commit metadata, stripped symbols, -`opt-level = 3`, thin LTO, one codegen unit, no incremental state, and -`panic = "abort"`. Set `TARGET` or `DESTDIR` when invoking `make package` for a -controlled package build; `VERSION` must match the workspace version. The -legacy Go tree is physically isolated under `legacy/go/` and is never included -in release artifacts. +## Try the UI -## Installation +Preview mode does not call PAM, start a session, or run power actions: -```bash +```sh +make run-preview +# or +./build/latchd --preview --no-log +``` + +Use `LATCHD_ASCII=1` for terminals without reliable Unicode support, `NO_COLOR=1` for monochrome output, and `LATCHD_HIGH_CONTRAST=1` for a stronger focus palette. + +## Install on a test machine + +Do this on a disposable machine first. Keep another login path available while changing a display manager. + +```sh sudo make install sudo systemctl enable latchd.service ``` -Installation places the binary at `/usr/bin/latchd`, the configuration at `/etc/latchd/config.toml`, the PAM policy at `/etc/pam.d/latchd`, and the systemd unit at `/usr/lib/systemd/system/latchd.service`. The unit owns `/dev/tty2`; adjust `TTYPath`, `After`, and the `tty` configuration together if another VT is required. +This installs: -Keep the previous package/binary available while validating a deployment. `--preview` is a safe UI exercise that does not authenticate, create a user session, or execute power actions. `--no-tui` (also `--preflight`) validates configuration and exits without entering the login screen. +- `/usr/bin/latchd` +- `/etc/latchd/config.toml` +- `/etc/pam.d/latchd` +- `/usr/lib/systemd/system/latchd.service` -## Command line - -```text -latchd [OPTIONS] - - -c, --config Configuration TOML (default /etc/latchd/config.toml) - -v, --variables Optional TOML variable file - --tty Override the virtual terminal number - --xsessions Override the X11 desktop-entry directory - --wlsessions Override the Wayland desktop-entry directory - --initial-path Override the session PATH - --preview Draw the UI without authentication or session startup - --no-tui, --preflight Validate configuration and exit - --no-log Disable the main log - --show-config Print the key validated settings and exit - --build-info Print version, commit, and build epoch and exit - -V, --version Print the version -``` - -An explicit configuration or variables file used by a real (non-preview) service must be root-owned, regular, and not group/other writable. The default configuration is optional: when `/etc/latchd/config.toml` is absent, built-in defaults are used. +The default service owns `/dev/tty2`. Change the `tty` setting and the unit's `TTYPath` together when using another virtual terminal. ## Configuration -See [extra/config.toml](extra/config.toml) for the complete schema. Values may be substituted from a TOML variables file with `$NAME` syntax. Power entries contain an executable path and an argv array, never a shell command string. +The complete example is in [`extra/config.toml`](extra/config.toml). Important sections are: -Session discovery reads root-owned `.desktop` files from the configured X11 and Wayland directories and executable root-owned scripts from `/etc/latchd/wms` and `/etc/latchd/wayland`. Unsupported desktop-entry field codes are rejected rather than interpreted by a shell. A TTY shell is offered when no graphical sessions are available, or when `include_tty_shell = true`. +- `[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. +- `[environment_switcher]` controls session choices and the optional TTY shell. +- `[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. -When enabled, the account chooser discovers eligible NSS users (by default, -UID 1000 and above with a real home directory and login shell). `Ctrl-N` and -`Ctrl-P` cycle accounts, clear the password, and start a bounded advisory -`faillock` query. The displayed lockout state never overrides PAM: a PAM -conversation remains the only authorization decision. Set -`accounts.enumerate = false` when listing local accounts is not appropriate. +Configuration files used by a real root service must be root-owned and not writable by group or other users. Use a variables file for deployment-specific values instead of putting secrets in command arguments. -The `[visual]` section is TUI-only. It can load a root-owned PNG/JPEG as a -downsampled terminal-cell wallpaper, run a low-rate cellular-automata ambient -layer, and show short-lived notifications. `reduced_motion = true` disables -motion while preserving the static layout; `NO_COLOR=1` disables the image -layer and all color effects. - -## TUI controls - -The screen is designed for 80×24 and remains usable down to 60 columns. Below the supported minimum it reports the required size without drawing a partial frame. +## Controls | Key | Action | | --- | --- | | Tab / Shift-Tab | Move focus | -| Up / Down | Move focus | -| Left / Right | Change session when the chooser is focused | -| Ctrl-N / Ctrl-P | Select the next / previous discovered account | -| Ctrl-B | Toggle the TUI-only wallpaper/automata background | +| Up / Down | Move focus or move in a filtered list | +| Left / Right | Change the selected session | +| `/` | Filter sessions or discovered accounts | +| Ctrl-R | Refresh account and session discovery | +| Ctrl-N / Ctrl-P | Select the next or previous account | +| Ctrl-B | Toggle wallpaper and ambient effects | | Ctrl-M | Toggle reduced motion | | Enter | Authenticate from the password field | -| Esc | Clear the password / cancel power confirmation | +| Esc | Clear the password or close a popup | | `?` | Show keyboard help | | Ctrl-C | Restore the terminal and quit | -| F1–F12 | Confirm and run a configured power action | +| F1–F12 | Confirm a configured power action | -`NO_COLOR=1` requests a monochrome semantic style, and `LATCHD_ASCII=1` selects ASCII session markers for terminals without reliable Unicode support. +The filter is smart-case: lowercase searches ignore case; a search containing uppercase characters is case-sensitive. The account-age and faillock messages are advisory. PAM remains the only authorization decision. + +## Testing a deployment + +The ordinary tests do not need a privileged container: + +```sh +make check +make audit +make docker-test +make integration-test +make system-e2e +``` + +`make system-e2e` reports real host capabilities as `PASS`, `SKIP`, or `FAIL`. It does not call an account password or claim that a pseudo-terminal is a virtual terminal. `make system-e2e-strict` is for a prepared disposable VM with a test PAM policy, a real VT, systemd-logind, and the X11 or Wayland stack under test. See [`docs/e2e-testing.md`](docs/e2e-testing.md). + +## Security notes + +- PAM handles stay on one worker thread. +- Password buffers are zeroized and never placed in argv, logs, or the TUI. +- Session environments are rebuilt from an allowlist. +- Desktop files, scripts, binaries, homes, logs, and configuration sources are checked before use. +- Xauthority files and session logs use private permissions and cleanup guards. +- Power actions require confirmation and are disabled in preview mode. +- Account status and faillock output never bypass PAM or provide an administrator unlock shortcut. ## Project layout ```text -crates/ -├── latchd-core/ validated config, accounts, sessions, and UTMPX accounting -├── latchd-auth/ PAM FFI, conversation callback, and transaction worker -└── latchd-tui/ Ratatui state machine, wallpaper, automata, and effects -rust/main.rs CLI, privilege checks, logging, and orchestration -extra/ example config, PAM policy, systemd unit, session directories -scripts/ Docker test entry point -legacy/ -└── go/ non-production Go reference (explicit `make legacy-build` only) +crates/latchd-core/ configuration, account discovery, sessions, UTMPX +crates/latchd-auth/ PAM FFI and the authentication worker +crates/latchd-tui/ Ratatui state, rendering, filtering, and effects +rust/main.rs command-line entry point and service orchestration +extra/ example configuration, PAM policy, and systemd unit +scripts/ Docker and CLI/system capability tests +legacy/go/ non-production Go reference ``` -## Platform support +## Releases -The first release targets Linux with PAM and systemd. BSD and non-PAM platforms are not claimed or tested. Real PAM/VT/X11 integration should be exercised on an isolated test host; the ordinary Docker suite covers compilation, unit tests, rendering tests, formatting, Clippy, and rustdoc without privileged container access. +GitLab CI runs formatting, tests, Clippy, rustdoc, RustSec auditing, CLI integration tests, capability probes, and the Docker suite. SemVer tags produce a reproducible Linux archive with checksums. Live VT, PAM-policy, X11, Wayland, and logind validation belongs in the isolated VM gate described above. ## License -Apache-2.0; see [LICENSE](LICENSE). +Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/crates/latchd-core/src/accounts.rs b/crates/latchd-core/src/accounts.rs index 3e6507d..b39cf13 100644 --- a/crates/latchd-core/src/accounts.rs +++ b/crates/latchd-core/src/accounts.rs @@ -6,7 +6,7 @@ use std::{ path::{Path, PathBuf}, process::{Command, Stdio}, thread, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use crate::{ @@ -29,6 +29,87 @@ pub struct Identity { pub shell: PathBuf, } +/// Effective account state determined from the login shell and shadow aging data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountState { + /// Account can be offered for login. + Active, + /// Password is disabled by the shadow lock marker. + Locked, + /// Password must be changed before login can continue. + PasswordExpired, + /// Account expiration date has elapsed. + AccountExpired, + /// Password inactivity period has elapsed. + AccountInactive, + /// Login shell explicitly disallows interactive sessions. + NoLoginShell, + /// Shadow data was not available or could not be parsed. + Unknown, +} + +/// Non-sensitive password and account aging information. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AccountStatus { + /// Effective state used by the chooser and diagnostics. + pub state: AccountState, + /// Day the password was last changed, if present. + pub password_changed_day: Option, + /// Day the password expires, if the policy specifies one. + pub password_expiry_day: Option, + /// Day the account expires, if specified. + pub account_expiry_day: Option, + /// Day after which an expired password disables the account. + pub inactive_after_day: Option, +} + +impl AccountStatus { + /// Returns whether this account may be presented as an active login choice. + pub const fn is_login_allowed(self) -> bool { + matches!(self.state, AccountState::Active) + } +} + +/// An identity paired with its non-sensitive account policy state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountRecord { + /// Discovered NSS identity. + pub identity: Identity, + /// Current local account status. + pub status: AccountStatus, +} + +/// A currently logged-in process obtained from UTMPX. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveSession { + /// Login name associated with the session. + pub username: String, + /// UTMPX line, such as `tty2` or `pts/0`. + pub terminal: String, + /// Session leader PID when reported by UTMPX. + pub pid: libc::pid_t, + /// Remote host, if supplied by the session producer. + pub host: Option, +} + +/// Runtime session-management backend detected on this host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionBackend { + /// systemd-logind runtime markers are available. + SystemdLogind, + /// UTMPX is available as a conservative fallback. + Utmpx, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowEntry { + hash: String, + last_change: Option, + max_days: Option, + inactive_days: Option, + account_expiry: Option, +} + /// A non-authoritative account lockout summary. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct FaillockStatus { @@ -81,6 +162,96 @@ pub fn discover(config: &AccountsConfig) -> Vec { accounts } +/// Discovers identities and attaches account-aging state when the shadow +/// database is readable. A missing or inaccessible shadow file yields +/// [`AccountState::Unknown`] rather than hiding the account. +pub fn discover_with_status(config: &AccountsConfig) -> Vec { + discover(config) + .into_iter() + .map(|identity| { + let status = inspect_account(&identity, config).unwrap_or(AccountStatus { + state: AccountState::Unknown, + password_changed_day: None, + password_expiry_day: None, + account_expiry_day: None, + inactive_after_day: None, + }); + AccountRecord { identity, status } + }) + .collect() +} + +/// Inspects password and account aging fields without returning password +/// hashes. The configured shadow path must be root-owned and non-writable. +pub fn inspect_account( + identity: &Identity, + config: &AccountsConfig, +) -> Result { + if !trusted_shadow(&config.shadow_path) { + return Err(Error::Config("shadow_path is not a trusted file".into())); + } + let content = std::fs::read_to_string(&config.shadow_path) + .io_context(format!("read {}", config.shadow_path.display()))?; + let entry = content.lines().find_map(|line| { + let (username, _) = line.split_once(':')?; + (username == identity.username) + .then(|| parse_shadow_entry(line)) + .flatten() + }); + let day = unix_day(); + Ok(account_status_from_shadow(identity, entry, day)) +} + +/// Returns all active UTMPX user-process records in deterministic order. +pub fn active_sessions() -> Vec { + let mut sessions = Vec::new(); + unsafe { + libc::setutxent(); + loop { + let entry = libc::getutxent(); + if entry.is_null() { + break; + } + let record = &*entry; + if record.ut_type != libc::USER_PROCESS { + continue; + } + let Some(username) = utmp_field(&record.ut_user) else { + continue; + }; + let Some(terminal) = utmp_field(&record.ut_line) else { + continue; + }; + sessions.push(ActiveSession { + username, + terminal, + pid: record.ut_pid, + host: utmp_field(&record.ut_host), + }); + } + libc::endutxent(); + } + sessions.sort_by(|left, right| { + left.username + .cmp(&right.username) + .then_with(|| left.terminal.cmp(&right.terminal)) + .then_with(|| left.pid.cmp(&right.pid)) + }); + sessions +} + +/// Detects whether systemd-logind has initialized its runtime seat state. +pub fn session_backend() -> SessionBackend { + if logind_markers_present( + Path::new("/run/systemd/system").is_dir(), + Path::new("/run/systemd/seats").is_dir(), + ) { + SessionBackend::SystemdLogind + } else { + SessionBackend::Utmpx + } +} + /// Runs the configured helper and parses its stable numeric output. pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result { let helper = &config.faillock_path; @@ -116,16 +287,26 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result) -> FaillockStatus { + let failures = parse_failure_count(output); let remaining = max_tries.map(|limit| limit.saturating_sub(failures)); - Ok(FaillockStatus { + FaillockStatus { available: true, failures, max_tries, remaining, locked_out: remaining.is_some_and(|value| value == 0), - }) + } } fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool { @@ -165,11 +346,49 @@ fn c_path(pointer: *const libc::c_char) -> Option { } fn parse_failure_count(bytes: &[u8]) -> u32 { - String::from_utf8_lossy(bytes) - .lines() - .filter_map(|line| line.split_whitespace().next()) - .find_map(|value| value.parse::().ok()) - .unwrap_or_default() + let mut rows = 0u32; + for line in String::from_utf8_lossy(bytes).lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some((_, count)) = line.split_once(':') + && let Some(value) = count.split_whitespace().next() + && let Ok(parsed) = value.parse::() + { + return parsed; + } + let mut fields = line.split_whitespace(); + let Some(first) = fields.next() else { + continue; + }; + if let Ok(parsed) = first.parse::() { + return parsed; + } + if is_date(first) && fields.next().is_some_and(is_time) { + rows = rows.saturating_add(1); + } + } + rows +} + +fn is_date(value: &str) -> bool { + let mut fields = value.split('-'); + fields + .next() + .is_some_and(|year| year.len() == 4 && year.chars().all(|c| c.is_ascii_digit())) + && fields + .next() + .is_some_and(|month| month.len() == 2 && month.chars().all(|c| c.is_ascii_digit())) + && fields + .next() + .is_some_and(|day| day.len() == 2 && day.chars().all(|c| c.is_ascii_digit())) + && fields.next().is_none() +} + +fn is_time(value: &str) -> bool { + let mut fields = value.split(':'); + fields.all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit())) } fn read_deny(path: &Path) -> Option { @@ -185,6 +404,118 @@ fn read_deny(path: &Path) -> Option { }) } +fn trusted_shadow(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + metadata.is_file() && metadata.uid() == 0 && metadata.mode() & 0o022 == 0 +} + +fn parse_shadow_entry(line: &str) -> Option { + let mut fields = line.split(':'); + let username = fields.next()?; + if username.is_empty() { + return None; + } + let hash = fields.next()?.to_owned(); + let last_change = parse_shadow_day(fields.next()?); + let _minimum_days = parse_shadow_day(fields.next()?); + let max_days = parse_shadow_day(fields.next()?); + let _warning_days = parse_shadow_day(fields.next()?); + let inactive_days = parse_shadow_day(fields.next()?); + let account_expiry = parse_shadow_day(fields.next()?); + let _reserved = fields.next()?; + Some(ShadowEntry { + hash, + last_change, + max_days, + inactive_days, + account_expiry, + }) +} + +fn parse_shadow_day(value: &str) -> Option { + (!value.is_empty() && value != "-1") + .then(|| value.parse::().ok()) + .flatten() + .filter(|day| *day >= 0) +} + +fn shadow_hash_is_locked(hash: &str) -> bool { + hash.is_empty() || hash.starts_with('!') || hash.starts_with('*') +} + +fn account_status_from_shadow( + identity: &Identity, + entry: Option, + today: i64, +) -> AccountStatus { + let Some(entry) = entry else { + return AccountStatus { + state: AccountState::Unknown, + password_changed_day: None, + password_expiry_day: None, + account_expiry_day: None, + inactive_after_day: None, + }; + }; + let password_expiry_day = entry + .last_change + .zip(entry.max_days) + .and_then(|(changed, max)| changed.checked_add(max)); + let inactive_after_day = password_expiry_day + .zip(entry.inactive_days) + .and_then(|(expiry, inactive)| expiry.checked_add(inactive)); + let state = if !identity.shell.is_absolute() + || identity.shell == Path::new("/bin/false") + || identity.shell == Path::new("/usr/sbin/nologin") + || identity.shell == Path::new("/sbin/nologin") + { + AccountState::NoLoginShell + } else if entry.account_expiry.is_some_and(|expiry| expiry <= today) { + AccountState::AccountExpired + } else if shadow_hash_is_locked(&entry.hash) { + AccountState::Locked + } else if entry.last_change == Some(0) + || password_expiry_day.is_some_and(|expiry| expiry <= today) + { + AccountState::PasswordExpired + } else if inactive_after_day.is_some_and(|expiry| expiry <= today) { + AccountState::AccountInactive + } else { + AccountState::Active + }; + AccountStatus { + state, + password_changed_day: entry.last_change, + password_expiry_day, + account_expiry_day: entry.account_expiry, + inactive_after_day, + } +} + +fn unix_day() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| (duration.as_secs() / 86_400) as i64) + .unwrap_or_default() +} + +fn utmp_field(field: &[libc::c_char; N]) -> Option { + let length = field.iter().position(|byte| *byte == 0).unwrap_or(N); + if length == 0 { + return None; + } + let bytes: Vec = field[..length].iter().map(|byte| *byte as u8).collect(); + String::from_utf8(bytes) + .ok() + .filter(|value| !value.is_empty()) +} + +fn logind_markers_present(system_dir: bool, seats_dir: bool) -> bool { + system_dir && seats_dir +} + fn trusted_helper(path: &Path) -> bool { let Ok(metadata) = std::fs::symlink_metadata(path) else { return false; @@ -199,6 +530,56 @@ fn trusted_helper(path: &Path) -> bool { mod tests { use super::*; + #[test] + fn parses_faillock_rows_with_username_and_timestamp_prefixes() { + assert_eq!(parse_failure_count(b"alice: 4\n"), 4); + assert_eq!( + parse_failure_count(b"2026-01-01 12:00:00 TTY 1\n2026-01-02 12:00:00 TTY 1\n"), + 2 + ); + } + + #[test] + fn parses_shadow_aging_and_lock_markers_without_exposing_hashes() { + let entry = parse_shadow_entry("alice:$6$redacted:0:0:30:7:14:20000:").expect("entry"); + assert_eq!(entry.last_change, Some(0)); + assert_eq!(entry.max_days, Some(30)); + assert_eq!(entry.inactive_days, Some(14)); + assert_eq!(entry.account_expiry, Some(20_000)); + assert!(shadow_hash_is_locked("!$6$hash")); + assert!(!shadow_hash_is_locked("$6$hash")); + } + + #[test] + fn account_state_prioritises_non_login_shell_and_expiry() { + let identity = Identity { + username: "alice".into(), + uid: 1000, + gid: 1000, + home: "/home/alice".into(), + shell: "/usr/sbin/nologin".into(), + }; + let status = account_status_from_shadow( + &identity, + Some(ShadowEntry { + hash: "*".into(), + last_change: Some(1), + max_days: Some(1), + inactive_days: Some(1), + account_expiry: Some(1), + }), + 20_000, + ); + assert_eq!(status.state, AccountState::NoLoginShell); + } + + #[test] + fn detects_systemd_logind_only_when_runtime_markers_exist() { + assert!(logind_markers_present(true, true)); + assert!(!logind_markers_present(true, false)); + assert!(!logind_markers_present(false, true)); + } + #[test] fn parses_legacy_failure_count() { assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4); diff --git a/crates/latchd-core/src/config.rs b/crates/latchd-core/src/config.rs index f3527d9..e862bf0 100644 --- a/crates/latchd-core/src/config.rs +++ b/crates/latchd-core/src/config.rs @@ -72,6 +72,8 @@ pub struct AccountsConfig { pub faillock_path: PathBuf, /// faillock configuration used to read the deny threshold. pub faillock_config: PathBuf, + /// Shadow database used for local password and account aging checks. + pub shadow_path: PathBuf, } impl Default for AccountsConfig { @@ -82,6 +84,7 @@ impl Default for AccountsConfig { enumerate: true, faillock_path: PathBuf::from("/usr/sbin/faillock"), faillock_config: PathBuf::from("/etc/security/faillock.conf"), + shadow_path: PathBuf::from("/etc/shadow"), } } } @@ -293,6 +296,7 @@ pub fn validate(config: &Config) -> Result<(), Error> { ("x11.server_log_path", &config.x11.server_log_path), ("accounts.faillock_path", &config.accounts.faillock_path), ("accounts.faillock_config", &config.accounts.faillock_config), + ("accounts.shadow_path", &config.accounts.shadow_path), ] { if !path.is_absolute() { return Err(Error::Config(format!("{name} must be an absolute path"))); diff --git a/crates/latchd-tui/src/background.rs b/crates/latchd-tui/src/background.rs index a0746bb..7a26cc6 100644 --- a/crates/latchd-tui/src/background.rs +++ b/crates/latchd-tui/src/background.rs @@ -19,6 +19,8 @@ use ratatui::{ const MAX_IMAGE_SIDE: u32 = 480; const MAX_SOURCE_SIDE: u32 = 8_192; const MAX_SOURCE_PIXELS: u64 = 16_777_216; +const MAX_ANIMATION_WIDTH: usize = 160; +const MAX_ANIMATION_HEIGHT: usize = 64; const FRAME_INTERVAL: Duration = Duration::from_millis(120); /// Background state owned by the TUI thread. @@ -189,6 +191,10 @@ struct Automata { impl Automata { fn resize(&mut self, width: usize, height: usize) { + // Keep procedural state bounded even when a terminal reports an + // unusually large window. Rendering remains clipped to this layer. + let width = width.min(MAX_ANIMATION_WIDTH); + let height = height.min(MAX_ANIMATION_HEIGHT); if self.width == width && self.height == height { return; } @@ -221,11 +227,11 @@ impl Automata { let mut count = 0; for dy in [self.height.wrapping_sub(1), 0, 1] { for dx in [self.width.wrapping_sub(1), 0, 1] { - if dx == 0 && dy == 0 { - continue; - } let nx = (x + dx) % self.width; let ny = (y + dy) % self.height; + if nx == x && ny == y { + continue; + } count += self.current[ny * self.width + nx]; } } @@ -264,4 +270,16 @@ mod tests { automata.tick(); assert_eq!(automata.current.len(), 32); } + + #[test] + fn automata_caps_untrusted_terminal_dimensions() { + let mut automata = Automata::default(); + automata.resize(usize::MAX, usize::MAX); + assert_eq!(automata.width, MAX_ANIMATION_WIDTH); + assert_eq!(automata.height, MAX_ANIMATION_HEIGHT); + assert_eq!( + automata.current.len(), + MAX_ANIMATION_WIDTH * MAX_ANIMATION_HEIGHT + ); + } } diff --git a/crates/latchd-tui/src/lib.rs b/crates/latchd-tui/src/lib.rs index d0cc6e2..490c1f9 100644 --- a/crates/latchd-tui/src/lib.rs +++ b/crates/latchd-tui/src/lib.rs @@ -17,7 +17,7 @@ use zeroize::Zeroizing; use latchd_auth::{AuthService, AuthTicket}; use latchd_core::{ - accounts::{self, FaillockStatus, Identity}, + accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity}, config::Config, error::{Error, ResultExt}, session::{self, SessionDescriptor}, @@ -30,6 +30,16 @@ const MIN_WIDTH: u16 = 60; const MIN_HEIGHT: u16 = 16; const MAX_INPUT: usize = 256; +fn unknown_account_status() -> AccountStatus { + AccountStatus { + state: AccountState::Unknown, + password_changed_day: None, + password_expiry_day: None, + account_expiry_day: None, + inactive_after_day: None, + } +} + /// The result of one completed login-screen interaction. pub struct LoginSelection { /// Authenticated PAM ticket. @@ -45,6 +55,20 @@ enum Focus { Password, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FilterTarget { + Sessions, + Accounts, +} + +#[derive(Debug, Clone)] +struct FilterState { + target: FilterTarget, + query: String, +} + +type DiscoverySnapshot = (Vec, Vec, Vec); + #[derive(Debug)] enum AuthState { Idle, @@ -71,6 +95,7 @@ struct App { preview: bool, sessions: Vec, accounts: Vec, + account_statuses: Vec, account_index: Option, faillock_receiver: Option>>, faillock_status: Option, @@ -80,6 +105,8 @@ struct App { password: Zeroizing, status: String, auth_state: AuthState, + auth_failures: u32, + auth_blocked_until: Option, show_help: bool, power_confirmation: Option, should_quit: bool, @@ -89,6 +116,10 @@ struct App { notice: Option, animation_frame: usize, reduced_motion: bool, + filter: Option, + refresh_receiver: Option>, + refreshing: bool, + high_contrast: bool, } impl App { @@ -111,6 +142,7 @@ impl App { auth, preview, sessions, + account_statuses: vec![unknown_account_status(); accounts.len()], accounts, account_index, faillock_receiver: None, @@ -121,6 +153,8 @@ impl App { password: Zeroizing::new(String::new()), status: "Enter your username".into(), auth_state: AuthState::Idle, + auth_failures: 0, + auth_blocked_until: None, show_help: false, power_confirmation: None, should_quit: false, @@ -130,12 +164,17 @@ impl App { notice: None, animation_frame: 0, reduced_motion, + filter: None, + refresh_receiver: None, + refreshing: false, + high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(), } } fn run(&mut self, terminal: &mut DefaultTerminal) -> Result, Error> { loop { self.poll_faillock(); + self.poll_refresh(); let now = Instant::now(); if self.background.tick_if_due(now) { self.animation_frame = self.animation_frame.wrapping_add(1); @@ -153,6 +192,8 @@ impl App { if let Some(result) = self.poll_auth() { match result { Ok(ticket) => { + self.auth_failures = 0; + self.auth_blocked_until = None; let selected = self .sessions .get(self.selected) @@ -167,6 +208,9 @@ impl App { tracing::warn!(%error, "authentication attempt failed"); self.auth_state = AuthState::Failed; self.password.clear(); + self.auth_failures = self.auth_failures.saturating_add(1); + self.auth_blocked_until = + Some(Instant::now() + auth_backoff(self.auth_failures)); self.status = "Authentication failed".into(); self.notify("Authentication failed", NoticeLevel::Warning); self.start_faillock_query(); @@ -230,6 +274,98 @@ impl App { } } + fn poll_refresh(&mut self) { + let Some(receiver) = &self.refresh_receiver else { + return; + }; + match receiver.try_recv() { + Ok((sessions, accounts, account_statuses)) => { + self.sessions = sessions; + self.accounts = accounts; + self.account_statuses = account_statuses; + self.refresh_receiver = None; + self.refreshing = false; + self.reconcile_selection(); + self.status = format!( + "Discovery refreshed: {} session(s), {} account(s)", + self.sessions.len(), + self.accounts.len() + ); + self.notify("Account and session discovery refreshed", NoticeLevel::Info); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + self.refresh_receiver = None; + self.refreshing = false; + self.status = "Discovery refresh failed".into(); + self.notify( + "Could not refresh account and session discovery", + NoticeLevel::Warning, + ); + } + } + } + + fn start_refresh(&mut self) { + if self.refreshing { + self.status = "Discovery refresh is already running".into(); + return; + } + let config = self.config.clone(); + let (sender, receiver) = mpsc::sync_channel(1); + let task = thread::Builder::new() + .name("latchd-discovery".into()) + .spawn(move || { + let sessions = session::discover(&config); + let (accounts, account_statuses) = if config.accounts.enumerate { + let records = accounts::discover_with_status(&config.accounts); + records + .into_iter() + .map(|record| (record.identity, record.status)) + .unzip() + } else { + (Vec::new(), Vec::new()) + }; + let _ = sender.send((sessions, accounts, account_statuses)); + }); + match task { + Ok(_) => { + self.refresh_receiver = Some(receiver); + self.refreshing = true; + self.status = "Refreshing accounts and sessions…".into(); + } + Err(error) => { + tracing::debug!(%error, "could not start discovery refresh"); + self.status = format!("Could not start discovery refresh: {error}"); + } + } + } + + fn reconcile_selection(&mut self) { + if self.sessions.is_empty() { + self.selected = 0; + } else if self.selected >= self.sessions.len() { + self.selected = self.sessions.len() - 1; + } + if self.accounts.is_empty() { + self.account_index = None; + self.account_statuses.clear(); + } else if self.account_statuses.len() != self.accounts.len() { + self.account_statuses + .resize(self.accounts.len(), unknown_account_status()); + } else if self + .account_index + .is_some_and(|index| index >= self.accounts.len()) + { + self.account_index = Some(0); + } + if let Some(index) = self.account_index { + if let Some(account) = self.accounts.get(index) { + self.username = account.username.clone(); + } + } + } + fn start_faillock_query(&mut self) { let username = self.username.clone(); if username.is_empty() { @@ -262,6 +398,149 @@ impl App { } } + fn begin_filter(&mut self) { + let target = match self.focus { + Focus::Sessions => FilterTarget::Sessions, + Focus::Username => FilterTarget::Accounts, + Focus::Password => return, + }; + self.filter = Some(FilterState { + target, + query: String::new(), + }); + self.status = match target { + FilterTarget::Sessions => { + "Filter sessions: type text, Enter selects, Esc clears".into() + } + FilterTarget::Accounts => { + "Filter accounts: type text, Enter selects, Esc clears".into() + } + }; + } + + fn filter_key(&mut self, key: KeyEvent) { + let Some(target) = self.filter.as_ref().map(|filter| filter.target) else { + return; + }; + match key.code { + KeyCode::Esc => { + self.filter = None; + self.status = "Filter cleared".into(); + } + KeyCode::Enter => self.accept_filter(), + KeyCode::Backspace => { + if let Some(filter) = &mut self.filter { + filter.query.pop(); + } + self.update_filter_status(); + } + KeyCode::Up | KeyCode::Left => match target { + FilterTarget::Sessions => self.previous_session(), + FilterTarget::Accounts => self.previous_account(), + }, + KeyCode::Down | KeyCode::Right => match target { + FilterTarget::Sessions => self.next_session(), + FilterTarget::Accounts => self.next_account(), + }, + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && self + .filter + .as_ref() + .is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) => + { + if let Some(filter) = &mut self.filter { + filter.query.push(character); + } + self.update_filter_status(); + } + _ => {} + } + } + + fn update_filter_status(&mut self) { + let Some(filter) = &self.filter else { + return; + }; + let count = match filter.target { + FilterTarget::Sessions => self.matching_session_indices().len(), + FilterTarget::Accounts => self.matching_account_indices().len(), + }; + self.status = format!( + "Filter {}: {} match(es) — Enter selects, Esc clears", + match filter.target { + FilterTarget::Sessions => "sessions", + FilterTarget::Accounts => "accounts", + }, + count + ); + } + + fn accept_filter(&mut self) { + let Some(filter) = self.filter.take() else { + return; + }; + match filter.target { + FilterTarget::Sessions => { + let index = self + .sessions + .iter() + .enumerate() + .find(|(_, session)| matches_filter(&session.title, &filter.query)) + .map(|(index, _)| index); + if let Some(index) = index { + self.selected = index; + self.status = format!("Selected session {}", self.sessions[index].title); + } else { + self.status = "No matching sessions".into(); + } + } + FilterTarget::Accounts => { + let index = self + .accounts + .iter() + .enumerate() + .find(|(_, account)| matches_filter(&account.username, &filter.query)) + .map(|(index, _)| index); + if let Some(index) = index { + self.select_account_index(index); + } else { + self.status = "No matching accounts".into(); + } + } + } + } + + fn matching_session_indices(&self) -> Vec { + let query = self + .filter + .as_ref() + .filter(|filter| filter.target == FilterTarget::Sessions) + .map_or("", |filter| filter.query.as_str()); + self.sessions + .iter() + .enumerate() + .filter(|(_, session)| matches_filter(&session.title, query)) + .map(|(index, _)| index) + .collect() + } + + fn matching_account_indices(&self) -> Vec { + let query = self + .filter + .as_ref() + .filter(|filter| filter.target == FilterTarget::Accounts) + .map_or("", |filter| filter.query.as_str()); + self.accounts + .iter() + .enumerate() + .filter(|(_, account)| matches_filter(&account.username, query)) + .map(|(index, _)| index) + .collect() + } + fn handle_key(&mut self, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.should_quit = true; @@ -279,6 +558,10 @@ impl App { } return; } + if self.filter.is_some() { + self.filter_key(key); + return; + } if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { KeyCode::Char('n') => { @@ -314,6 +597,10 @@ impl App { ); return; } + KeyCode::Char('r') => { + self.start_refresh(); + return; + } _ => {} } } @@ -328,6 +615,7 @@ impl App { } match key.code { KeyCode::Char('?') => self.show_help = true, + KeyCode::Char('/') => self.begin_filter(), KeyCode::Esc => { self.password.clear(); self.status = "Login cancelled".into(); @@ -409,6 +697,16 @@ impl App { self.status = "Username and password are required".into(); return; } + if let Some(until) = self.auth_blocked_until { + if let Some(remaining) = until.checked_duration_since(Instant::now()) { + self.status = format!( + "Authentication paused after failed attempts; try again in {}s", + remaining.as_secs().saturating_add(1) + ); + return; + } + self.auth_blocked_until = None; + } let username = self.username.clone(); let password = self.password.as_bytes().to_vec(); let service = self.config.pam_service.clone(); @@ -483,17 +781,23 @@ impl App { } fn next_session(&mut self) { - if !self.sessions.is_empty() { - self.selected = (self.selected + 1) % self.sessions.len(); + let indices = self.matching_session_indices(); + if let Some(position) = indices.iter().position(|&index| index == self.selected) { + self.selected = indices[(position + 1) % indices.len()]; + } else if let Some(&index) = indices.first() { + self.selected = index; } } fn previous_session(&mut self) { - if !self.sessions.is_empty() { - self.selected = self - .selected - .checked_sub(1) - .unwrap_or(self.sessions.len() - 1); + let indices = self.matching_session_indices(); + if let Some(position) = indices.iter().position(|&index| index == self.selected) { + self.selected = indices + .get(position.checked_sub(1).unwrap_or(indices.len() - 1)) + .copied() + .unwrap_or(self.selected); + } else if let Some(&index) = indices.first() { + self.selected = index; } } @@ -506,20 +810,36 @@ impl App { } fn select_account(&mut self, delta: isize) { - if self.accounts.is_empty() { + let indices = self.matching_account_indices(); + if indices.is_empty() { self.notify( "No eligible accounts discovered; enter a username manually", NoticeLevel::Info, ); return; } - let current = self.account_index.unwrap_or(0) as isize; - let length = self.accounts.len() as isize; - let index = (current + delta).rem_euclid(length) as usize; + let current = self + .account_index + .and_then(|index| indices.iter().position(|&candidate| candidate == index)) + .unwrap_or(0) as isize; + let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize]; + self.select_account_index(index); + } + + fn select_account_index(&mut self, index: usize) { + let Some(username) = self + .accounts + .get(index) + .map(|account| account.username.clone()) + else { + return; + }; self.account_index = Some(index); - self.username = self.accounts[index].username.clone(); + self.username = username; self.password.clear(); self.auth_state = AuthState::Idle; + self.auth_failures = 0; + self.auth_blocked_until = None; self.faillock_status = None; self.status = format!("Selected account {}", self.username); self.start_faillock_query(); @@ -576,10 +896,10 @@ impl App { } else { vertical[1] }; - let session_items: Vec = self - .sessions + let session_indices = self.matching_session_indices(); + let session_items: Vec = session_indices .iter() - .enumerate() + .filter_map(|&index| self.sessions.get(index).map(|session| (index, session))) .map(|(index, session)| { let marker = if index == self.selected { if self.ascii { ">" } else { "▶" } @@ -600,8 +920,22 @@ impl App { }) }) .collect(); - let sessions = - List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions)); + let session_items = if session_items.is_empty() { + vec![ListItem::new(" No matching sessions").style(self.style_muted())] + } else { + session_items + }; + let session_title = if self + .filter + .as_ref() + .is_some_and(|filter| filter.target == FilterTarget::Sessions) + { + format!("Session {}/{}", session_indices.len(), self.sessions.len()) + } else { + "Session".into() + }; + let sessions = List::new(session_items) + .block(self.block(&session_title, self.focus == Focus::Sessions)); frame.render_widget(sessions, session_area); let fields = Layout::default() @@ -622,26 +956,97 @@ impl App { frame.render_widget(password, fields[1]); let status = self.status_line(); frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]); - frame.render_widget( - Paragraph::new( - "Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help", - ), - vertical[2], - ); + frame.render_widget(Paragraph::new(self.footer_hint()), vertical[2]); frame.render_widget( Paragraph::new("PAM authentication • session output stays off the login screen"), vertical[3], ); if self.show_help { self.draw_help(frame, area); + } else if self.filter.is_some() { + self.draw_filter(frame, area); } } + fn footer_hint(&self) -> String { + let mut hint = match self.focus { + Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help", + Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help", + Focus::Password => "Tab focus Enter login Ctrl-M motion ? help", + } + .to_owned(); + if self.no_color { + hint.push_str(" [monochrome]"); + } else if self.high_contrast { + hint.push_str(" [high contrast]"); + } + if self.reduced_motion { + hint.push_str(" [reduced motion]"); + } + hint + } + + fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) { + let Some(filter) = &self.filter else { + return; + }; + let popup = centered_rect(72, 52, area); + frame.render_widget(Clear, popup); + let (label, values): (&str, Vec) = match filter.target { + FilterTarget::Sessions => ( + "Sessions", + self.matching_session_indices() + .iter() + .filter_map(|&index| self.sessions.get(index)) + .map(|session| session.title.clone()) + .collect(), + ), + FilterTarget::Accounts => ( + "Accounts", + self.matching_account_indices() + .iter() + .filter_map(|&index| self.accounts.get(index)) + .map(|account| account.username.clone()) + .collect(), + ), + }; + let mut lines = vec![ + Line::from(format!("{label} /{}", filter.query)), + Line::raw(""), + ]; + if values.is_empty() { + lines.push(Line::from("No matches")); + } else { + for value in values.iter().take(8) { + lines.push(Line::from(format!( + " {}", + truncate_cells(value, popup.width.saturating_sub(5) as usize) + ))); + } + } + lines.push(Line::raw("")); + lines.push(Line::from( + "Type to filter • ↑/↓ choose • Enter accept • Esc clear", + )); + frame.render_widget( + Paragraph::new(lines) + .block(self.block("Filter", true)) + .wrap(Wrap { trim: true }), + popup, + ); + } + fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) { let popup = centered_rect(70, 50, area); frame.render_widget(Clear, popup); - frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help") - .block(self.block("Help", false)).wrap(Wrap { trim: true }), popup); + frame.render_widget( + Paragraph::new( + "Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / accept filter\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help", + ) + .block(self.block("Help", false)) + .wrap(Wrap { trim: true }), + popup, + ); } fn block(&self, title: &str, focused: bool) -> Block<'static> { @@ -680,6 +1085,14 @@ impl App { }; status = format!("{spinner} Authenticating…"); } + if let Some(until) = self.auth_blocked_until { + if let Some(remaining) = until.checked_duration_since(Instant::now()) { + status.push_str(&format!( + " • retry cooldown {}s", + remaining.as_secs().saturating_add(1) + )); + } + } if let Some(lockout) = self.faillock_status { if lockout.locked_out { status @@ -694,6 +1107,21 @@ impl App { )); } } + if let Some(index) = self.account_index { + if let Some(account) = self.account_statuses.get(index) { + let policy = match account.state { + AccountState::Active | AccountState::Unknown => None, + AccountState::Locked => Some("account password is locked"), + AccountState::PasswordExpired => Some("password must be changed"), + AccountState::AccountExpired => Some("account has expired"), + AccountState::AccountInactive => Some("account is inactive"), + AccountState::NoLoginShell => Some("account has no login shell"), + }; + if let Some(policy) = policy { + status.push_str(&format!(" • {policy}; PAM remains authoritative")); + } + } + } if let Some(notice) = &self.notice { let marker = match notice.level { NoticeLevel::Info => { @@ -713,12 +1141,20 @@ impl App { }; status.push_str(&format!(" {marker} {}", notice.message)); } + if self.refreshing { + status.push_str(" • refreshing discovery"); + } status } fn style_accent(&self) -> Style { if self.no_color { Style::default().add_modifier(ratatui::prelude::Modifier::BOLD) + } else if self.high_contrast { + Style::default() + .fg(ratatui::prelude::Color::White) + .bg(ratatui::prelude::Color::Blue) + .add_modifier(ratatui::prelude::Modifier::BOLD) } else { Style::default() .fg(ratatui::prelude::Color::Cyan) @@ -728,6 +1164,11 @@ impl App { fn style_selected(&self) -> Style { if self.no_color { Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED) + } else if self.high_contrast { + Style::default() + .fg(ratatui::prelude::Color::Black) + .bg(ratatui::prelude::Color::White) + .add_modifier(ratatui::prelude::Modifier::BOLD) } else { Style::default() .fg(ratatui::prelude::Color::Yellow) @@ -746,13 +1187,18 @@ pub fn run( preview: bool, ) -> Result, Error> { let sessions = session::discover(&config); - let accounts = if config.accounts.enumerate { - accounts::discover(&config.accounts) + let (accounts, account_statuses) = if config.accounts.enumerate { + let records = accounts::discover_with_status(&config.accounts); + records + .into_iter() + .map(|record| (record.identity, record.status)) + .unzip() } else { - Vec::new() + (Vec::new(), Vec::new()) }; let background = Background::load(&config.visual)?; let mut app = App::new(config, auth, preview, sessions, accounts, background); + app.account_statuses = account_statuses; app.account_index = (!app.accounts.is_empty()).then_some(0); app.start_faillock_query(); ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) @@ -795,6 +1241,22 @@ fn truncate_cells(value: &str, width: usize) -> String { output } +fn matches_filter(value: &str, query: &str) -> bool { + if query.is_empty() { + return true; + } + if query.chars().any(char::is_uppercase) { + value.contains(query) + } else { + value.to_lowercase().contains(query) + } +} + +fn auth_backoff(failures: u32) -> Duration { + let shift = failures.saturating_sub(1).min(4); + Duration::from_secs(1u64 << shift) +} + #[cfg(test)] mod tests { use super::*; @@ -839,6 +1301,40 @@ mod tests { assert!(rendered.contains("Password")); } + #[test] + fn renders_without_layout_errors_at_supported_breakpoints() { + for (width, height) in [(80, 24), (79, 23), (60, 24)] { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + vec![SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }], + Vec::new(), + Background::load(&Config::default().visual) + .expect("default background should load"), + ); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); + terminal + .draw(|frame| app.draw(frame)) + .expect("frame should render"); + let rendered = terminal.backend().buffer().content(); + assert!(rendered.iter().any(|cell| cell.symbol() == "l")); + assert!( + !rendered + .iter() + .any(|cell| cell.symbol() == "terminal too small") + ); + } + } + #[test] fn renders_a_truthful_too_small_state() { let auth = AuthService::new().expect("PAM worker should start"); @@ -898,4 +1394,106 @@ mod tests { assert!(app.password.is_empty()); assert!(matches!(app.auth_state, AuthState::Idle)); } + + #[test] + fn account_policy_state_is_visible_without_overriding_pam() { + let auth = AuthService::new().expect("PAM worker should start"); + let account = Identity { + username: "alice".into(), + uid: 1000, + gid: 1000, + home: "/home/alice".into(), + shell: "/bin/sh".into(), + }; + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + vec![account], + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.account_statuses[0].state = AccountState::Locked; + let status = app.status_line(); + assert!(status.contains("password is locked")); + assert!(status.contains("PAM remains authoritative")); + } + + #[test] + fn authentication_backoff_is_exponential_and_bounded() { + assert_eq!(auth_backoff(1), Duration::from_secs(1)); + assert_eq!(auth_backoff(2), Duration::from_secs(2)); + assert_eq!(auth_backoff(5), Duration::from_secs(16)); + assert_eq!(auth_backoff(100), Duration::from_secs(16)); + } + + #[test] + fn filtering_is_smart_case_and_selects_a_matching_session() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + vec![ + SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }, + SessionDescriptor { + kind: session::SessionKind::Wayland, + title: "Sway session".into(), + argv: vec!["sway".into()], + current_desktop: None, + session_desktop: None, + }, + ], + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.focus = Focus::Sessions; + app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(app.matching_session_indices(), vec![1]); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.selected, 1); + assert!(app.filter.is_none()); + } + + #[test] + fn refresh_reconciles_selection_without_blocking_state() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.refresh_receiver = Some({ + let (sender, receiver) = mpsc::sync_channel(1); + sender + .send(( + vec![SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }], + Vec::new(), + Vec::new(), + )) + .expect("refresh result should queue"); + receiver + }); + app.refreshing = true; + app.poll_refresh(); + assert!(!app.refreshing); + assert_eq!(app.sessions.len(), 1); + } } diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md new file mode 100644 index 0000000..20870dc --- /dev/null +++ b/docs/e2e-testing.md @@ -0,0 +1,35 @@ +# End-to-end testing + +Latchd has two test layers: + +* `make integration-test` builds the binary and exercises metadata, TOML + validation, preflight, and the non-authenticating preview path. It does not + need PAM credentials, root, a VT, X11, or Wayland. +* `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and + systemd-logind capabilities. Missing capabilities are reported as `SKIP`, + never as a successful test. Use `make system-e2e-strict` only in a prepared + VM or privileged runner; strict mode fails when a capability is unavailable. + +The Docker test image runs the first layer and starts an isolated Xvfb server +when available. It does not use host display sockets, host PAM files, or a +host VT. PAM authentication and a real login handoff require a disposable VM +with a dedicated test account and an explicitly provisioned PAM policy; the +container probe intentionally reports those checks as capability information +only. + +For a local release-style run: + +```sh +make integration-test +make docker-test +make system-e2e +``` + +For a VM with a real seat, compositor, and logind: + +```sh +make system-e2e-strict +``` + +Do not run strict mode on a normal developer workstation unless the VM's +display-manager handoff and test PAM policy are isolated from the host. diff --git a/extra/config.toml b/extra/config.toml index fd7aa3c..3212ffb 100644 --- a/extra/config.toml +++ b/extra/config.toml @@ -36,6 +36,7 @@ excluded_users = [] enumerate = true faillock_path = "/usr/sbin/faillock" faillock_config = "/etc/security/faillock.conf" +shadow_path = "/etc/shadow" [visual] background_enabled = true diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh new file mode 100755 index 0000000..a57adab --- /dev/null +++ b/scripts/test-cli.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Exercise the non-interactive CLI contract without authenticating a user. +# +# 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 + +binary=${1:-target/debug/latchd} + +if [ ! -x "$binary" ]; then + echo "test-cli: binary is not executable: $binary" >&2 + exit 2 +fi + +workdir=$(mktemp -d "${TMPDIR:-/tmp}/latchd-cli.XXXXXX") +trap 'rm -rf "$workdir"' EXIT HUP INT TERM + +config="$workdir/config.toml" +cat >"$config" <<'TOML' +tty = 2 +main_log_path = "/tmp/latchd-test.log" +client_log_path = "/tmp/latchd-test.client.log" +pam_service = "latchd" +system_shell = "/bin/sh" +initial_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +[x11] +display = ":99" +server_path = "/usr/bin/X" +xauth_path = "/usr/bin/xauth" +server_timeout_secs = 5 +server_log_path = "/tmp/latchd-test.xorg.log" +sessions_path = "/nonexistent/latchd-test/xsessions" +scripts_path = "/nonexistent/latchd-test/wms" + +[wayland] +sessions_path = "/nonexistent/latchd-test/wayland-sessions" +scripts_path = "/nonexistent/latchd-test/wayland" + +[environment_switcher] +include_tty_shell = false +max_display_length = 18 + +[power_controls] +entries = [] + +[accounts] +minimum_uid = 1000 +excluded_users = [] +enumerate = true +faillock_path = "/usr/sbin/faillock" +faillock_config = "/etc/security/faillock.conf" + +[visual] +background_enabled = false +animations = false +notifications = false +reduced_motion = true +max_wallpaper_bytes = 1048576 +TOML + +assert_contains() { + needle=$1 + haystack=$2 + grep -F -- "$needle" "$haystack" >/dev/null || { + echo "test-cli: expected '$needle' in $haystack" >&2 + cat "$haystack" >&2 + exit 1 + } +} + +info="$workdir/build-info" +"$binary" --build-info >"$info" +assert_contains "version=" "$info" +assert_contains "commit=" "$info" +assert_contains "epoch=" "$info" + +version="$workdir/version" +"$binary" --version >"$version" +assert_contains "latchd" "$version" + +valid="$workdir/valid" +"$binary" --preview --no-tui --no-log --config "$config" >"$valid" +assert_contains "configuration valid" "$valid" + +shown="$workdir/show-config" +"$binary" --preview --show-config --no-log --config "$config" >"$shown" +assert_contains "tty = 2" "$shown" +assert_contains "pam_service" "$shown" + +unknown="$workdir/unknown.toml" +printf 'unknown = true\n' >"$unknown" +if "$binary" --preview --no-tui --no-log --config "$unknown" >"$workdir/unknown.out" 2>&1; then + echo "test-cli: unknown configuration field was accepted" >&2 + exit 1 +fi +assert_contains "unknown" "$workdir/unknown.out" + +invalid_tty="$workdir/invalid-tty" +if "$binary" --preview --no-tui --no-log --config "$config" --tty 0 >"$invalid_tty" 2>&1; then + echo "test-cli: --tty 0 was accepted" >&2 + exit 1 +fi +assert_contains "--tty must be greater than zero" "$invalid_tty" + +echo "test-cli: PASS (CLI metadata, config validation, preflight, and safe preview paths)" diff --git a/scripts/test-system-e2e.sh b/scripts/test-system-e2e.sh new file mode 100755 index 0000000..5bb3858 --- /dev/null +++ b/scripts/test-system-e2e.sh @@ -0,0 +1,146 @@ +#!/bin/sh +# Probe host capabilities needed for a real login-manager deployment. +# +# 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 + +binary=${1:-} +strict=0 +if [ "${1:-}" = "--strict" ]; then + strict=1 + shift + binary=${1:-} +fi + +failures=0 +skips=0 +passes=0 + +pass() { + passes=$((passes + 1)) + printf 'PASS: %s\n' "$*" +} + +skip() { + skips=$((skips + 1)) + printf 'SKIP: %s\n' "$*" +} + +fail() { + failures=$((failures + 1)) + printf 'FAIL: %s\n' "$*" >&2 +} + +need_command() { + command -v "$1" >/dev/null 2>&1 +} + +if [ -n "$binary" ]; then + if [ ! -x "$binary" ]; then + fail "latchd binary is not executable: $binary" + else + pass "latchd binary is available" + fi +else + skip "latchd binary not supplied; capability-only probe" +fi + +# This is deliberately a runtime capability check, not an authentication +# 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 + pass "PAM runtime library is available" +else + skip "PAM runtime library is unavailable" +fi +if [ -r /etc/pam.d/latchd ]; then + pass "latchd PAM policy is installed" +else + skip "latchd PAM policy is not installed (authentication E2E requires a provisioned VM)" +fi +if need_command faillock; then + pass "faillock helper is available" +else + skip "faillock helper is unavailable" +fi + +# A real VT is a character device and must be allocated by the host/VM. A +# pseudo-terminal from CI is not equivalent and is intentionally not accepted. +if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then + pass "Linux virtual terminal device is available" +else + skip "Linux virtual terminal is unavailable (container or non-console host)" +fi + +# Xvfb is a genuine X server and is safe to exercise without host display +# access. If it is absent, this is a skip rather than a synthetic success. +if need_command Xvfb; then + display_number=$((100 + ($$ % 500))) + display=":$display_number" + xlog=${TMPDIR:-/tmp}/latchd-xvfb.$$.log + Xvfb "$display" -screen 0 640x480x24 -nolisten tcp >"$xlog" 2>&1 & + xvfb_pid=$! + cleanup_xvfb() { + kill "$xvfb_pid" >/dev/null 2>&1 || true + wait "$xvfb_pid" >/dev/null 2>&1 || true + rm -f "$xlog" + } + trap cleanup_xvfb EXIT HUP INT TERM + ready=0 + i=0 + while [ "$i" -lt 20 ]; do + if [ -S "/tmp/.X11-unix/X$display_number" ] && kill -0 "$xvfb_pid" 2>/dev/null; then + ready=1 + break + fi + i=$((i + 1)) + sleep 0.1 + done + if [ "$ready" -eq 1 ]; then + pass "X11 server starts in an isolated display ($display)" + else + fail "Xvfb did not start; see $xlog" + fi + cleanup_xvfb + trap - EXIT HUP INT TERM +else + skip "Xvfb is unavailable" +fi + +# A Wayland socket is meaningful only when a compositor owns it. Do not claim +# Wayland support merely because a client library or executable is installed. +wayland_socket= +if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then + case "$WAYLAND_DISPLAY" in + /*) wayland_socket=$WAYLAND_DISPLAY ;; + *) wayland_socket=$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY ;; + esac +fi +if [ -n "$wayland_socket" ] && [ -S "$wayland_socket" ]; then + pass "Wayland compositor socket is available ($wayland_socket)" +else + skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)" +fi + +# systemd-logind is not normally PID 1 in a Docker container. loginctl output +# is checked only when the manager and seat are actually reachable. +if need_command loginctl && [ -d /run/systemd/system ]; then + if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then + pass "systemd-logind seat0 is reachable" + else + fail "systemd-logind is present but seat0 is not reachable" + fi +else + skip "systemd-logind seat0 is unavailable" +fi + +printf 'E2E summary: pass=%s skip=%s fail=%s\n' "$passes" "$skips" "$failures" +if [ "$failures" -ne 0 ]; then + exit 1 +fi +if [ "$strict" -eq 1 ] && [ "$skips" -ne 0 ]; then + echo "E2E strict mode: unavailable capabilities are errors" >&2 + exit 2 +fi +exit 0