diff --git a/.gitignore b/.gitignore index 8fc0a10..403bec3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,13 @@ go_project_build_documentation.md documentation.md session.md -build/* -examples/* +build/ +examples/ things_todo.md -ly/* +ly/ + +# The Rust implementation replaced this reference tree. +/legacy/go/ tui-design-skill/ FEATURE_CHECKLIST.md @@ -15,3 +18,5 @@ IMPLEMENTATION_CHECKLIST.md audit_code.md audit_rust_code.md .latchd-work/ +reference.md +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ecad0..9a0c671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,127 +2,73 @@ ## Unreleased -- Preview mode now loads `$XDG_CONFIG_HOME/latchd/config.toml` or - `~/.config/latchd/config.toml` when no `--config` path is set. -- Added `make install-user` to create a user configuration without replacing - an existing file. -- Added Docker PTY screenshots and a short UI guide. +- TUI module layout — `crates/latchd-tui/src/lib.rs` held state, input, authentication, discovery, rendering, and tests in one file; split those responsibilities into `app.rs`, `app/auth.rs`, `app/discovery.rs`, `app/input.rs`, `app/render.rs`, and `app/tests.rs`, which makes each change surface easier to locate +- error reports — `crates/latchd-core/src/error.rs::Error::user_report` now adds a detail line and recovery commands; `rust/main.rs` prints the report for CLI failures instead of one short error line +- TUI checks — keep the render and state tests beside the new modules; `cargo test --locked --workspace --all-targets` covers the split without changing the login flow +- service configuration — document `/etc/latchd/config.toml` as the service file and keep preview, build, and end-to-end instructions aligned with the Makefile -- 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 — password and session handoff -## 0.5.0 — PAM password policy flow +- password-change state — before this change, an expired password had no separate screen state; `crates/latchd-tui/src/app/auth.rs` now switches to a new-password prompt for `PAM_NEW_AUTHTOK_REQD` and `PAM_AUTHTOK_EXPIRED`, then starts authentication again after the change +- password conversation — `crates/latchd-auth/src/lib.rs::converse` handles the current-password and new-password prompts in order; tests cover both prompt sequences in `crates/latchd-auth/src/lib.rs` +- audit events — `crates/latchd-core/src/audit.rs` records authentication, password-change, and session results with stable `PamReason` values, so the service log can group failures without copying module text into the event fields +- session preferences — `crates/latchd-core/src/preferences.rs` stores one bounded entry per account, writes through a private temporary file, and replaces the target atomically; invalid ownership or file mode stops the save +- Wayland handoff — `crates/latchd-core/src/session.rs::build_spec` carries the selected session type, desktop, seat, VT, runtime directory, session ID, and D-Bus address into `latchd-auth` before the session opens +- PAM fixture — `crates/latchd-auth/tests/pam_fixture.rs` covers denied authentication, password change, session metadata, and child environment values; `scripts/test-pam-fixture.sh` installs the temporary service files for the test run +- test coverage — `crates/latchd-core`, `crates/latchd-auth`, and `crates/latchd-tui` now test configuration, account state, session parsing, authentication conversations, preferences, rendering, and background animation -- Added PAM password-change handling for accounts that return - `PAM_NEW_AUTHTOK_REQD` or `PAM_AUTHTOK_EXPIRED` during login. -- Added an explicit TUI new-password state. PAM still enforces password - history, strength, and token policy; the login screen never implements its - own password rules. -- Added zeroizing old/new password buffers and conversation tests for the - current-password/new-password prompt sequence. -- Added redacted structured audit events for authentication, password changes, - and session handoff. Raw PAM messages and password material are excluded. -- Added TUI, core, and preference tests; the workspace now contains 37 - 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 — account and session workflow -## 0.4.0 — Advanced login workflow +- account status — `crates/latchd-core/src/accounts.rs::inspect_account` reads lock markers, password age, account expiry, inactivity, login shell, and shadow availability; the TUI shows the matching state beside the selected account +- faillock parsing — `crates/latchd-core/src/accounts.rs::faillock_status_from_output` accepts legacy counts, username prefixes, and timestamp rows; malformed output or a timeout removes the extra count from the screen +- account switching — `crates/latchd-tui/src/app/input.rs::select_account_index` changes the account, clears the password, resets authentication state, and reapplies the saved session for the new account +- session refresh — `crates/latchd-tui/src/app/discovery.rs::start_refresh` moves account and session discovery to a worker; the screen keeps accepting input while the lists refresh +- filtering — `crates/latchd-tui/src/app/input.rs::matches_filter` adds smart-case filtering for account and session names; uppercase query characters select case-sensitive matching +- terminal display — `crates/latchd-tui/src/app/render.rs` adds high-contrast styles, footer hints, account status text, notifications, and a minimum terminal size of 60 columns by 16 rows +- authentication backoff — `crates/latchd-tui/src/app/auth.rs::auth_backoff` delays repeated failures from one to sixteen seconds and resets the delay when the account changes +- host checks — `scripts/test-cli.sh` and `scripts/test-system-e2e.sh` cover CLI preflight and the available PAM, faillock, VT, X11, Wayland, and systemd-logind capabilities +- VM gate — `docs/e2e-testing.md` lists the host requirements for the strict check and the commands for a disposable VM +- version — set the workspace package version to `0.4.0` in `Cargo.toml` -- 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 -## 0.3.0 — Rust workspace release preparation +- Rust service — `rust/main.rs` became the supported login-manager entry point; the Go prototype stayed under `legacy/go/` and is excluded from normal builds +- workspace crates — split authentication, configuration, accounts, sessions, and the TUI into `latchd-core`, `latchd-auth`, and `latchd-tui` +- session commands — `crates/latchd-core/src/session.rs::parse_argv` stores a program and argument list instead of sending desktop entries through a shell, which rejects shell operators and desktop field codes +- terminal effects — `crates/latchd-tui/src/background.rs` adds wallpaper loading, a bounded cellular animation, reduced motion, and the background toggle +- release pipeline — `.gitlab-ci.yml` and `Makefile` add format, test, Clippy, rustdoc, Docker, version, package, checksum, and release targets +- deployment gate — `scripts/test-system-e2e.sh` and `docs/e2e-testing.md` separate container checks from live VT, display, and logind checks -- Rust is the supported login manager; the Go/cgo prototype is quarantined at - `legacy/go/` and excluded from normal builds and release archives. -- Added workspace crates for PAM authentication, session/config/account - management, and the TUI, including account switching and advisory faillock. -- Added optional terminal-cell wallpapers, cellular automata effects, and - accessible motion/background controls. -- Added GitLab CI verification, version-checked tag releases, optimized - single-version Cargo builds, deterministic installation archives, SHA-256 - checksums, and embedded build metadata. -- Live VT, logind, PAM-policy, X11, and Wayland validation remains a deployment - requirement on an isolated VM or target host. +## Legacy Go history -## Legacy Go history (not the Rust release) +The entries below describe the first Go prototype. The Rust workspace does not build or ship this code. -## 0.1.0 — Unreleased +## 0.1.0 — first public prototype -first public release +- login screen — `legacy/go/login/login.go` added username and password fields, a session switcher, status messages, and preview mode +- session types — `legacy/go/session/scan.go` added X11, Wayland, script, and TTY entries +- authentication — `legacy/go/auth/pam.go` added cgo login support with one OS thread per PAM transaction +- faillock — `legacy/go/auth/faillock.go` added lockout counts, remaining attempts, and the administrator reset key +- terminal effects — `legacy/go/tui/bg/automata.go` added the cellular background, switcher motion, and failed-login shake +- terminal details — `legacy/go/login/login.go` added the caps-lock indicator, power keys, clock, and preview mode +- configuration — `legacy/go/config/config.go` added configuration parsing, variable replacement, and configurable power programs +- accounting — `legacy/go/auth/utmpx.go` added UTMPX records for `who`, `w`, and `last` on glibc +- X11 startup — `legacy/go/session/x11.go` added Xauthority cookies, the `SIGUSR1` readiness signal, and a server timeout +- privilege drop — `legacy/go/session/exec.go` started the selected child with the account UID, GID, and supplementary groups +- logs — `legacy/go/session/exec.go` capped the client log at 64 MiB -### What's In +### Fixes -- tui login screen with username/password fields, session switcher, and status messages -- supports x11, wayland, and tty sessions -- pam authentication via cgo (thread-locked for safety) -- faillock integration — detects lockouts, shows remaining attempts, admin bypass via ctrl+u -- conway's game of life background rendered at 30 fps with unicode half-block characters -- spring animation on switcher transitions -- shake animation on failed login -- caps lock indicator via ioctl -- configurable power controls (shutdown/reboot on f1/f2 by default) -- toml configuration with `$VARIABLE` substitution -- preview mode (`--preview`) to test the login screen without leaving your session -- utmpx user accounting (glibc only) -- x11 session setup — xauthority cookies, sigusr1 handshake, server timeout -- privilege dropping for child processes -- log piping with 64 mb cap - -### Recent Fixes - -- **DesktopNames parsing** — `.desktop` files now parse the `DesktopNames` field. the first semicolon-delimited entry becomes `XDG_SESSION_DESKTOP`, and the full list with `:` separators becomes `XDG_CURRENT_DESKTOP`. fixes GNOME/KDE sessions not getting proper environment variables -- **utmpx accounting wired** — `AddUtmpx` and `Close` are now called from `spawnSession` so user logins appear in `who`/`w`/`last` -- **fixed hardcoded shell** — `Credentials.Shell` now reads from `/etc/passwd` instead of always returning `/bin/sh`. removes duplicate `userShell` logic in exec.go -- **automata background wired** — `Automata.Frame()` is now rendered in `View()` as the base layer with the login form card overlaid using ANSI cursor positioning. the form has a dark card background for readability -- **clock widget** — added a datetime display (`Mon Jan 02 15:04:05`) to the top bar, updated every tick -- **session specifier label** — the environment switcher now shows the session type (`x11`, `wayland`) above the session name, like ly's specifier -- **form card background** — login content is rendered inside a padded card with a dark background so it reads cleanly over the animated automata - -- **utmpx time field portability** — replaced hardcoded `C.__uint32_t` / `C.__int32_t` casts with a C helper function (`auth/utmpx_time.h`) that lets the compiler handle type conversion. fixes build failure on systems where `ut_tv` uses `struct timeval` instead of the 32/64 compat struct -- **cgo pointer safety for pam** — replaced `unsafe.Pointer(&h)` (Go stack pointer passed to C) with `storeHandle`/`loadHandle` helpers that keep the `cgo.Handle` in C-heap memory. fixes `cgo argument has Go pointer to unpinned Go pointer` panic on Go 1.22+ -- **pam conversation callback** — fixed `unsafe.Slice` using `&r` (pointer-to-pointer) as the backing array instead of `r` (the calloc'd response array), which wrote password bytes into random stack memory. also now only responds to `PAM_PROMPT_ECHO_OFF`/`PAM_PROMPT_ECHO_ON` messages — info and error messages get nil. fixes `pam_unix: auth could not identify password` -- **cgo handle lifecycle** — moved `cgo.Handle` and its C-heap backing from `Validate` (where they were `defer`-freed on return) into the `Credentials` struct so they stay alive through `OpenSession` and until `CloseSession`. fixes `misuse of an invalid Handle` panic -- **session spawning** — replaced the broken `ForkExec` (which relaunched the same binary with made-up flags) with a direct `session.Spawn` call that drops privileges and runs the desktop in-process. fixes `flag provided but not defined: -user` -- **renamed extra/ files** — `lemurs.pam` → `latchd.pam`, `lemurs.service` → `latchd.service` -- **readme credits** — added a credits section acknowledging lemurs for the `extra/` directory structure and session scanning approach -- **makefile `enable` and `update` targets** — `make enable` stops any running DM, disables the aliased display-manager, and enables latchd. `make update` runs uninstall → git pull → reinstall in one command -- **missing `extra/` directory** — added `extra/config.toml`, `extra/xsetup.sh`, `extra/lemurs.pam`, and `extra/lemurs.service` so `make install` can find them -- **makefile `deps` target** — added `make deps` to automatically install `libpam0g-dev` and `build-essential`, and verify go is present -- **readme overhaul** — rewrote for clarity: added "what problem does this solve", "how it works", known issues, simpler install instructions, and proper dependency listing -- **view cleanup** — removed the non-functional automata background overlay from `View()` (the `Frame()` was never called, and the black-space placeholder caused `EEEE` garbage characters on some terminals). also removed a hardcoded 80-char width on the top bar so it adapts to the terminal -- **session environment setup** — `spawnSession` now calls `SetBasicVariables`, `SetDisplay`, `SetSessionParams`, `SetSeatVars`, `SetSessionVars`, and `SetXDGCommonPaths` before spawning the desktop. fixes `setupX` failing immediately with `missing DISPLAY/XDG_VTNR/HOME env vars` because none were set -- **pam session error made non-fatal** — `pam_open_session` failure (e.g. `pam_systemd` rejecting the DM process as session leader) is now logged as a warning instead of blocking the entire session spawn. also removed `session include login` from `latchd.pam` since the DM process can't register user sessions with systemd-logind -- **global session switching with `,` and `.`** — sessions can now be switched from any mode (not just when the switcher is focused). arrows still work when switcher is focused - -### What's Missing - -- no tests -- session leader is the latchd process, not the desktop — pam session tracking via systemd-logind may be imperfect -- not all config options affect the rendering yet -- occasional terminal state corruption on abrupt exit -- automata background visible at screen edges; form card covers the center +- DesktopNames parsing — `legacy/go/session/scan.go::splitDesktopNames` previously dropped desktop names from the session environment; it now maps the first semicolon-delimited value to `XDG_SESSION_DESKTOP` and the full list to `XDG_CURRENT_DESKTOP`, which fixes GNOME and KDE sessions that start without their desktop values +- UTMPX time fields — `legacy/go/auth/utmpx.go` used fixed C integer types for `ut_tv`; `legacy/go/auth/utmpx_time.h` now accepts the platform time fields, which fixes builds where `ut_tv` uses `struct timeval` +- cgo pointer storage — `legacy/go/auth/pam.go` passed a Go pointer through `pam_conv`; `storeHandle` and `loadHandle` keep the numeric handle in C memory, which fixes the Go 1.22 pointer check +- PAM conversation — `legacy/go/auth/pam.go::latchd_pam_conv` used a pointer-to-pointer as the response slice; it now writes to the allocated response array and answers only prompt messages, which fixes corrupted responses and the `pam_unix` password error +- cgo handle lifetime — `legacy/go/auth/pam.go::Credentials` now owns the handle until session close; the previous `Validate` cleanup released it before `OpenSession` +- session spawning — `legacy/go/session/exec.go` starts the selected desktop directly with dropped credentials; the previous path relaunched latchd with an undefined `-user` flag +- file names — rename the `lemurs` policy and unit files in `extra/` to `latchd` +- extra files — add `extra/config.toml`, `extra/xsetup.sh`, `extra/latchd.pam`, and `extra/latchd.service`, which lets `make install` find its source files +- Make targets — add `deps`, `enable`, and `update` targets for dependency checks, service enablement, and local updates +- terminal rendering — remove the unused background layer and fixed top-bar width from the Go renderer, which fixes garbage characters and narrow-terminal layout errors +- session environment — `legacy/go/session/env.go` now sets display, VT, desktop, seat, XDG, and home values before the desktop starts, which fixes missing `DISPLAY`, `XDG_VTNR`, and `HOME` +- session startup — handle `pam_open_session` errors as session log entries and remove the obsolete `login` include from the prototype service file +- global session switching — accept `,` and `.` from every screen state while keeping arrow keys for the focused switcher diff --git a/README.md b/README.md index 481a6e6..c7f5f74 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,72 @@ # latchd -Want a TUI-based login manager because you are tired of GDM or Plasma Login? Latchd puts a small, keyboard-driven 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. +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, then starts a TTY shell, an X11 desktop, or a Wayland desktop for the account you choose. -Latchd is written in Rust. The old Go prototype remains in `legacy/go/` for reference. It is not part of the release build. +Latchd is written in Rust and runs as a systemd service. It finds login accounts through NSS, reads desktop entries from the configured directories, The old Go prototype is still in [`legacy/go/`](legacy/go/), but it is not part of the build. ![Latchd login screen](docs/images/latchd-preview.png) -The image above is the preview screen. It was captured from the Docker test image. See [`docs/ui.md`](docs/ui.md) for the keyboard reference and the second screenshot. +This screen comes from the Docker preview capture; the [UI guide](docs/ui.md) has the keyboard map and the help screen. -## Features +## What latchd does -- Discovers normal login accounts through NSS. -- Shows account expiry, locked-account, and faillock status before login. -- Lets PAM handle authentication and password changes. -- Switches between users and sessions from the same screen. -- Finds X11 and Wayland desktop entries from configured directories. -- Can offer a TTY shell as a session choice. -- Starts the selected session with a restricted environment and the correct user ID. -- Passes the selected session, seat, and virtual terminal to PAM and logind. -- Imports approved values from PAM into the session environment. -- Writes UTMPX records when the platform supports them. -- Provides filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion controls. -- Has a preview mode for UI work and configuration checks. +- account discovery — reads normal login accounts through NSS; filters low UIDs, excluded names, no-login shells, and unsafe home directories +- account status — reads shadow aging fields and the configured faillock helper; shows expiry, lock, inactivity, and failure counts in the screen +- session discovery — reads X11 and Wayland `.desktop` files plus administrator scripts; keeps the program and its arguments separate instead of passing them to a shell +- login handoff — builds the user environment, opens the configured login session, starts the selected program with the account UID and groups, and closes the session when it exits +- X11 startup — creates a private Xauthority file, waits for the X server readiness signal, and removes the file after the desktop exits +- Wayland startup — starts the selected desktop with the session values it needs; it does not start a compositor +- TTY startup — attaches the selected shell to the virtual terminal and records the user process when UTMPX is available +- user interface — provides account switching, last-session preferences, filtering, refresh, notifications, wallpapers, reduced motion, and confirmed power actions -The service needs Linux PAM and a virtual terminal. X11 and Wayland sessions also need their own display server, compositor, and desktop files. Docker covers the parts that do not require those host services. The remaining checks must run on a prepared machine or VM. +Preview mode draws the same screen and reads the same TOML structure, but it does not authenticate or start a user program, which makes it useful for layout work and config checks. -## Requirements +## Quick start -Builds use Rust 1.88 and the system PAM development files. On Debian or Ubuntu: +Install the build requirements on Debian or Ubuntu: ```sh sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules ``` -The repository includes the required `rustfmt` and `clippy` components in `rust-toolchain.toml`. - -## Build and run - -Run the local verification gate and build the release binary: +Build the release binary and run the checks: ```sh make check make build ``` -The binary is written to `build/latchd`. To open the non-authenticating preview: +The binary is `build/latchd`. Open the preview screen with: ```sh -make run-preview -# or ./build/latchd --preview --no-log ``` -Preview mode does not contact PAM, start a user session, or run power actions. It is safe to use while changing the layout or configuration. - -For terminals with limited character support, set `LATCHD_ASCII=1`. Set `NO_COLOR=1` for plain output. Set `LATCHD_HIGH_CONTRAST=1` for a stronger focus colour. +The repository pins Rust 1.88 in [`rust-toolchain.toml`](rust-toolchain.toml). ## Configuration -The example file is [`extra/config.toml`](extra/config.toml). Latchd uses separate paths for a system service and a local preview: +The service reads `/etc/latchd/config.toml`. Preview mode first checks `$XDG_CONFIG_HOME/latchd/config.toml`, then `~/.config/latchd/config.toml`, and finally the system file. `--config PATH` selects one file directly. -- A root service reads `/etc/latchd/config.toml`. -- Preview mode first checks `$XDG_CONFIG_HOME/latchd/config.toml`. -- If `XDG_CONFIG_HOME` is not set, preview mode checks `~/.config/latchd/config.toml`. -- `--config PATH` selects a file explicitly and takes precedence over the defaults. - -Create the user file from the example: +Create a user file from the example with: ```sh make install-user -./build/latchd --preview --no-log ``` -`make install-user` creates `~/.config/latchd/` with mode `700` and creates `config.toml` with mode `600`. It does not replace an existing file. Set `USER_CONFIG_HOME=/path` or `XDG_CONFIG_HOME=/path` when the file must be stored elsewhere. - -The main sections in the example are: - -- `[accounts]` for NSS discovery, faillock, and account-age checks. -- `[x11]` and `[wayland]` for desktop-entry and administrator script directories. -- `[environment_switcher]` for session choices and the last-session preference file. -- `[visual]` for wallpaper, animation, notification, and reduced-motion settings. -- `[power_controls]` for confirmed power commands. Latchd starts these programs directly; it does not use a shell. - -Keep the system file owned by root and unwritable by group or other users. Do not put passwords or other secrets in command-line arguments. +That command creates `~/.config/latchd/` with mode `700`, writes `config.toml` with mode `600`, and leaves an existing file alone. The full field reference, variable substitution, command-line overrides, desktop entry rules, and permission checks are in [`docs/configuration.md`](docs/configuration.md). ## Install on a test machine -Use a disposable machine first. Keep another login path available while changing the display manager. +Use a disposable machine and keep another login path available while you change the display manager: ```sh sudo make install sudo systemctl enable latchd.service +sudo systemctl start latchd.service ``` -This installs: - -- `/usr/bin/latchd` -- `/etc/latchd/config.toml` -- `/etc/pam.d/latchd` -- `/usr/lib/systemd/system/latchd.service` - -The supplied service uses `/dev/tty2`. If you choose another VT, change both the `tty` value in the config and `TTYPath` in the systemd unit. Stop or disable the existing display manager before enabling latchd on the same terminal. +The install target writes the binary, `/etc/latchd/config.toml`, `/etc/pam.d/latchd`, the session-script directories, and the systemd unit. The supplied unit uses `/dev/tty2`, so change both `tty` in the TOML file and `TTYPath` in the unit if you use another virtual terminal. The install target does not disable the current display manager for you. ## Keyboard controls @@ -110,74 +76,53 @@ The supplied service uses `/dev/tty2`. If you choose another VT, change both the | Up / Down | Move focus or move in a filtered list | | Left / Right | Change the selected session | | `/` | Filter sessions or accounts | -| Ctrl-R | Refresh account and session discovery | +| Ctrl-R | Refresh accounts and sessions | | Ctrl-N / Ctrl-P | Select the next or previous account | -| Ctrl-B | Toggle wallpaper and ambient effects | +| Ctrl-B | Toggle the background | | Ctrl-M | Toggle reduced motion | -| Enter | Authenticate or submit a PAM password change | +| Enter | Authenticate or submit a password change | | Esc | Clear the password or close a popup | -| `?` | Show keyboard help | +| `?` | Show the key reference | | Ctrl-C | Restore the terminal and quit | | F1–F12 | Confirm a configured power action | -Lowercase filters ignore case. A filter with an uppercase letter is case-sensitive. The status line can show account and faillock information before login. +Lowercase filters ignore case, while a filter with an uppercase character is case-sensitive. The preview screen and the capture command are documented in [`docs/ui.md`](docs/ui.md). -## Testing +## Checks and releases -Run the local checks with these commands: +Use `make check` for formatting, unit tests, Clippy, and rustdoc; use `make integration-test` for the CLI paths and `make docker-test` for the pinned container build. `make pam-fixture-test` needs root because it installs temporary test policies, while `make system-e2e` checks the services and devices available on the current host. -```sh -make check -make integration-test -make docker-test -make system-e2e -``` +Run `make system-e2e-strict` only in a disposable VM that has a test policy, a real virtual terminal, systemd-logind, and the X11 or Wayland stack you want to test. [`docs/e2e-testing.md`](docs/e2e-testing.md) explains what each check can and cannot cover. -Run `make audit` after you install `cargo-audit`. Run `make pam-fixture-test` as root. The PAM fixture command creates temporary policies and removes them when it exits. - -`make system-e2e` checks the PAM library, faillock, virtual terminal, X11, Wayland, and systemd-logind on the current host. It prints `PASS`, `SKIP`, or `FAIL`. A pseudo-terminal does not provide a virtual terminal. The check does not install a PAM policy or start systemd-logind. - -Run the strict check in a disposable VM with a test PAM policy, a real VT, systemd-logind, and the X11 or Wayland stack you want to test: - -```sh -make system-e2e-strict -``` - -See [`docs/e2e-testing.md`](docs/e2e-testing.md) for the test limits and VM setup. - -## Security notes - -- PAM handles stay on one worker thread. -- Password buffers are zeroized. Passwords are not written to argv, logs, or the TUI. -- Audit records contain the account and a stable result code. They do not contain PAM's free-form error text or password data. -- Session environments are rebuilt from an allowlist. -- Latchd checks desktop files, scripts, binaries, home directories, logs, and configuration files before use. -- Xauthority files and session logs use private permissions and cleanup guards. -- Power commands need confirmation and are disabled in preview mode. -- Account and faillock checks do not unlock accounts or bypass PAM. - -## Project layout - -```text -crates/latchd-core/ configuration, account discovery, sessions, and UTMPX -crates/latchd-auth/ PAM FFI, password changes, 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, CLI, PAM, and system capability tests -legacy/go/ non-production Go reference -``` - -## Releases - -GitLab CI runs formatting, tests, Clippy, rustdoc, RustSec checks, CLI tests, capability checks, and the Docker suite. A SemVer tag runs the package job and publishes a Linux archive with its SHA-256 file. The strict VT, PAM, X11, Wayland, and logind checks are separate because CI containers do not provide those host services. - -Create a local package with: +Create a local release archive with: ```sh make package ``` +GitLab CI runs the same Rust, CLI, container, and capability checks, then creates an archive and SHA-256 file for a SemVer tag. The release process is described in [`docs/development.md`](docs/development.md). + +## Read next + +- [`docs/guide.md`](docs/guide.md) explains the service from startup to session cleanup. +- [`docs/configuration.md`](docs/configuration.md) explains each configuration field. +- [`docs/architecture.md`](docs/architecture.md) explains the workspace and the process boundaries. +- [`docs/development.md`](docs/development.md) explains builds, tests, Docker, and packages. +- [`docs/ui.md`](docs/ui.md) shows the screen and its controls. +- [`docs/e2e-testing.md`](docs/e2e-testing.md) explains the VM and container checks. + +## Project layout + +```text +crates/latchd-core/ account discovery, configuration, sessions, and UTMPX +crates/latchd-auth/ authentication worker and session handoff +crates/latchd-tui/ terminal state, rendering, input, and effects +rust/main.rs command-line entry point and service loop +extra/ example configuration, login policy, and systemd unit +scripts/ CLI, Docker, fixture, and host checks +legacy/go/ reference implementation from the early prototype +``` + ## License Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/crates/latchd-auth/src/lib.rs b/crates/latchd-auth/src/lib.rs index 8d404ca..a5e2c1a 100644 --- a/crates/latchd-auth/src/lib.rs +++ b/crates/latchd-auth/src/lib.rs @@ -1,6 +1,6 @@ -//! PAM authentication with one dedicated worker thread. +//! Authentication worker and session handoff. //! -//! The worker owns all PAM handles. Public tickets contain only an opaque ID. +//! Keep handles on one thread; public tickets contain only an opaque ID. use std::{ collections::HashMap, @@ -108,7 +108,7 @@ unsafe extern "C" { fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char; } -/// An opaque authenticated PAM transaction. +/// Opaque ticket for an authenticated transaction. pub struct AuthTicket { id: u64, identity: Identity, @@ -153,7 +153,7 @@ enum Command { }, } -/// Serializes PAM operations on a dedicated worker thread. +/// Run handle-based operations on one worker thread. #[derive(Clone)] pub struct AuthService { commands: SyncSender, @@ -192,10 +192,10 @@ impl AuthService { Ok(ticket) } - /// Changes an expired account password through the configured PAM policy. + /// Change an expired account password through the configured policy. /// - /// PAM enforces password strength, history, and token rules. - /// Zeroizing buffers hold both passwords and errors never include them. + /// The configured policy checks password strength, history, and token rules + /// zeroizing buffers hold both passwords. pub fn change_password( &self, username: String, @@ -218,7 +218,7 @@ impl AuthService { .map_err(|_| Error::Worker("PAM worker disconnected".into()))? } - /// Runs the authenticated session and closes its PAM transaction. + /// Run the authenticated session and close its transaction. pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> { let (response, result) = sync_channel(0); let id = ticket.id; diff --git a/crates/latchd-core/src/accounting.rs b/crates/latchd-core/src/accounting.rs index 3de5014..50bf77a 100644 --- a/crates/latchd-core/src/accounting.rs +++ b/crates/latchd-core/src/accounting.rs @@ -1,4 +1,4 @@ -//! Linux UTMPX accounting with idempotent RAII cleanup. +//! Write and close UTMPX records for user sessions. use std::{io, mem, os::raw::c_char, ptr}; @@ -10,7 +10,7 @@ pub struct UtmpxGuard { } impl UtmpxGuard { - /// Adds a user-process record for a child and returns its cleanup guard. + /// Add a user-process record and return the cleanup guard. pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result { let mut record: libc::utmpx = unsafe { mem::zeroed() }; record.ut_type = libc::USER_PROCESS; @@ -26,7 +26,6 @@ impl UtmpxGuard { }) } - /// Writes the corresponding dead-process record. Calling it twice is safe. pub fn close(&mut self) -> Result<(), Error> { if self.closed { return Ok(()); diff --git a/crates/latchd-core/src/accounts.rs b/crates/latchd-core/src/accounts.rs index 1a5b87c..8d48e52 100644 --- a/crates/latchd-core/src/accounts.rs +++ b/crates/latchd-core/src/accounts.rs @@ -1,4 +1,4 @@ -//! NSS account discovery and advisory faillock status. +//! Discover login accounts and display account policy state. use std::{ ffi::CStr, @@ -23,7 +23,7 @@ pub struct Identity { pub shell: PathBuf, } -/// Effective account state determined from the login shell and shadow aging data. +/// Account state derived from the shell and shadow aging fields. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountState { Active, @@ -32,7 +32,7 @@ pub enum AccountState { AccountExpired, AccountInactive, NoLoginShell, - /// Shadow data was not available or could not be parsed. + /// Shadow data was missing or could not be parsed. Unknown, } @@ -65,7 +65,7 @@ pub struct ActiveSession { pub host: Option, } -/// Runtime session-management backend detected on this host. +/// Session backend found on the host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionBackend { SystemdLogind, @@ -81,7 +81,7 @@ struct ShadowEntry { account_expiry: Option, } -/// A non-authoritative account lockout summary. +/// Parsed faillock counts for the selected account. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct FaillockStatus { pub available: bool, @@ -91,7 +91,7 @@ pub struct FaillockStatus { pub locked_out: bool, } -/// Enumerates interactive accounts using NSS and deterministic ordering. +/// Read interactive accounts through NSS and sort them by username. pub fn discover(config: &AccountsConfig) -> Vec { let mut accounts = Vec::new(); unsafe { @@ -128,9 +128,8 @@ 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. +/// Read identities and attach shadow aging state when the file is available. +/// Missing shadow data produces [`AccountState::Unknown`] for that account. pub fn discover_with_status(config: &AccountsConfig) -> Vec { discover(config) .into_iter() @@ -147,8 +146,8 @@ pub fn discover_with_status(config: &AccountsConfig) -> Vec { .collect() } -/// Inspects password and account aging fields without returning password -/// hashes. The configured shadow path must be root-owned and non-writable. +/// Read aging fields without returning password hashes; the shadow path must +/// be root-owned and not writable by group or other users. pub fn inspect_account( identity: &Identity, config: &AccountsConfig, @@ -168,7 +167,7 @@ pub fn inspect_account( Ok(account_status_from_shadow(identity, entry, day)) } -/// Returns all active UTMPX user-process records in deterministic order. +/// Read active UTMPX user-process records in sorted order. pub fn active_sessions() -> Vec { let mut sessions = Vec::new(); unsafe { @@ -206,7 +205,7 @@ pub fn active_sessions() -> Vec { sessions } -/// Detects whether systemd-logind has initialized its runtime seat state. +/// Check 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(), @@ -218,7 +217,7 @@ pub fn session_backend() -> SessionBackend { } } -/// Runs the configured helper and parses its stable numeric output. +/// Run the configured helper and parse its numeric output. pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result { let helper = &config.faillock_path; if !helper.is_absolute() { @@ -257,8 +256,7 @@ 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)); diff --git a/crates/latchd-core/src/audit.rs b/crates/latchd-core/src/audit.rs index 6856d4b..aa1d740 100644 --- a/crates/latchd-core/src/audit.rs +++ b/crates/latchd-core/src/audit.rs @@ -1,12 +1,10 @@ -//! Redacted security audit events. -//! -//! Audit records exclude secrets, PAM messages, and command arguments. +//! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information. use std::fmt; use crate::error::Error; -/// The security operation that produced an audit event. +/// Login operation that produced an audit event. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditAction { Authentication, @@ -24,7 +22,7 @@ impl fmt::Display for AuditAction { } } -/// The outcome of a security operation. +/// Result recorded for a login operation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditOutcome { Started, @@ -44,7 +42,7 @@ impl fmt::Display for AuditOutcome { } } -/// A safe category for a PAM failure, without exposing PAM's free-form text. +/// Stable category for a PAM status without its free-form message. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PamReason { AuthenticationRejected, @@ -72,12 +70,12 @@ impl fmt::Display for PamReason { } } -/// A structured event whose fields are safe to send to an audit logger. +/// Event fields intended for the audit logger. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AuditEvent<'a> { pub action: AuditAction, 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 pam_reason: Option, } @@ -115,7 +113,7 @@ impl<'a> AuditEvent<'a> { } } -/// Maps Linux-PAM status codes to stable, non-sensitive categories. +/// Map Linux-PAM status codes to stable categories. pub const fn pam_reason(status: i32) -> PamReason { match status { 6 | 7 => PamReason::AuthenticationRejected, diff --git a/crates/latchd-core/src/config.rs b/crates/latchd-core/src/config.rs index 5942e1b..080990a 100644 --- a/crates/latchd-core/src/config.rs +++ b/crates/latchd-core/src/config.rs @@ -13,7 +13,7 @@ pub const SYSTEM_CONFIG_PATH: &str = "/etc/latchd/config.toml"; const PROJECT_CONFIG_DIR: &str = "latchd"; const CONFIG_FILE: &str = "config.toml"; -/// Returns the current user's configuration path. +/// Build the current user's configuration path from XDG variables. pub fn user_config_path() -> Option { let base = env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -184,7 +184,7 @@ pub struct PowerAction { pub args: Vec, } -/// Loads a configuration file over the built-in defaults. +/// Load a TOML file over the built-in defaults and validate it. pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result { let mut config = Config::default(); if let Some(variables_path) = variables { @@ -231,7 +231,7 @@ fn resolve_variables(input: &str, variables: &HashMap) -> String }) } -/// Validates values that must be safe before starting the login screen. +/// Validate values and paths before the login screen starts. pub fn validate(config: &Config) -> Result<(), Error> { if config.tty == 0 || config.tty > 63 { return Err(Error::Config("tty must be between 1 and 63".into())); @@ -314,13 +314,13 @@ pub fn validate(config: &Config) -> Result<(), Error> { Ok(()) } -/// Rejects a privileged configuration source that can be replaced by another user. +/// Require a root-owned regular file without group or other write permission. pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> { let metadata = fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?; if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { return Err(Error::Config(format!( - "{label} must be a root-owned, non-group-writable regular file: {}", + "{label} must be owned by root, non-group-writable regular file: {}", path.display() ))); } diff --git a/crates/latchd-core/src/error.rs b/crates/latchd-core/src/error.rs index 008e5f3..7e1c6a6 100644 --- a/crates/latchd-core/src/error.rs +++ b/crates/latchd-core/src/error.rs @@ -1,39 +1,39 @@ -use std::io; +use std::{fmt::Write as _, io}; use thiserror::Error; #[derive(Debug, Error)] pub enum Error { - #[error("configuration: {0}")] + #[error("Configuration error: {0}")] Config(String), - #[error("{context}: {source}")] + #[error("I/O error while {context}: {source}")] Io { context: String, #[source] source: io::Error, }, - #[error("parse {path}: {source}")] + #[error("TOML error in {path}: {source}")] Toml { path: String, #[source] source: toml::de::Error, }, - #[error("PAM {operation} failed with status {status}: {message}")] + #[error("PAM error during {operation} (status {status}): {message}")] Pam { operation: &'static str, status: i32, message: String, }, - #[error("session: {0}")] + #[error("Session error: {0}")] Session(String), - #[error("terminal: {0}")] + #[error("Terminal error: {0}")] Terminal(String), - #[error("authentication worker: {0}")] + #[error("Authentication worker error: {0}")] Worker(String), } impl Error { - /// Returns a PAM status when this error came directly from PAM. + /// Return the PAM status when the error came from PAM. pub fn pam_status(&self) -> Option { match self { Self::Pam { status, .. } => Some(*status), @@ -41,10 +41,88 @@ impl Error { } } - /// Returns whether PAM requires a password change before login can finish. + /// Check whether the status requests a password change. pub fn requires_password_change(&self) -> bool { matches!(self.pam_status(), Some(12 | 27)) } + + /// Build a user-facing report with context and recovery commands. + pub fn user_report(&self) -> String { + let mut report = format!("{}\n\nDetails: {}\n\nTry:\n", self, self.details()); + for fix in self.fixes() { + let _ = writeln!(report, " - {fix}"); + } + report + } + + fn details(&self) -> &'static str { + match self { + Self::Config(_) => { + "latchd rejected a configuration value before it opened the login screen." + } + Self::Io { .. } => { + "The operating system rejected a file operation. The path, owner, mode, or free space can be wrong." + } + Self::Toml { .. } => { + "The TOML parser found an invalid key, value, or separator at the location shown above." + } + Self::Pam { .. } => { + "The login service returned a PAM status. The status and module message appear above." + } + Self::Session(_) => "latchd could not prepare or start the selected desktop session.", + Self::Terminal(_) => { + "The terminal does not provide the input or output device that latchd needs." + } + Self::Worker(_) => { + "A background latchd task stopped before it completed the requested operation." + } + } + } + + fn fixes(&self) -> Vec { + match self { + Self::Config(_) => vec![ + "Edit the value named in the error.".into(), + "Run `latchd --preview --no-tui` to check the user configuration.".into(), + "Run `sudo latchd --no-tui` to check the system configuration.".into(), + ], + Self::Io { .. } => vec![ + "Check the path and permissions named in the error.".into(), + "Run `df -h` to check free space.".into(), + "Run `sudo journalctl -u latchd.service -b` to read the service log.".into(), + ], + Self::Toml { path, .. } => { + let path = shell_quote(path); + vec![ + format!("Open {path} at the reported line and column."), + format!("Run `latchd --preview --no-tui --config {path}` after the edit."), + ] + } + Self::Pam { .. } => vec![ + "Run `sudo journalctl -u latchd.service -b` to read the login service log.".into(), + "Run `sudo faillock --user ` to check the account lock state.".into(), + "Check the PAM service file with `sudoedit /etc/pam.d/`.".into(), + ], + Self::Session(_) => vec![ + "Run `latchd --preview --no-tui` to validate the configuration and discovered sessions.".into(), + "Run `sudo journalctl -u latchd.service -b` to read the session log.".into(), + "Check the selected desktop command with `command -v `.".into(), + ], + Self::Terminal(_) => vec![ + "Run `tty` to confirm that the process has a controlling terminal.".into(), + "Run `latchd --preview` to test the screen without a real login.".into(), + "Run `sudo systemctl restart latchd.service` after you attach latchd to a real VT.".into(), + ], + Self::Worker(_) => vec![ + "Run `sudo systemctl restart latchd.service` to restart the worker.".into(), + "Run `sudo journalctl -u latchd.service -b` to find the worker failure.".into(), + ], + } + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) } pub fn io_error(context: impl Into, source: io::Error) -> Error { @@ -63,3 +141,27 @@ impl ResultExt for io::Result { self.map_err(|source| io_error(context, source)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configuration_report_contains_details_and_commands() { + let report = Error::Config("tty must be between 1 and 63".into()).user_report(); + assert!(report.contains("Details:")); + assert!(report.contains("latchd --preview --no-tui")); + assert!(report.contains("sudo latchd --no-tui")); + } + + #[test] + fn toml_report_quotes_the_source_path() { + let error = Error::Toml { + path: "/tmp/latchd config.toml".into(), + source: toml::from_str::("=").expect_err("invalid TOML"), + }; + let report = error.user_report(); + assert!(report.contains("'/tmp/latchd config.toml'")); + assert!(report.contains("--config '/tmp/latchd config.toml'")); + } +} diff --git a/crates/latchd-core/src/lib.rs b/crates/latchd-core/src/lib.rs index 67f618e..fe114ec 100644 --- a/crates/latchd-core/src/lib.rs +++ b/crates/latchd-core/src/lib.rs @@ -1,4 +1,4 @@ -//! Core configuration, account, session, and accounting contracts for latchd. +//! Shared configuration, account, session, and accounting types. #![deny(unsafe_op_in_unsafe_fn)] diff --git a/crates/latchd-core/src/preferences.rs b/crates/latchd-core/src/preferences.rs index 422276f..0ddac09 100644 --- a/crates/latchd-core/src/preferences.rs +++ b/crates/latchd-core/src/preferences.rs @@ -1,4 +1,4 @@ -//! Bounded, owner-checked persistence for last-used session choices. +//! Store the last session choice with private, atomic writes. use std::{ fs::{self, OpenOptions}, @@ -32,7 +32,7 @@ pub struct LastUsedPreferences { } impl LastUsedPreferences { - /// Records a successful selection, moving the newest entry to the front. + /// Record a successful selection and move it to the front. pub fn remember(&mut self, username: &str, session: &SessionDescriptor) { if username.is_empty() || session.title.is_empty() { return; @@ -49,7 +49,7 @@ impl LastUsedPreferences { self.entries.truncate(MAX_ENTRIES); } - /// Finds the exact previously selected session for an account. + /// Find the saved session for an account. pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option { let entry = self .entries @@ -65,7 +65,7 @@ impl LastUsedPreferences { } } -/// Loads preferences from an owner-checked TOML file. +/// Load preferences from an owner-checked TOML file. pub fn load(path: &Path) -> Result { if !path.is_absolute() { return Err(Error::Config("last-session path must be absolute".into())); @@ -93,7 +93,7 @@ pub fn load(path: &Path) -> Result { Ok(preferences) } -/// Atomically saves preferences with mode `0600` under a trusted directory. +/// Save 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())); diff --git a/crates/latchd-core/src/session.rs b/crates/latchd-core/src/session.rs index c9d24e7..49bae4d 100644 --- a/crates/latchd-core/src/session.rs +++ b/crates/latchd-core/src/session.rs @@ -1,4 +1,4 @@ -//! Session discovery, environment construction, and child lifecycle. +//! Discover sessions, build their environments, and run their children. use std::{ ffi::{CString, OsStr}, @@ -41,7 +41,7 @@ pub enum SessionKind { pub struct SessionDescriptor { pub kind: SessionKind, pub title: String, - /// Program and arguments, without shell interpretation. + /// Program and arguments kept separate from shell parsing. pub argv: Vec, pub current_desktop: Option, pub session_desktop: Option, @@ -82,7 +82,7 @@ impl SessionSpec { format!("tty{}", self.tty) } - /// Returns the selected-session metadata that PAM and logind must see. + /// Return session metadata for the login stack and logind. pub fn pam_environment(&self) -> impl Iterator { self.environment .iter() @@ -91,7 +91,7 @@ impl SessionSpec { } } -/// Discovers configured sessions in stable directory order. +/// Discover configured sessions in sorted directory order. pub fn discover(config: &Config) -> Vec { let mut sessions = Vec::new(); scan_desktop_dir( @@ -124,7 +124,7 @@ pub fn discover(config: &Config) -> Vec { sessions } -/// Checks configured binaries and discovered session entries without starting them. +/// Check configured binaries and session entries without starting them. pub fn preflight(config: &Config) -> Result<(), Error> { let shell = secure_executable(&config.system_shell, "configured system shell")?; if !shell.is_file() { @@ -192,7 +192,7 @@ fn secure_executable(path: &Path, label: &str) -> Result { Ok(resolved) } -/// Builds an explicit environment and process specification for one identity. +/// Build the environment and process specification for one account. pub fn build_spec( identity: &Identity, config: &Config, @@ -328,7 +328,7 @@ pub fn build_spec( }) } -/// Runs a user session and owns all child process cleanup. +/// Run a user session and clean up its child process. pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> { let home_metadata = fs::symlink_metadata(&identity.home) .map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?; diff --git a/crates/latchd-tui/src/app.rs b/crates/latchd-tui/src/app.rs new file mode 100644 index 0000000..79df937 --- /dev/null +++ b/crates/latchd-tui/src/app.rs @@ -0,0 +1,264 @@ +//! Login screen state and orchestration. + +use std::{ + sync::mpsc::Receiver, + time::{Duration, Instant}, +}; + +use crossterm::event::{self, Event, KeyEventKind}; +use ratatui::DefaultTerminal; +use zeroize::Zeroizing; + +use latchd_auth::{AuthService, AuthTicket}; +use latchd_core::{ + accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity}, + config::Config, + error::{Error, ResultExt}, + preferences::{self, LastUsedPreferences}, + session::{self, SessionDescriptor}, +}; + +use crate::background::Background; + +mod auth; +mod discovery; +mod input; +mod render; + +#[cfg(test)] +mod tests; + +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, + } +} + +pub struct LoginSelection { + pub ticket: AuthTicket, + pub session: SessionDescriptor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Sessions, + Username, + 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, + Pending(Receiver>), + PasswordChangePending(Receiver>), + Failed, +} + +enum AuthCompletion { + Login(Result), + PasswordChange(Result<(), Error>), +} + +#[derive(Debug, Clone, Copy)] +enum NoticeLevel { + Info, + Warning, +} + +#[derive(Debug)] +struct Notice { + message: String, + level: NoticeLevel, + expires: Instant, +} + +struct App { + config: Config, + auth: AuthService, + preview: bool, + sessions: Vec, + accounts: Vec, + account_statuses: Vec, + account_index: Option, + faillock_receiver: Option>>, + faillock_status: Option, + selected: usize, + focus: Focus, + username: String, + password: Zeroizing, + pending_password: Zeroizing>, + pending_new_password: Zeroizing, + password_change_mode: bool, + status: String, + auth_state: AuthState, + auth_failures: u32, + auth_blocked_until: Option, + show_help: bool, + power_confirmation: Option, + should_quit: bool, + ascii: bool, + no_color: bool, + background: Background, + notice: Option, + animation_frame: usize, + reduced_motion: bool, + filter: Option, + refresh_receiver: Option>, + refreshing: bool, + high_contrast: bool, + preferences: LastUsedPreferences, + preference_path: Option, +} + +impl App { + fn new( + config: Config, + auth: AuthService, + preview: bool, + sessions: Vec, + accounts: Vec, + background: Background, + ) -> Self { + let username = accounts + .first() + .map(|account| account.username.clone()) + .unwrap_or_default(); + let account_index = (!accounts.is_empty()).then_some(0); + let reduced_motion = config.visual.reduced_motion; + Self { + config, + auth, + preview, + sessions, + account_statuses: vec![unknown_account_status(); accounts.len()], + accounts, + account_index, + faillock_receiver: None, + faillock_status: None, + selected: 0, + focus: Focus::Username, + username, + password: Zeroizing::new(String::new()), + pending_password: Zeroizing::new(Vec::new()), + pending_new_password: Zeroizing::new(String::new()), + password_change_mode: false, + 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, + ascii: std::env::var_os("LATCHD_ASCII").is_some(), + no_color: std::env::var_os("NO_COLOR").is_some(), + background, + notice: None, + animation_frame: 0, + reduced_motion, + filter: None, + refresh_receiver: None, + refreshing: false, + high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(), + preferences: LastUsedPreferences::default(), + preference_path: None, + } + } + + 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); + } + if self + .notice + .as_ref() + .is_some_and(|notice| notice.expires <= now) + { + self.notice = None; + } + terminal + .draw(|frame| self.draw(frame)) + .map_err(|error| Error::Terminal(error.to_string()))?; + if let Some(completion) = self.poll_auth() { + if let Some(selection) = self.handle_auth_completion(completion)? { + return Ok(Some(selection)); + } + } + if self.should_quit { + return Ok(None); + } + if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? { + if let Event::Key(key) = event::read().io_context("read terminal event")? { + if key.kind == KeyEventKind::Press { + self.handle_key(key); + } + } + } + } + } +} + +/// Run the login screen and restore the terminal on every return path. +pub fn run( + config: Config, + auth: AuthService, + preview: bool, +) -> Result, Error> { + 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 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); + app.account_statuses = account_statuses; + 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(); + ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) +} diff --git a/crates/latchd-tui/src/app/auth.rs b/crates/latchd-tui/src/app/auth.rs new file mode 100644 index 0000000..13405f6 --- /dev/null +++ b/crates/latchd-tui/src/app/auth.rs @@ -0,0 +1,229 @@ +use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +use zeroize::Zeroizing; + +use latchd_core::{ + audit::{AuditAction, AuditEvent}, + error::Error, +}; + +use super::{App, AuthCompletion, AuthState, Focus, LoginSelection, NoticeLevel}; + +impl App { + pub(super) fn poll_auth(&mut self) -> Option { + match &self.auth_state { + AuthState::Pending(receiver) => match receiver.try_recv() { + Ok(result) => Some(AuthCompletion::Login(result)), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err( + Error::Worker("authentication task disconnected".into()), + ))), + }, + AuthState::PasswordChangePending(receiver) => match receiver.try_recv() { + Ok(result) => Some(AuthCompletion::PasswordChange(result)), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::PasswordChange(Err( + Error::Worker("password-change task disconnected".into()), + ))), + }, + AuthState::Idle | AuthState::Failed => None, + } + } + + pub(super) fn handle_auth_completion( + &mut self, + completion: AuthCompletion, + ) -> Result, Error> { + match completion { + AuthCompletion::Login(Ok(ticket)) => { + self.audit(AuditEvent::succeeded( + AuditAction::Authentication, + &self.username, + )); + self.auth_failures = 0; + self.auth_blocked_until = None; + self.pending_password.clear(); + let selected = self + .sessions + .get(self.selected) + .cloned() + .ok_or_else(|| Error::Session("no session selected".into()))?; + self.remember_selected_session(&selected); + Ok(Some(LoginSelection { + ticket, + session: selected, + })) + } + AuthCompletion::Login(Err(error)) => { + let audit = AuditEvent::failed(AuditAction::Authentication, &self.username, &error); + self.audit(audit); + if error.requires_password_change() { + self.auth_state = AuthState::Idle; + self.password_change_mode = true; + self.password.clear(); + self.auth_failures = 0; + self.auth_blocked_until = None; + self.focus = Focus::Password; + self.status = "PAM requires a new password; enter it and press Enter".into(); + self.notify("Password change required by PAM", NoticeLevel::Warning); + } else { + self.auth_state = AuthState::Failed; + self.pending_password.clear(); + 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(); + } + Ok(None) + } + AuthCompletion::PasswordChange(Ok(())) => { + self.audit(AuditEvent::succeeded( + AuditAction::PasswordChange, + &self.username, + )); + self.password = std::mem::replace( + &mut self.pending_new_password, + Zeroizing::new(String::new()), + ); + self.pending_password.clear(); + self.password_change_mode = false; + self.auth_state = AuthState::Idle; + self.status = "Password changed; authenticating…".into(); + self.notify("Password changed; signing in", NoticeLevel::Info); + self.begin_authentication(); + Ok(None) + } + AuthCompletion::PasswordChange(Err(error)) => { + self.audit(AuditEvent::failed( + AuditAction::PasswordChange, + &self.username, + &error, + )); + self.auth_state = AuthState::Failed; + self.password_change_mode = false; + self.pending_password.clear(); + self.pending_new_password.clear(); + self.password.clear(); + self.status = "PAM rejected the password change".into(); + self.notify("Password change failed", NoticeLevel::Warning); + Ok(None) + } + } + } + + pub(super) fn audit(&self, event: AuditEvent<'_>) { + tracing::info!( + target: "latchd.audit", + action = %event.action, + outcome = %event.outcome, + account = %event.account, + pam_reason = ?event.pam_reason, + "security event" + ); + } + + pub(super) fn begin_authentication(&mut self) { + if self.preview { + self.status = "Preview mode — authentication and session startup are disabled".into(); + return; + } + if self.password_change_mode { + self.begin_password_change(); + return; + } + if self.username.is_empty() || self.password.is_empty() { + 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(); + self.pending_password = Zeroizing::new(password.clone()); + let service = self.config.pam_service.clone(); + let auth = self.auth.clone(); + let (sender, receiver) = mpsc::sync_channel(0); + let task = thread::Builder::new() + .name("latchd-auth-attempt".into()) + .spawn(move || { + let result = auth.authenticate(username, password, service); + let _ = sender.send(result); + }); + match task { + Ok(_) => { + self.audit(AuditEvent::started( + AuditAction::Authentication, + &self.username, + )); + self.auth_state = AuthState::Pending(receiver); + self.status = "Authenticating…".into(); + self.password.clear(); + } + Err(error) => { + self.pending_password.clear(); + self.status = format!("Could not start authentication: {error}"); + } + } + } + + pub(super) fn begin_password_change(&mut self) { + if self.pending_password.is_empty() { + self.password_change_mode = false; + self.status = "Password-change context expired; authenticate again".into(); + return; + } + if self.password.is_empty() { + self.status = "A new password is required".into(); + return; + } + let username = self.username.clone(); + let current_password = self.pending_password.to_vec(); + let new_password = self.password.as_bytes().to_vec(); + self.pending_new_password = Zeroizing::new(self.password.as_str().to_owned()); + let service = self.config.pam_service.clone(); + let auth = self.auth.clone(); + let (sender, receiver) = mpsc::sync_channel(0); + let task = thread::Builder::new() + .name("latchd-password-change".into()) + .spawn(move || { + let result = + auth.change_password(username, current_password, new_password, service); + let _ = sender.send(result); + }); + match task { + Ok(_) => { + self.audit(AuditEvent::started( + AuditAction::PasswordChange, + &self.username, + )); + self.auth_state = AuthState::PasswordChangePending(receiver); + self.status = "Changing password through PAM…".into(); + self.password.clear(); + } + Err(error) => { + self.pending_new_password.clear(); + self.status = format!("Could not start password change: {error}"); + } + } + } +} + +pub(super) fn auth_backoff(failures: u32) -> Duration { + let shift = failures.saturating_sub(1).min(4); + Duration::from_secs(1u64 << shift) +} diff --git a/crates/latchd-tui/src/app/discovery.rs b/crates/latchd-tui/src/app/discovery.rs new file mode 100644 index 0000000..f5a2ac0 --- /dev/null +++ b/crates/latchd-tui/src/app/discovery.rs @@ -0,0 +1,172 @@ +use std::sync::mpsc; +use std::thread; + +use latchd_core::{accounts, preferences, session, session::SessionDescriptor}; + +use super::{App, NoticeLevel, unknown_account_status}; + +impl App { + pub(super) fn apply_preferred_session(&mut self) { + if let Some(index) = self + .preferences + .preferred_index(&self.username, &self.sessions) + { + self.selected = index; + } + } + + pub(super) 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"); + } + } + + pub(super) fn poll_faillock(&mut self) { + let Some(receiver) = &self.faillock_receiver else { + return; + }; + match receiver.try_recv() { + Ok(Ok(status)) => { + self.faillock_status = Some(status); + self.faillock_receiver = None; + if status.available { + if status.locked_out { + self.notify( + "Account has reached the advisory faillock threshold", + NoticeLevel::Warning, + ); + } else if status.failures > 0 { + self.notify( + &format!("{} failed attempt(s) recorded", status.failures), + NoticeLevel::Info, + ); + } + } + } + Ok(Err(error)) => { + tracing::debug!(%error, "faillock query failed"); + self.faillock_receiver = None; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None, + } + } + + pub(super) 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, + ); + } + } + } + + pub(super) 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}"); + } + } + } + + pub(super) 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(); + } + } + } + + pub(super) fn start_faillock_query(&mut self) { + let username = self.username.clone(); + if username.is_empty() { + self.faillock_receiver = None; + self.faillock_status = None; + return; + } + let config = self.config.accounts.clone(); + let (sender, receiver) = mpsc::sync_channel(1); + self.faillock_receiver = Some(receiver); + let task = thread::Builder::new() + .name("latchd-faillock".into()) + .spawn(move || { + let result = accounts::faillock_status(&username, &config); + let _ = sender.send(result); + }); + if let Err(error) = task { + tracing::debug!(%error, "could not start faillock query"); + self.faillock_receiver = None; + } + } +} diff --git a/crates/latchd-tui/src/app/input.rs b/crates/latchd-tui/src/app/input.rs new file mode 100644 index 0000000..2e6aa62 --- /dev/null +++ b/crates/latchd-tui/src/app/input.rs @@ -0,0 +1,449 @@ +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{App, AuthState, FilterState, FilterTarget, Focus, MAX_INPUT, Notice, NoticeLevel}; + +impl App { + pub(super) fn notify(&mut self, message: &str, level: NoticeLevel) { + if self.config.visual.notifications { + self.notice = Some(Notice { + message: message.to_owned(), + level, + expires: Instant::now() + Duration::from_secs(4), + }); + } + } + + pub(super) 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() + } + }; + } + + pub(super) 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(); + } + _ => {} + } + } + + pub(super) 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 + ); + } + + pub(super) 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(); + } + } + } + } + + pub(super) 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() + } + + pub(super) 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() + } + + pub(super) fn handle_key(&mut self, key: KeyEvent) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.should_quit = true; + return; + } + if matches!( + self.auth_state, + AuthState::Pending(_) | AuthState::PasswordChangePending(_) + ) { + if key.code == KeyCode::Esc { + self.status = "Authentication is still in progress".into(); + } + return; + } + if self.show_help { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) { + self.show_help = false; + } + return; + } + if self.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') => { + self.next_account(); + return; + } + KeyCode::Char('p') => { + self.previous_account(); + return; + } + KeyCode::Char('b') => { + let enabled = self.background.toggle_enabled(); + self.notify( + if enabled { + "Ambient background enabled" + } else { + "Ambient background disabled" + }, + NoticeLevel::Info, + ); + return; + } + KeyCode::Char('m') => { + let reduced = self.background.toggle_motion(); + self.reduced_motion = reduced; + self.notify( + if reduced { + "Reduced motion enabled" + } else { + "Reduced motion disabled" + }, + NoticeLevel::Info, + ); + return; + } + KeyCode::Char('r') => { + self.start_refresh(); + return; + } + _ => {} + } + } + if let Some(index) = self.power_confirmation { + if key.code == KeyCode::Enter { + self.run_power_action(index); + } else if key.code == KeyCode::Esc { + self.power_confirmation = None; + self.status = "Power action cancelled".into(); + } + return; + } + match key.code { + KeyCode::Char('?') => self.show_help = true, + KeyCode::Char('/') => self.begin_filter(), + KeyCode::Esc => { + self.password.clear(); + self.status = "Login cancelled".into(); + } + KeyCode::Tab | KeyCode::Down => self.next_focus(), + KeyCode::BackTab | KeyCode::Up => self.previous_focus(), + KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(), + KeyCode::Right if self.focus == Focus::Sessions => self.next_session(), + KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(), + KeyCode::F(number) => self.confirm_power_action(number), + KeyCode::Backspace => self.backspace(), + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.insert(character) + } + _ => {} + } + } + + pub(super) fn confirm_power_action(&mut self, number: u8) { + if self.preview { + self.status = "Preview mode — power actions are disabled".into(); + return; + } + let key = format!("F{number}"); + if let Some((index, action)) = self + .config + .power_controls + .entries + .iter() + .enumerate() + .find(|(_, action)| action.key.eq_ignore_ascii_case(&key)) + { + self.power_confirmation = Some(index); + self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint); + } + } + + pub(super) fn run_power_action(&mut self, index: usize) { + if self.preview { + self.power_confirmation = None; + self.status = "Preview mode — power actions are disabled".into(); + return; + } + let Some(action) = self.config.power_controls.entries.get(index).cloned() else { + self.power_confirmation = None; + self.status = "Power action is no longer available".into(); + return; + }; + if !action.program.is_absolute() { + self.power_confirmation = None; + self.status = "Power action must use an absolute program path".into(); + return; + } + match std::process::Command::new(&action.program) + .args(&action.args) + .spawn() + { + Ok(_) => { + self.power_confirmation = None; + self.status = format!("Started {}", action.hint); + } + Err(error) => { + self.power_confirmation = None; + self.status = format!("Could not start {}: {error}", action.hint); + } + } + } + + pub(super) fn insert(&mut self, character: char) { + if self.focus == Focus::Username { + self.account_index = None; + } + let target = match self.focus { + Focus::Username => &mut self.username, + Focus::Password => &mut self.password, + Focus::Sessions => return, + }; + if target.chars().count() < MAX_INPUT { + target.push(character); + } + } + + pub(super) fn backspace(&mut self) { + if self.focus == Focus::Username { + self.account_index = None; + } + let target = match self.focus { + Focus::Username => &mut self.username, + Focus::Password => &mut self.password, + Focus::Sessions => return, + }; + target.pop(); + } + + pub(super) fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::Sessions => Focus::Username, + Focus::Username => Focus::Password, + Focus::Password => Focus::Sessions, + }; + self.update_hint(); + } + + pub(super) fn previous_focus(&mut self) { + self.focus = match self.focus { + Focus::Sessions => Focus::Password, + Focus::Username => Focus::Sessions, + Focus::Password => Focus::Username, + }; + self.update_hint(); + } + + pub(super) fn update_hint(&mut self) { + self.status = match self.focus { + Focus::Sessions => "Choose a session with ←/→".into(), + Focus::Username => "Enter your username".into(), + Focus::Password if self.password_change_mode => { + "Enter a new password and press Enter".into() + } + Focus::Password => "Enter your password and press Enter".into(), + }; + } + + pub(super) fn next_session(&mut self) { + 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; + } + } + + pub(super) fn previous_session(&mut self) { + 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; + } + } + + pub(super) fn next_account(&mut self) { + self.select_account(1); + } + + pub(super) fn previous_account(&mut self) { + self.select_account(-1); + } + + pub(super) fn select_account(&mut self, delta: isize) { + 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 + .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); + } + + pub(super) 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 = username; + self.password.clear(); + self.pending_password.clear(); + self.pending_new_password.clear(); + self.password_change_mode = false; + 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.apply_preferred_session(); + self.start_faillock_query(); + self.notify( + &format!("Selected account {}", self.username), + NoticeLevel::Info, + ); + } +} + +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) + } +} diff --git a/crates/latchd-tui/src/app/render.rs b/crates/latchd-tui/src/app/render.rs new file mode 100644 index 0000000..48929d4 --- /dev/null +++ b/crates/latchd-tui/src/app/render.rs @@ -0,0 +1,398 @@ +use std::time::Instant; + +use ratatui::{ + prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, +}; +use unicode_width::UnicodeWidthStr; + +use latchd_core::accounts::AccountState; + +use super::{App, AuthState, FilterTarget, Focus, MIN_HEIGHT, MIN_WIDTH, NoticeLevel}; + +impl App { + pub(super) fn draw(&mut self, frame: &mut ratatui::Frame) { + let area = frame.area(); + self.background.draw(frame, area, self.ascii, self.no_color); + if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(format!( + "terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}" + )) + .alignment(ratatui::prelude::Alignment::Center), + area, + ); + return; + } + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(8), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(area); + let title = Paragraph::new(Line::from(vec![ + Span::styled("latchd", self.style_accent()), + Span::raw(" secure terminal login"), + ])) + .alignment(ratatui::prelude::Alignment::Center); + frame.render_widget(title, vertical[0]); + + let body = if area.width < 80 { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Length(8)]) + .split(vertical[1]) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .split(vertical[1]) + }; + let session_area = body[0]; + let form_area = if body.len() == 2 { + body[1] + } else { + vertical[1] + }; + let session_indices = self.matching_session_indices(); + let session_items: Vec = session_indices + .iter() + .filter_map(|&index| self.sessions.get(index).map(|session| (index, session))) + .map(|(index, session)| { + let marker = if index == self.selected { + if self.ascii { ">" } else { "▶" } + } else { + " " + }; + ListItem::new(format!( + "{marker} {}", + truncate_cells( + &session.title, + self.config.environment_switcher.max_display_length + ) + )) + .style(if index == self.selected { + self.style_selected() + } else { + self.style_muted() + }) + }) + .collect(); + let 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() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(1), + ]) + .split(form_area); + let account_title = self.account_title(); + let username = Paragraph::new(self.username.as_str()) + .block(self.block(&account_title, self.focus == Focus::Username)); + let password_marker = if self.ascii { "*" } else { "•" }; + let password = Paragraph::new(password_marker.repeat(self.password.chars().count())).block( + self.block( + if self.password_change_mode { + "New password" + } else { + "Password" + }, + self.focus == Focus::Password, + ), + ); + frame.render_widget(username, fields[0]); + frame.render_widget(password, fields[1]); + let status = self.status_line(); + frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]); + frame.render_widget(Paragraph::new(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); + } + } + + pub(super) 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 if self.password_change_mode => { + "Tab focus Enter change password Ctrl-M motion ? 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 + } + + pub(super) 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, + ); + } + + pub(super) 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\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 / change password\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help", + ) + .block(self.block("Help", false)) + .wrap(Wrap { trim: true }), + popup, + ); + } + + pub(super) fn block(&self, title: &str, focused: bool) -> Block<'static> { + let mut block = Block::default() + .borders(Borders::ALL) + .title(title.to_owned()) + .border_style(if focused { + self.style_accent() + } else { + self.style_muted() + }); + if !self.no_color { + block = block.style(Style::default().bg(ratatui::prelude::Color::Black)); + } + block + } + + pub(super) fn account_title(&self) -> String { + self.account_index + .map(|index| format!("Username {}/{}", index + 1, self.accounts.len())) + .unwrap_or_else(|| "Username".into()) + } + + pub(super) fn status_line(&self) -> String { + let mut status = self.status.clone(); + if matches!( + self.auth_state, + AuthState::Pending(_) | AuthState::PasswordChangePending(_) + ) { + let spinner = if self.config.visual.animations && !self.reduced_motion { + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + .get(self.animation_frame % 10) + .copied() + .unwrap_or("⠋") + } else if self.ascii { + "|" + } else { + "…" + }; + status = if matches!(self.auth_state, AuthState::PasswordChangePending(_)) { + format!("{spinner} Changing password through PAM…") + } else { + 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 + .push_str(" • advisory faillock threshold reached; PAM remains authoritative"); + } else if lockout.failures > 0 { + let remaining = lockout + .remaining + .map_or_else(|| "unknown".into(), |value| value.to_string()); + status.push_str(&format!( + " • {} failed attempt(s), {remaining} remaining", + lockout.failures + )); + } + } + if let Some(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 => { + if self.ascii { + "[i]" + } else { + "ℹ" + } + } + NoticeLevel::Warning => { + if self.ascii { + "[!]" + } else { + "⚠" + } + } + }; + status.push_str(&format!(" {marker} {}", notice.message)); + } + if self.refreshing { + status.push_str(" • refreshing discovery"); + } + status + } + + pub(super) 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) + .add_modifier(ratatui::prelude::Modifier::BOLD) + } + } + pub(super) 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) + .add_modifier(ratatui::prelude::Modifier::BOLD) + } + } + pub(super) fn style_muted(&self) -> Style { + Style::default().add_modifier(ratatui::prelude::Modifier::DIM) + } +} + +fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} + +pub(super) fn truncate_cells(value: &str, width: usize) -> String { + if UnicodeWidthStr::width(value) <= width { + return value.into(); + } + let mut output = String::new(); + let mut used = 0; + for character in value.chars() { + let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1); + if used + character_width + 1 > width { + break; + } + output.push(character); + used += character_width; + } + output.push('…'); + output +} diff --git a/crates/latchd-tui/src/app/tests.rs b/crates/latchd-tui/src/app/tests.rs new file mode 100644 index 0000000..30ce6f1 --- /dev/null +++ b/crates/latchd-tui/src/app/tests.rs @@ -0,0 +1,345 @@ +use std::sync::mpsc; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; +use zeroize::Zeroizing; + +use super::{auth::auth_backoff, render::truncate_cells, *}; + +#[test] +fn truncation_respects_terminal_cells() { + assert_eq!(truncate_cells("界界界", 5), "界界…"); +} + +#[test] +fn renders_the_login_screen_at_the_floor() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + vec![SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }], + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); + terminal + .draw(|frame| app.draw(frame)) + .expect("frame should render"); + let buffer = terminal.backend().buffer(); + let rendered = buffer + .content() + .chunks(buffer.area.width as usize) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n"); + assert!(rendered.contains("latchd")); + assert!(rendered.contains("Username")); + 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"); + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + let backend = TestBackend::new(59, 15); + let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); + terminal + .draw(|frame| app.draw(frame)) + .expect("frame should render"); + let buffer = terminal.backend().buffer(); + let rendered = buffer + .content() + .chunks(buffer.area.width as usize) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n"); + assert!(rendered.contains("terminal too small")); +} + +#[test] +fn switching_accounts_clears_password_and_auth_state() { + let auth = AuthService::new().expect("PAM worker should start"); + let accounts = vec![ + Identity { + username: "alice".into(), + uid: 1000, + gid: 1000, + home: "/home/alice".into(), + shell: "/bin/sh".into(), + }, + Identity { + username: "bob".into(), + uid: 1001, + gid: 1001, + home: "/home/bob".into(), + shell: "/bin/sh".into(), + }, + ]; + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + accounts, + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.password = Zeroizing::new("secret".into()); + app.next_account(); + assert_eq!(app.username, "bob"); + assert!(app.password.is_empty()); + assert!(matches!(app.auth_state, AuthState::Idle)); +} + +#[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 pam_password_change_requirement_enters_explicit_new_password_mode() { + 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.pending_password = Zeroizing::new(b"old-secret".to_vec()); + app.handle_auth_completion(AuthCompletion::Login(Err(Error::Pam { + operation: "pam_acct_mgmt", + status: 12, + message: "module detail must stay out of the UI".into(), + }))) + .expect("policy transition should not fail"); + assert!(app.password_change_mode); + assert_eq!(app.focus, Focus::Password); + assert!(app.status.contains("new password")); +} + +#[test] +fn password_change_mode_has_a_distinct_prompt() { + 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"), + ); + app.password_change_mode = true; + app.focus = Focus::Password; + app.update_hint(); + assert!(app.status.contains("new password")); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("frame"); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + 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] +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/crates/latchd-tui/src/background.rs b/crates/latchd-tui/src/background.rs index 19a254d..0f60981 100644 --- a/crates/latchd-tui/src/background.rs +++ b/crates/latchd-tui/src/background.rs @@ -1,4 +1,4 @@ -//! Low-cost terminal wallpaper and procedural ambient animation. +//! Draw terminal wallpaper and a small ambient animation. use std::{ fs, @@ -189,7 +189,7 @@ struct Automata { impl Automata { fn resize(&mut self, width: usize, height: usize) { - // Cap the state size. Some terminal emulators report extreme dimensions. + // Keep the state bounded when a terminal reports an extreme size. let width = width.min(MAX_ANIMATION_WIDTH); let height = height.min(MAX_ANIMATION_HEIGHT); if self.width == width && self.height == height { diff --git a/crates/latchd-tui/src/lib.rs b/crates/latchd-tui/src/lib.rs index d7b7ed8..9034b99 100644 --- a/crates/latchd-tui/src/lib.rs +++ b/crates/latchd-tui/src/lib.rs @@ -1,1813 +1,6 @@ -//! Ratatui login screen and deterministic state machine. - -use std::{ - sync::mpsc::{self, Receiver}, - thread, - time::{Duration, Instant}, -}; - -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::{ - DefaultTerminal, - prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, -}; -use unicode_width::UnicodeWidthStr; -use zeroize::Zeroizing; - -use latchd_auth::{AuthService, AuthTicket}; -use latchd_core::{ - accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity}, - audit::{AuditAction, AuditEvent}, - config::Config, - error::{Error, ResultExt}, - preferences::{self, LastUsedPreferences}, - session::{self, SessionDescriptor}, -}; +//! Terminal login screen and session selection. +mod app; mod background; -use background::Background; -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, - } -} - -pub struct LoginSelection { - pub ticket: AuthTicket, - pub session: SessionDescriptor, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Focus { - Sessions, - Username, - 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, - Pending(Receiver>), - PasswordChangePending(Receiver>), - Failed, -} - -enum AuthCompletion { - Login(Result), - PasswordChange(Result<(), Error>), -} - -#[derive(Debug, Clone, Copy)] -enum NoticeLevel { - Info, - Warning, -} - -#[derive(Debug)] -struct Notice { - message: String, - level: NoticeLevel, - expires: Instant, -} - -struct App { - config: Config, - auth: AuthService, - preview: bool, - sessions: Vec, - accounts: Vec, - account_statuses: Vec, - account_index: Option, - faillock_receiver: Option>>, - faillock_status: Option, - selected: usize, - focus: Focus, - username: String, - password: Zeroizing, - pending_password: Zeroizing>, - pending_new_password: Zeroizing, - password_change_mode: bool, - status: String, - auth_state: AuthState, - auth_failures: u32, - auth_blocked_until: Option, - show_help: bool, - power_confirmation: Option, - should_quit: bool, - ascii: bool, - no_color: bool, - background: Background, - notice: Option, - animation_frame: usize, - reduced_motion: bool, - filter: Option, - refresh_receiver: Option>, - refreshing: bool, - high_contrast: bool, - preferences: LastUsedPreferences, - preference_path: Option, -} - -impl App { - fn new( - config: Config, - auth: AuthService, - preview: bool, - sessions: Vec, - accounts: Vec, - background: Background, - ) -> Self { - let username = accounts - .first() - .map(|account| account.username.clone()) - .unwrap_or_default(); - let account_index = (!accounts.is_empty()).then_some(0); - let reduced_motion = config.visual.reduced_motion; - Self { - config, - auth, - preview, - sessions, - account_statuses: vec![unknown_account_status(); accounts.len()], - accounts, - account_index, - faillock_receiver: None, - faillock_status: None, - selected: 0, - focus: Focus::Username, - username, - password: Zeroizing::new(String::new()), - pending_password: Zeroizing::new(Vec::new()), - pending_new_password: Zeroizing::new(String::new()), - password_change_mode: false, - 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, - ascii: std::env::var_os("LATCHD_ASCII").is_some(), - no_color: std::env::var_os("NO_COLOR").is_some(), - background, - notice: None, - animation_frame: 0, - reduced_motion, - filter: None, - refresh_receiver: None, - refreshing: false, - high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(), - preferences: LastUsedPreferences::default(), - preference_path: None, - } - } - - 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); - } - if self - .notice - .as_ref() - .is_some_and(|notice| notice.expires <= now) - { - self.notice = None; - } - terminal - .draw(|frame| self.draw(frame)) - .map_err(|error| Error::Terminal(error.to_string()))?; - if let Some(completion) = self.poll_auth() { - if let Some(selection) = self.handle_auth_completion(completion)? { - return Ok(Some(selection)); - } - } - if self.should_quit { - return Ok(None); - } - if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? { - if let Event::Key(key) = event::read().io_context("read terminal event")? { - if key.kind == KeyEventKind::Press { - self.handle_key(key); - } - } - } - } - } - - fn poll_auth(&mut self) -> Option { - match &self.auth_state { - AuthState::Pending(receiver) => match receiver.try_recv() { - Ok(result) => Some(AuthCompletion::Login(result)), - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err( - Error::Worker("authentication task disconnected".into()), - ))), - }, - AuthState::PasswordChangePending(receiver) => match receiver.try_recv() { - Ok(result) => Some(AuthCompletion::PasswordChange(result)), - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::PasswordChange(Err( - Error::Worker("password-change task disconnected".into()), - ))), - }, - AuthState::Idle | AuthState::Failed => None, - } - } - - fn handle_auth_completion( - &mut self, - completion: AuthCompletion, - ) -> Result, Error> { - match completion { - AuthCompletion::Login(Ok(ticket)) => { - self.audit(AuditEvent::succeeded( - AuditAction::Authentication, - &self.username, - )); - self.auth_failures = 0; - self.auth_blocked_until = None; - self.pending_password.clear(); - let selected = self - .sessions - .get(self.selected) - .cloned() - .ok_or_else(|| Error::Session("no session selected".into()))?; - self.remember_selected_session(&selected); - Ok(Some(LoginSelection { - ticket, - session: selected, - })) - } - AuthCompletion::Login(Err(error)) => { - let audit = AuditEvent::failed(AuditAction::Authentication, &self.username, &error); - self.audit(audit); - if error.requires_password_change() { - self.auth_state = AuthState::Idle; - self.password_change_mode = true; - self.password.clear(); - self.auth_failures = 0; - self.auth_blocked_until = None; - self.focus = Focus::Password; - self.status = "PAM requires a new password; enter it and press Enter".into(); - self.notify("Password change required by PAM", NoticeLevel::Warning); - } else { - self.auth_state = AuthState::Failed; - self.pending_password.clear(); - 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(); - } - Ok(None) - } - AuthCompletion::PasswordChange(Ok(())) => { - self.audit(AuditEvent::succeeded( - AuditAction::PasswordChange, - &self.username, - )); - self.password = std::mem::replace( - &mut self.pending_new_password, - Zeroizing::new(String::new()), - ); - self.pending_password.clear(); - self.password_change_mode = false; - self.auth_state = AuthState::Idle; - self.status = "Password changed; authenticating…".into(); - self.notify("Password changed; signing in", NoticeLevel::Info); - self.begin_authentication(); - Ok(None) - } - AuthCompletion::PasswordChange(Err(error)) => { - self.audit(AuditEvent::failed( - AuditAction::PasswordChange, - &self.username, - &error, - )); - self.auth_state = AuthState::Failed; - self.password_change_mode = false; - self.pending_password.clear(); - self.pending_new_password.clear(); - self.password.clear(); - self.status = "PAM rejected the password change".into(); - self.notify("Password change failed", NoticeLevel::Warning); - Ok(None) - } - } - } - - fn audit(&self, event: AuditEvent<'_>) { - tracing::info!( - target: "latchd.audit", - action = %event.action, - outcome = %event.outcome, - account = %event.account, - pam_reason = ?event.pam_reason, - "security event" - ); - } - - 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) { - let Some(receiver) = &self.faillock_receiver else { - return; - }; - match receiver.try_recv() { - Ok(Ok(status)) => { - self.faillock_status = Some(status); - self.faillock_receiver = None; - if status.available { - if status.locked_out { - self.notify( - "Account has reached the advisory faillock threshold", - NoticeLevel::Warning, - ); - } else if status.failures > 0 { - self.notify( - &format!("{} failed attempt(s) recorded", status.failures), - NoticeLevel::Info, - ); - } - } - } - Ok(Err(error)) => { - tracing::debug!(%error, "faillock query failed"); - self.faillock_receiver = None; - } - Err(mpsc::TryRecvError::Empty) => {} - Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None, - } - } - - fn 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() { - self.faillock_receiver = None; - self.faillock_status = None; - return; - } - let config = self.config.accounts.clone(); - let (sender, receiver) = mpsc::sync_channel(1); - self.faillock_receiver = Some(receiver); - let task = thread::Builder::new() - .name("latchd-faillock".into()) - .spawn(move || { - let result = accounts::faillock_status(&username, &config); - let _ = sender.send(result); - }); - if let Err(error) = task { - tracing::debug!(%error, "could not start faillock query"); - self.faillock_receiver = None; - } - } - - fn notify(&mut self, message: &str, level: NoticeLevel) { - if self.config.visual.notifications { - self.notice = Some(Notice { - message: message.to_owned(), - level, - expires: Instant::now() + Duration::from_secs(4), - }); - } - } - - fn 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; - return; - } - if matches!( - self.auth_state, - AuthState::Pending(_) | AuthState::PasswordChangePending(_) - ) { - if key.code == KeyCode::Esc { - self.status = "Authentication is still in progress".into(); - } - return; - } - if self.show_help { - if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) { - self.show_help = false; - } - return; - } - if self.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') => { - self.next_account(); - return; - } - KeyCode::Char('p') => { - self.previous_account(); - return; - } - KeyCode::Char('b') => { - let enabled = self.background.toggle_enabled(); - self.notify( - if enabled { - "Ambient background enabled" - } else { - "Ambient background disabled" - }, - NoticeLevel::Info, - ); - return; - } - KeyCode::Char('m') => { - let reduced = self.background.toggle_motion(); - self.reduced_motion = reduced; - self.notify( - if reduced { - "Reduced motion enabled" - } else { - "Reduced motion disabled" - }, - NoticeLevel::Info, - ); - return; - } - KeyCode::Char('r') => { - self.start_refresh(); - return; - } - _ => {} - } - } - if let Some(index) = self.power_confirmation { - if key.code == KeyCode::Enter { - self.run_power_action(index); - } else if key.code == KeyCode::Esc { - self.power_confirmation = None; - self.status = "Power action cancelled".into(); - } - return; - } - match key.code { - KeyCode::Char('?') => self.show_help = true, - KeyCode::Char('/') => self.begin_filter(), - KeyCode::Esc => { - self.password.clear(); - self.status = "Login cancelled".into(); - } - KeyCode::Tab | KeyCode::Down => self.next_focus(), - KeyCode::BackTab | KeyCode::Up => self.previous_focus(), - KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(), - KeyCode::Right if self.focus == Focus::Sessions => self.next_session(), - KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(), - KeyCode::F(number) => self.confirm_power_action(number), - KeyCode::Backspace => self.backspace(), - KeyCode::Char(character) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.insert(character) - } - _ => {} - } - } - - fn confirm_power_action(&mut self, number: u8) { - if self.preview { - self.status = "Preview mode — power actions are disabled".into(); - return; - } - let key = format!("F{number}"); - if let Some((index, action)) = self - .config - .power_controls - .entries - .iter() - .enumerate() - .find(|(_, action)| action.key.eq_ignore_ascii_case(&key)) - { - self.power_confirmation = Some(index); - self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint); - } - } - - fn run_power_action(&mut self, index: usize) { - if self.preview { - self.power_confirmation = None; - self.status = "Preview mode — power actions are disabled".into(); - return; - } - let Some(action) = self.config.power_controls.entries.get(index).cloned() else { - self.power_confirmation = None; - self.status = "Power action is no longer available".into(); - return; - }; - if !action.program.is_absolute() { - self.power_confirmation = None; - self.status = "Power action must use an absolute program path".into(); - return; - } - match std::process::Command::new(&action.program) - .args(&action.args) - .spawn() - { - Ok(_) => { - self.power_confirmation = None; - self.status = format!("Started {}", action.hint); - } - Err(error) => { - self.power_confirmation = None; - self.status = format!("Could not start {}: {error}", action.hint); - } - } - } - - fn begin_authentication(&mut self) { - if self.preview { - self.status = "Preview mode — authentication and session startup are disabled".into(); - return; - } - if self.password_change_mode { - self.begin_password_change(); - return; - } - if self.username.is_empty() || self.password.is_empty() { - 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(); - self.pending_password = Zeroizing::new(password.clone()); - let service = self.config.pam_service.clone(); - let auth = self.auth.clone(); - let (sender, receiver) = mpsc::sync_channel(0); - let task = thread::Builder::new() - .name("latchd-auth-attempt".into()) - .spawn(move || { - let result = auth.authenticate(username, password, service); - let _ = sender.send(result); - }); - match task { - Ok(_) => { - self.audit(AuditEvent::started( - AuditAction::Authentication, - &self.username, - )); - self.auth_state = AuthState::Pending(receiver); - self.status = "Authenticating…".into(); - self.password.clear(); - } - Err(error) => { - self.pending_password.clear(); - self.status = format!("Could not start authentication: {error}"); - } - } - } - - fn begin_password_change(&mut self) { - if self.pending_password.is_empty() { - self.password_change_mode = false; - self.status = "Password-change context expired; authenticate again".into(); - return; - } - if self.password.is_empty() { - self.status = "A new password is required".into(); - return; - } - let username = self.username.clone(); - let current_password = self.pending_password.to_vec(); - let new_password = self.password.as_bytes().to_vec(); - self.pending_new_password = Zeroizing::new(self.password.as_str().to_owned()); - let service = self.config.pam_service.clone(); - let auth = self.auth.clone(); - let (sender, receiver) = mpsc::sync_channel(0); - let task = thread::Builder::new() - .name("latchd-password-change".into()) - .spawn(move || { - let result = - auth.change_password(username, current_password, new_password, service); - let _ = sender.send(result); - }); - match task { - Ok(_) => { - self.audit(AuditEvent::started( - AuditAction::PasswordChange, - &self.username, - )); - self.auth_state = AuthState::PasswordChangePending(receiver); - self.status = "Changing password through PAM…".into(); - self.password.clear(); - } - Err(error) => { - self.pending_new_password.clear(); - self.status = format!("Could not start password change: {error}"); - } - } - } - - fn insert(&mut self, character: char) { - if self.focus == Focus::Username { - self.account_index = None; - } - let target = match self.focus { - Focus::Username => &mut self.username, - Focus::Password => &mut self.password, - Focus::Sessions => return, - }; - if target.chars().count() < MAX_INPUT { - target.push(character); - } - } - - fn backspace(&mut self) { - if self.focus == Focus::Username { - self.account_index = None; - } - let target = match self.focus { - Focus::Username => &mut self.username, - Focus::Password => &mut self.password, - Focus::Sessions => return, - }; - target.pop(); - } - - fn next_focus(&mut self) { - self.focus = match self.focus { - Focus::Sessions => Focus::Username, - Focus::Username => Focus::Password, - Focus::Password => Focus::Sessions, - }; - self.update_hint(); - } - - fn previous_focus(&mut self) { - self.focus = match self.focus { - Focus::Sessions => Focus::Password, - Focus::Username => Focus::Sessions, - Focus::Password => Focus::Username, - }; - self.update_hint(); - } - - fn update_hint(&mut self) { - self.status = match self.focus { - Focus::Sessions => "Choose a session with ←/→".into(), - Focus::Username => "Enter your username".into(), - Focus::Password if self.password_change_mode => { - "Enter a new password and press Enter".into() - } - Focus::Password => "Enter your password and press Enter".into(), - }; - } - - fn next_session(&mut self) { - 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) { - 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; - } - } - - fn next_account(&mut self) { - self.select_account(1); - } - - fn previous_account(&mut self) { - self.select_account(-1); - } - - fn select_account(&mut self, delta: isize) { - 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 - .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 = username; - self.password.clear(); - self.pending_password.clear(); - self.pending_new_password.clear(); - self.password_change_mode = false; - 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.apply_preferred_session(); - self.start_faillock_query(); - self.notify( - &format!("Selected account {}", self.username), - NoticeLevel::Info, - ); - } - - fn draw(&mut self, frame: &mut ratatui::Frame) { - let area = frame.area(); - self.background.draw(frame, area, self.ascii, self.no_color); - if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { - frame.render_widget(Clear, area); - frame.render_widget( - Paragraph::new(format!( - "terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}" - )) - .alignment(ratatui::prelude::Alignment::Center), - area, - ); - return; - } - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(2), - Constraint::Min(8), - Constraint::Length(2), - Constraint::Length(1), - ]) - .split(area); - let title = Paragraph::new(Line::from(vec![ - Span::styled("latchd", self.style_accent()), - Span::raw(" secure terminal login"), - ])) - .alignment(ratatui::prelude::Alignment::Center); - frame.render_widget(title, vertical[0]); - - let body = if area.width < 80 { - Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(5), Constraint::Length(8)]) - .split(vertical[1]) - } else { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) - .split(vertical[1]) - }; - let session_area = body[0]; - let form_area = if body.len() == 2 { - body[1] - } else { - vertical[1] - }; - let session_indices = self.matching_session_indices(); - let session_items: Vec = session_indices - .iter() - .filter_map(|&index| self.sessions.get(index).map(|session| (index, session))) - .map(|(index, session)| { - let marker = if index == self.selected { - if self.ascii { ">" } else { "▶" } - } else { - " " - }; - ListItem::new(format!( - "{marker} {}", - truncate_cells( - &session.title, - self.config.environment_switcher.max_display_length - ) - )) - .style(if index == self.selected { - self.style_selected() - } else { - self.style_muted() - }) - }) - .collect(); - let 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() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(1), - ]) - .split(form_area); - let account_title = self.account_title(); - let username = Paragraph::new(self.username.as_str()) - .block(self.block(&account_title, self.focus == Focus::Username)); - let password_marker = if self.ascii { "*" } else { "•" }; - let password = Paragraph::new(password_marker.repeat(self.password.chars().count())).block( - self.block( - if self.password_change_mode { - "New password" - } else { - "Password" - }, - self.focus == Focus::Password, - ), - ); - frame.render_widget(username, fields[0]); - frame.render_widget(password, fields[1]); - let status = self.status_line(); - frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]); - frame.render_widget(Paragraph::new(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 if self.password_change_mode => { - "Tab focus Enter change password Ctrl-M motion ? 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\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 / change password\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> { - let mut block = Block::default() - .borders(Borders::ALL) - .title(title.to_owned()) - .border_style(if focused { - self.style_accent() - } else { - self.style_muted() - }); - if !self.no_color { - block = block.style(Style::default().bg(ratatui::prelude::Color::Black)); - } - block - } - - fn account_title(&self) -> String { - self.account_index - .map(|index| format!("Username {}/{}", index + 1, self.accounts.len())) - .unwrap_or_else(|| "Username".into()) - } - - fn status_line(&self) -> String { - let mut status = self.status.clone(); - if matches!( - self.auth_state, - AuthState::Pending(_) | AuthState::PasswordChangePending(_) - ) { - let spinner = if self.config.visual.animations && !self.reduced_motion { - ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - .get(self.animation_frame % 10) - .copied() - .unwrap_or("⠋") - } else if self.ascii { - "|" - } else { - "…" - }; - status = if matches!(self.auth_state, AuthState::PasswordChangePending(_)) { - format!("{spinner} Changing password through PAM…") - } else { - 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 - .push_str(" • advisory faillock threshold reached; PAM remains authoritative"); - } else if lockout.failures > 0 { - let remaining = lockout - .remaining - .map_or_else(|| "unknown".into(), |value| value.to_string()); - status.push_str(&format!( - " • {} failed attempt(s), {remaining} remaining", - lockout.failures - )); - } - } - if let Some(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 => { - if self.ascii { - "[i]" - } else { - "ℹ" - } - } - NoticeLevel::Warning => { - if self.ascii { - "[!]" - } else { - "⚠" - } - } - }; - 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) - .add_modifier(ratatui::prelude::Modifier::BOLD) - } - } - 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) - .add_modifier(ratatui::prelude::Modifier::BOLD) - } - } - fn style_muted(&self) -> Style { - Style::default().add_modifier(ratatui::prelude::Modifier::DIM) - } -} - -/// Runs the login UI and restores the terminal on every return path. -pub fn run( - config: Config, - auth: AuthService, - preview: bool, -) -> Result, Error> { - 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 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); - app.account_statuses = account_statuses; - 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(); - ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) -} - -fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - height_percent) / 2), - Constraint::Percentage(height_percent), - Constraint::Percentage((100 - height_percent) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - width_percent) / 2), - Constraint::Percentage(width_percent), - Constraint::Percentage((100 - width_percent) / 2), - ]) - .split(vertical[1])[1] -} - -fn truncate_cells(value: &str, width: usize) -> String { - if UnicodeWidthStr::width(value) <= width { - return value.into(); - } - let mut output = String::new(); - let mut used = 0; - for character in value.chars() { - let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1); - if used + character_width + 1 > width { - break; - } - output.push(character); - used += character_width; - } - output.push('…'); - output -} - -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::*; - use ratatui::{Terminal, backend::TestBackend}; - - #[test] - fn truncation_respects_terminal_cells() { - assert_eq!(truncate_cells("界界界", 5), "界界…"); - } - - #[test] - fn renders_the_login_screen_at_the_floor() { - let auth = AuthService::new().expect("PAM worker should start"); - let mut app = App::new( - Config::default(), - auth, - true, - vec![SessionDescriptor { - kind: session::SessionKind::Tty, - title: "TTY shell".into(), - argv: Vec::new(), - current_desktop: None, - session_desktop: None, - }], - Vec::new(), - Background::load(&Config::default().visual).expect("default background should load"), - ); - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); - terminal - .draw(|frame| app.draw(frame)) - .expect("frame should render"); - let buffer = terminal.backend().buffer(); - let rendered = buffer - .content() - .chunks(buffer.area.width as usize) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>() - .join("\n"); - assert!(rendered.contains("latchd")); - assert!(rendered.contains("Username")); - 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"); - let mut app = App::new( - Config::default(), - auth, - true, - Vec::new(), - Vec::new(), - Background::load(&Config::default().visual).expect("default background should load"), - ); - let backend = TestBackend::new(59, 15); - let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); - terminal - .draw(|frame| app.draw(frame)) - .expect("frame should render"); - let buffer = terminal.backend().buffer(); - let rendered = buffer - .content() - .chunks(buffer.area.width as usize) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>() - .join("\n"); - assert!(rendered.contains("terminal too small")); - } - - #[test] - fn switching_accounts_clears_password_and_auth_state() { - let auth = AuthService::new().expect("PAM worker should start"); - let accounts = vec![ - Identity { - username: "alice".into(), - uid: 1000, - gid: 1000, - home: "/home/alice".into(), - shell: "/bin/sh".into(), - }, - Identity { - username: "bob".into(), - uid: 1001, - gid: 1001, - home: "/home/bob".into(), - shell: "/bin/sh".into(), - }, - ]; - let mut app = App::new( - Config::default(), - auth, - true, - Vec::new(), - accounts, - Background::load(&Config::default().visual).expect("default background should load"), - ); - app.password = Zeroizing::new("secret".into()); - app.next_account(); - assert_eq!(app.username, "bob"); - assert!(app.password.is_empty()); - assert!(matches!(app.auth_state, AuthState::Idle)); - } - - #[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 pam_password_change_requirement_enters_explicit_new_password_mode() { - 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.pending_password = Zeroizing::new(b"old-secret".to_vec()); - app.handle_auth_completion(AuthCompletion::Login(Err(Error::Pam { - operation: "pam_acct_mgmt", - status: 12, - message: "module detail must stay out of the UI".into(), - }))) - .expect("policy transition should not fail"); - assert!(app.password_change_mode); - assert_eq!(app.focus, Focus::Password); - assert!(app.status.contains("new password")); - } - - #[test] - fn password_change_mode_has_a_distinct_prompt() { - 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"), - ); - app.password_change_mode = true; - app.focus = Focus::Password; - app.update_hint(); - assert!(app.status.contains("new password")); - let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal"); - terminal.draw(|frame| app.draw(frame)).expect("frame"); - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - 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] - 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); - } -} +pub use app::{LoginSelection, run}; diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a1d0f7d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,91 @@ +# Architecture + +Latchd is one Rust binary split into three workspace crates; `rust/main.rs` joins them and owns the service loop. + +## Crates + +| Path | Role | +| --- | --- | +| `crates/latchd-core` | Configuration, account discovery, session discovery, environment construction, UTMPX, preferences, and audit records | +| `crates/latchd-auth` | PAM FFI, the conversation callback, authentication tickets, password changes, and session cleanup | +| `crates/latchd-tui` | Ratatui state, terminal input, rendering, filters, notices, wallpaper, and animation | +| `rust/main.rs` | CLI options, configuration, logging, preflight, and the service loop | + +## Startup + +The systemd unit starts latchd on the configured VT, then the process follows this path: + +1. Parse the CLI options. +2. Read `/etc/latchd/config.toml`. +3. Apply command-line overrides. +4. Validate values, paths, and power actions. +5. Check the shell, desktop programs, X11 tools, and session scripts. +6. Start the auth worker and the TUI. + +The service path also checks the terminal and the absence of an existing user session. Preview skips these checks and stops before authentication. + +## Authentication worker + +The auth worker owns each PAM handle and receives requests through a bounded channel. The TUI receives an opaque ticket after authentication. + +For a login, the worker starts a transaction, answers prompts, checks the account, resolves it through NSS, and establishes credentials. The password-change call accepts the current and new passwords when required. + +The worker drops the saved transaction when the user cancels a ticket. + +## Session handoff + +After the user selects a session, the main loop creates a `SessionSpec` with the account, command, environment, terminal, and optional X11 runtime. The worker then: + +1. sets the terminal name to `ttyN`; +2. supplies the selected session type, desktop, seat, and VT; +3. opens the login session; +4. copies a fixed list of runtime values into the child environment; +5. starts the user process; +6. closes the session and removes the credentials. + +The child starts with a cleared environment, receives locale values plus the account and XDG paths, creates a new session ID, initializes supplementary groups, and switches to the account UID and GID before it runs. + +The imported runtime list contains `XDG_SESSION_CLASS`, `XDG_SESSION_TYPE`, `XDG_SESSION_DESKTOP`, `XDG_SEAT`, `XDG_VTNR`, `XDG_RUNTIME_DIR`, `XDG_SESSION_ID`, and `DBUS_SESSION_BUS_ADDRESS`. + +## X11, Wayland, and TTY + +- X11 — starts the configured server on `vtN`, waits for its readiness signal, writes a private authority file with `xauth`, starts the desktop client, then removes the file and stops the server +- Wayland — starts the selected desktop program with the session values; the compositor and its socket come from the user service or desktop setup +- TTY — attaches the selected shell to the controlling terminal and keeps the terminal as the child standard input, output, and error + +The session runner writes a UTMPX user-process record when the platform supports it. It closes that record when the child exits. The runtime can report systemd-logind markers, but it does not call a logind D-Bus API. + +## Session discovery + +The core crate scans the configured X11 and Wayland directories in sorted file-name order. It keeps a `.desktop` file only when the file is root-owned, regular, and not writable by group or other users. + +The parser reads `Name`, `Type`, `Hidden`, `NoDisplay`, `TryExec`, `Exec`, and `DesktopNames`; it rejects field codes and shell operators, so each session remains a program plus an argument list. Administrator scripts use the same ownership checks, and the file name becomes the display title. + +The TTY shell appears when the configuration requests it or when no graphical entry survives the scan. + +## Account discovery + +The account scanner reads NSS records, sorts them by username, and filters by UID, excluded name, login shell, home ownership, and home permissions. Shadow aging data adds the status shown beside the selected account, while the faillock helper adds a short failure count with a 750 millisecond limit. + +A missing shadow file or a failed faillock query produces an unknown status. The account remains in the list. + +## Preferences and visual state + +The last-session store keeps one bounded entry per account, writes through a private temporary file, syncs it, and renames it into place. A missing or invalid store falls back to the first discovered session. + +The visual module loads an optional PNG or JPEG, scales it to a terminal-sized RGB image, and draws a small cellular animation over it. The animation state is capped at 160 columns by 64 rows; `Ctrl-B` toggles the background and `Ctrl-M` toggles motion. + +## File and process rules + +- The service configuration must belong to root and must not be writable by group or other users. +- Desktop files, X11 tools, scripts, shells, and wallpapers must pass ownership and mode checks. +- Session logs and Xauthority files use private permissions. +- Power actions use absolute program paths and require Enter confirmation. + +## Changing the code + +Add a config field in `crates/latchd-core/src/config.rs`, add its validation, and add one valid and one invalid test. Add a new session source as a `SessionDescriptor`; keep command parsing separate from shell execution. + +Keep handle-based authentication work inside `crates/latchd-auth`, add a fault test when the operation crosses the C boundary, and keep password storage zeroizing. Add TUI behavior in the state machine, then add a state test and a render test at the minimum terminal size. + +Run `cargo fmt --all -- --check`, `cargo test --locked --workspace --all-targets`, `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, and rustdoc before you send the change. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..b31c6b1 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,159 @@ +# Configuration reference + +Latchd uses `/etc/latchd/config.toml` as its service configuration. +The complete example is [`../extra/config.toml`](../extra/config.toml). Copy it to the main file, then change the required values. + +## Command-line overrides + +These command-line options replace selected values for one run: + +| Option | Field changed | +| --- | --- | +| `--tty N` | `tty` | +| `--xsessions PATH` | `x11.sessions_path` | +| `--wlsessions PATH` | `wayland.sessions_path` | +| `--initial-path PATHS` | `initial_path` | + +Use these options for a one-time test. Put permanent values in the main file. + +## Top-level fields + +| Field | Default | Description | +| --- | --- | --- | +| `tty` | `2` | Virtual terminal number. Valid values are `1` through `63`. | +| `main_log_path` | `/var/log/latchd.log` | Main service log. | +| `client_log_path` | `/var/log/latchd.client.log` | Output log for graphical session clients. | +| `pam_service` | `latchd` | Name of the PAM service file in `/etc/pam.d/`. | +| `system_shell` | `/bin/sh` | Shell used by the TTY session and the preflight check. | +| `initial_path` | `/usr/local/sbin:/usr/local/bin:/usr/bin` | Absolute directories used to resolve session programs. | + +The path fields in this table must be absolute. `initial_path` must contain only non-empty absolute directories separated by colons. + +## `[x11]` + +The X server starts only when the selected session has kind `X11`. + +| Field | Default | Description | +| --- | --- | --- | +| `display` | `:1` | X display name passed to Xorg and the session. | +| `server_path` | `/usr/bin/X` | X server executable. | +| `xauth_path` | `/usr/bin/xauth` | Program that writes the temporary Xauthority file. | +| `server_timeout_secs` | `60` | Maximum wait for the X server readiness signal. | +| `server_log_path` | `/var/log/latchd.xorg.log` | X server log. | +| `sessions_path` | `/usr/share/xsessions` | Directory with X11 `.desktop` files. | +| `scripts_path` | `/etc/latchd/wms` | Directory with administrator X11 session scripts. | + +Set absolute paths for the X server and `xauth`. Latchd passes `display`, `vtN`, and the authority file to Xorg. + +## `[wayland]` + +| Field | Default | Description | +| --- | --- | --- | +| `sessions_path` | `/usr/share/wayland-sessions` | Directory with Wayland `.desktop` files. | +| `scripts_path` | `/etc/latchd/wayland` | Directory with administrator Wayland session scripts. | + +The selected Wayland program starts with these values; latchd does not start a compositor or create a Wayland socket. + +## `[environment_switcher]` + +| Field | Default | Description | +| --- | --- | --- | +| `include_tty_shell` | `false` | Add the TTY shell when graphical sessions exist. | +| `max_display_length` | `18` | Maximum display width used for session titles. Must be greater than zero. | +| `remember_last_session` | `true` | Remember the last successful session for each account. | +| `last_session_path` | `/var/lib/latchd/last-session.toml` | File for the saved session preference. | + +The preference file stores a session kind and title for each account; it holds at most 128 entries, writes a synced temporary file, and renames it into place. The file and its parent directory must belong to the account that runs latchd and must not be writable by group or other users. + +Set `remember_last_session = false` to disable reads and writes; latchd ignores the path in that case. + +## `[accounts]` + +| Field | Default | Description | +| --- | --- | --- | +| `minimum_uid` | `1000` | Lowest UID that account enumeration includes. | +| `excluded_users` | `[]` | Usernames that account enumeration skips. | +| `enumerate` | `true` | Discover accounts through NSS for the account list. | +| `faillock_path` | `/usr/sbin/faillock` | Faillock helper executable. | +| `faillock_config` | `/etc/security/faillock.conf` | File from which Latchd reads the `deny` value. | +| `shadow_path` | `/etc/shadow` | Shadow file used for account status display. | + +Account enumeration also requires an absolute login shell and a safe home directory; `/bin/false`, `/usr/sbin/nologin`, and `/sbin/nologin` are excluded. + +Latchd reads the shadow file only when it is a root-owned, non-writable regular file. The screen can show these states: + +- `Locked` when the password hash starts with `!` or `*`; +- `PasswordExpired` when the last-change day is zero or the maximum age passed; +- `AccountExpired` when the account expiry day passed; +- `AccountInactive` when the inactive period passed; +- `NoLoginShell` when the shell does not permit login; +- `Unknown` when shadow data is not available. + +The faillock check only adds status text. The helper runs: + +```text +faillock --user USERNAME --legacy-output +``` + +The query stops after 750 milliseconds. Bad output, a missing helper, or a timeout removes the faillock status from the screen. + +## `[visual]` + +| Field | Default | Description | +| --- | --- | --- | +| `wallpaper_path` | not set | Optional PNG or JPEG file for the TUI background. | +| `background_enabled` | `true` | Draw the wallpaper and ambient background. | +| `animations` | `true` | Update the ambient cellular animation. | +| `notifications` | `true` | Show short status notices in the TUI. | +| `reduced_motion` | `false` | Start with the ambient animation disabled. | +| `max_wallpaper_bytes` | `8388608` | Maximum wallpaper file size. Valid range is 1 to 67108864 bytes. | + +The wallpaper path must be absolute, and the file must be root-owned, regular, and not group-writable or other-writable. Source images are limited to 8192 by 8192 pixels and 16,777,216 pixels total, then scaled for the terminal. + +The TUI also caps the animation state at 160 columns by 64 rows. Press `Ctrl-B` to toggle the background. Press `Ctrl-M` to toggle reduced motion. + +## `[power_controls]` + +Power actions are disabled in preview mode. Each action needs an absolute program path and a function-key label: + +```toml +[[power_controls.entries]] +key = "F1" +hint = "Power off" +program = "/usr/bin/systemctl" +args = ["poweroff"] +``` + +`key` must be `F1` through `F12`, and `hint` must not be empty; `program` starts directly with `args`, without a shell, after the TUI asks for confirmation. + +## Desktop entry rules + +Only the `[Desktop Entry]` section is read, with these keys: + +| Key | Use | +| --- | --- | +| `Name` | Session title. | +| `Type` | Must be `Application` when present. | +| `Hidden` | `true` hides the entry. | +| `NoDisplay` | `true` hides the entry. | +| `TryExec` | Optional executable check. | +| `Exec` | Program and arguments. | +| `DesktopNames` | Values for `XDG_CURRENT_DESKTOP` and `XDG_SESSION_DESKTOP`. | + +Plain and quoted arguments are accepted; desktop field codes such as `%u` and `%f`, plus shell syntax, are rejected. + +## Validation failures + +The parser rejects unknown fields. The validator rejects these common errors: + +- a virtual terminal outside `1` through `63`; +- an empty PAM service name; +- an empty path or a relative directory in `initial_path`; +- a relative path in a required absolute-path field; +- a zero session title width; +- a relative last-session path when preferences are enabled; +- a zero or oversized wallpaper limit; +- an empty X display or zero X server timeout; +- an invalid power-action key, label, or program path. + +Use `--preview --no-tui` to report these errors without starting the screen. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..52887c9 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,114 @@ +# Development and release + +This page lists the commands to build, test, and package latchd. The service configuration is `/etc/latchd/config.toml`. + +## Toolchain + +Install Rustup with Rust 1.88, then install the native packages used by the PAM link step: + +```sh +sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules +``` + +Keep `Cargo.lock` with dependency changes. + +## Build commands + +| Command | Result | +| --- | --- | +| `make deps` | Checks Cargo, rustfmt, pkg-config, and the PAM development files | +| `make build` | Builds the optimized binary and copies it to `build/latchd` | +| `make run-preview` | Builds the binary and opens preview mode | +| `make version` | Prints version, commit, epoch, and target metadata | +| `make package` | Creates a deterministic archive and SHA-256 file in `dist/` | +| `make clean` | Removes `build/`, `dist/`, and Cargo build output | + +`make build` reads the version from `Cargo.toml`, embeds the current commit, and uses `SOURCE_DATE_EPOCH` for repeatable package metadata. Set `VERSION`, `TARGET`, or `SOURCE_DATE_EPOCH` when you need a different release value. + +Build for another Rust target with: + +```sh +make build TARGET=x86_64-unknown-linux-gnu +``` + +The target toolchain and system libraries must already be installed. + +## Test commands + +Run the fast checks before the host checks: + +```sh +make fmt +make test +make lint +make doc +make integration-test +``` + +`make check` runs the first four commands together. The integration script uses preview mode and checks metadata, configuration validation, preflight, and safe CLI paths. + +Run the optional security audit after you install the tool: + +```sh +cargo install cargo-audit --locked +make audit +``` + +The Go prototype has its own opt-in checks: + +```sh +make legacy-check +make legacy-build +``` + +The release build never calls these targets. + +## Docker check + +`make docker-test` builds `Dockerfile.test` as `latchd-test:local`. The image installs the PAM development files, Xvfb, and `xauth`, then runs formatting, workspace tests, Clippy, rustdoc, a release build, the CLI script, the capability probe, and the disposable PAM fixture. + +The container checks build, test, preview, and fixture paths. Run the host checks in a disposable VM when you need a real VT, user session, compositor, or systemd-logind seat. + +Remove the local image after a test run if you need the space: + +```sh +docker image rm latchd-test:local +``` + +## PAM fixture + +`make pam-fixture-test` must run as root because it writes temporary files below `/etc/pam.d/`. The script uses unique service names, runs the ignored Rust fixture tests, and removes the files in its exit trap. + +The fixture checks authentication failure, password-change failure, selected session metadata, and environment values returned by the session module. + +## Host checks + +Use `make system-e2e` to inspect the binary, PAM library, faillock helper, VT device, Xvfb, Wayland socket, and systemd-logind. + +Use the strict form only in a disposable VM: + +```sh +make system-e2e-strict +``` + +The VM needs a test policy, a real VT, a working logind seat, and the X11 or Wayland stack under test. See [End-to-end testing](e2e-testing.md) for the setup and limits. + +## Release archive + +Run the release gates before packaging: + +```sh +make check +make audit +make integration-test +make docker-test +make package +``` + +`make package` stages the binary, configuration, login policy, systemd unit, README, license, changelog, and build metadata. It sorts the archive, sets a fixed owner and timestamp, then writes the archive checksum and `dist/release.env`. + +GitLab CI runs the verification and Docker jobs on branch and merge-request pipelines. A tag that matches the SemVer rule runs the package and release jobs. The package job checks that the tag version matches `Cargo.toml` before it creates the artifact. + +## Repository changes + +Keep Rust source under the workspace crates, keep host scripts in `scripts/`, and put sample service files in `extra/`. Add tests beside the behavior they cover, update the changelog with the implementation detail and the user-visible result, then run `git diff --check` before you commit. diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index d66d8a4..6870512 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -1,43 +1,85 @@ -# End-to-end testing +# End-to-end checks -Latchd has two test layers: +Latchd has checks for a development host, the Docker image, and a disposable VM. -* `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 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 - 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. +## Check matrix -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. The password-change path is covered by FFI/conversation tests in the -container, but a real PAM password rotation and 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. +| Command | What it covers | Requirement | +| --- | --- | --- | +| `make check` | formatting, Rust tests, Clippy, and rustdoc | Rust toolchain and PAM development files | +| `make integration-test` | release CLI, configuration validation, preflight, and preview paths | local build | +| `make docker-test` | release build, CLI checks, Xvfb, capability probe, and PAM fixture | Docker | +| `make pam-fixture-test` | denied login, password change, session metadata, and returned environment | root for temporary `/etc/pam.d/` files | +| `make system-e2e` | PAM library, faillock, VT device, X11, Wayland socket, and logind seat | host capabilities | +| `make system-e2e-strict` | all host capability checks | prepared VM | -For a local release-style run: +Run the local set with: ```sh +make check make integration-test -make pam-fixture-test make docker-test make system-e2e ``` -The preview interface is documented in [`ui.md`](ui.md). Its screenshots come from a Docker PTY and do not represent a host VT or compositor. +## Docker -For a VM with a real seat, compositor, and logind: +`Dockerfile.test` installs Rust 1.88, the PAM development files, Xvfb, and `xauth`, then runs the release checks inside the image. + +Use a disposable VM for VT, compositor, and systemd-logind checks. + +The Docker test keeps the image as `latchd-test:local`. Remove it after the run when required: ```sh +docker image rm latchd-test:local +``` + +The UI screenshots use a Docker PTY and preview mode. + +## PAM fixture + +Run the fixture as root: + +```sh +sudo make pam-fixture-test +``` + +The script creates temporary service names below `/etc/pam.d/`, builds a small session module, runs the Rust fixture tests, and removes the temporary files after the run. + +The fixture checks these handoffs: + +- a denied authentication returns an error; +- an expired password enters the password-change path; +- the selected TTY, session type, desktop, seat, and VT reach the session stack; +- session values returned by the module reach the child environment. + +## Host capability probe + +Use `make system-e2e` to inspect host capabilities. + +The probe checks `/dev/tty0`, Xvfb, a Wayland socket, the PAM library, faillock, `/etc/pam.d/latchd`, and systemd-logind. + +## Strict VM check + +Prepare a disposable VM with: + +- the latchd binary and `/etc/latchd/config.toml`; +- a test account and `/etc/pam.d/latchd` configured for it; +- a real virtual terminal and an available `seat0`; +- systemd-logind; +- the X11 or Wayland stack under test; +- the desktop files and executables named by the config. + +Build the binary, then run: + +```sh +make build 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. +## Reading failures + +- `configuration` errors point to a value or ownership check; run `--preview --show-config` to isolate it +- `session` errors point to a desktop entry, executable, home directory, X11 server, or child process; run `--preview --no-tui` and read the client log +- `PAM` errors point to account or service setup; check `/etc/pam.d/latchd` and the service journal +- `terminal` errors mean the process lacks a controlling terminal; use preview mode for non-interactive checks diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..6868b91 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,208 @@ +# Latchd guide + +Latchd is a terminal login manager for Linux. It starts on a virtual terminal, gives you a keyboard-first screen, and starts the shell or desktop you select. + +The service is small on purpose: systemd gives it the terminal, the login stack checks the account, and latchd handles the screen plus the process handoff. The [configuration reference](configuration.md) lists every field, while the [architecture notes](architecture.md) explain the Rust workspace. + +## What starts when + +The service unit starts `/usr/bin/latchd` as root on `/dev/tty2`. The default configuration file is `/etc/latchd/config.toml`. + +The screen contains these parts: + +- A list of desktop and shell sessions. +- An account field. +- A password field. +- A status line. + +Use the screen in this order: + +- Select an account. +- Select a desktop or shell. +- Enter the password. +- Press Enter. +- Enter a new password if the login stack requests one. + +After login, latchd performs these actions: + +- Build an environment for the selected account and session. +- Open the session. +- Start the selected child with the account credentials. +- Wait for the child to exit. +- Close the session. + +Session-specific setup works as follows: + +- X11 uses a temporary authority file. +- Wayland receives the session values that it needs. +- A TTY session starts the account shell. + +```text +Start latchd + | + v +Show accounts and sessions + | + v +Select account, session, and password + | + v +Press Enter + | + v +Check the password and account state + | + +------------------------------+ + | | + v v +Login accepted New password required + | | + | v + | Show new password field + | | + | v + | Submit the password change + | | + +--------------+---------------+ + | + v + Build the session environment + | + v + Open the session + | + v + Start the child + | + v + Wait for exit + | + v + Close the session +``` + +## Service mode and preview mode + +Service mode needs root, a controlling terminal, and a system configuration file with safe ownership. It can authenticate users and start sessions. + +Preview mode is for local work; it draws the screen, discovers the configured entries, and reads the user configuration, but it does not authenticate or start a child process. + +```sh +make build +./build/latchd --preview --no-log +``` + +Use the non-interactive checks when you only need a result: + +```sh +./build/latchd --preview --show-config --no-log +./build/latchd --preview --no-tui --no-log +``` + +`--show-config` prints the build information plus the selected terminal, service name, and shell. `--no-tui` validates the configuration and runs session preflight without opening the screen. + +## Build from source + +Install Rust 1.88 and the PAM development files. On Debian or Ubuntu, use: + +```sh +sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules +``` + +The toolchain file selects Rust 1.88 and the required `rustfmt` and `clippy` components. Build the optimized binary with: + +```sh +make build --release +``` + +The output is at `build/latchd`, `make build` also embeds the version, commit, and build time. + +Run `make check` for verification and `make package` to create a tarball with the binary, the system configuration, and the systemd unit. The `make install` target writes the files to the system. + +The commands and the container workflow are in [Development and release](development.md). + +## Install and start the service + +Use a disposable machine first and keep another way to log in while you change the display manager: + +```sh +sudo make install +sudo systemctl enable latchd.service +sudo systemctl start latchd.service +``` + +The install target writes the binary, the system TOML file, the login policy, the session-script directories, and the systemd unit. It does not disable another display manager, so stop the service that already owns the target VT before you start latchd there. + +The supplied unit owns `tty2`. If you move it, change `tty` in the TOML file and `TTYPath` in the unit together; otherwise the values passed to the login stack and the terminal will disagree. + +Use `sudo systemctl status latchd.service` and `sudo journalctl -u latchd.service -b --no-pager` when the unit fails before the screen appears. + +## Configuration files + +The service default is `/etc/latchd/config.toml`. Preview mode first checks `$XDG_CONFIG_HOME/latchd/config.toml`, then `~/.config/latchd/config.toml`, and finally the system file; `--config PATH` overrides all of them. + +Create the user file with: + +```sh +make install-user +``` + +The target creates the directory with mode `700` and the file with mode `600`, and it leaves an existing file unchanged. Read [Configuration reference](configuration.md) before you add a variable file or change a path used by the service. + +The main sections are: + +| Section | What it controls | +| --- | --- | +| `[accounts]` | NSS account list, shadow status, and faillock display | +| `[x11]` | X server, authority tool, desktop entries, and scripts | +| `[wayland]` | Wayland desktop entries and scripts | +| `[environment_switcher]` | TTY entry and last-session file | +| `[visual]` | Wallpaper, animation, notices, and motion setting | +| `[power_controls]` | Confirmed programs bound to F1 through F12 | + +## Accounts and sessions + +Account discovery starts with NSS and then removes entries below `minimum_uid`, entries in `excluded_users`, accounts with a no-login shell, and homes with unsafe ownership or write permission. Set `enumerate = false` when you want a blank list and manual username entry. + +The screen can show password expiry, account expiry, lock state, and inactivity from the configured shadow file. It can also call `faillock` for the selected name; the query has a 750 millisecond limit and a failed query only removes the extra status text. + +Session discovery reads `.desktop` files from the X11 and Wayland directories, then reads executable files from the matching script directories. Files must belong to root and must not be writable by group or other users. A desktop entry with `Hidden=true`, `NoDisplay=true`, an invalid `Exec`, or an unavailable `TryExec` is skipped. + +Latchd stores desktop commands as argument lists, so shell operators and desktop field codes do not run. A script entry uses the file path as its command. The TTY shell appears when `include_tty_shell = true`, and it also appears when no other session exists. + +## The session environment + +The child starts with an empty environment, then receives terminal locale values, account paths, XDG directories, session type, seat, VT number, and the selected desktop values. The account UID, primary GID, and supplementary groups are set before the child runs. + +X11 starts the configured server on the selected VT, waits for its readiness signal, creates a private authority file, and removes that file after the client exits. Wayland starts the selected program but does not start a compositor. TTY mode attaches the shell to the controlling terminal. + +The session records a UTMPX user process when the platform supports it. A last-session file can remember the successful choice for each account; the file is bounded and replaced atomically. + +## Logs and useful paths + +The example file uses these paths: + +| Path | Use | +| --- | --- | +| `/var/log/latchd.log` | Main service and audit output | +| `/var/log/latchd.client.log` | Graphical session output | +| `/var/log/latchd.xorg.log` | X server output | +| `/var/lib/latchd/last-session.toml` | Last session per account | +| `/etc/latchd/wms/` | X11 script entries | +| `/etc/latchd/wayland/` | Wayland script entries | + +The service creates log files with mode `600` and rejects a main log larger than 64 MiB. Audit lines include the action, result, account, and a short reason; passwords and command arguments stay out of the log. + +## When a session does not start + +Check the config and the paths first: + +```sh +./build/latchd --preview --show-config --no-log +./build/latchd --preview --no-tui --no-log +make system-e2e +``` + +Then check the desktop file, the executable named by `Exec` or `TryExec`, the account home permissions, and the service log. For X11, check the X server and `xauth` paths; for Wayland, check the compositor and its user service outside latchd. + +Use [End-to-end testing](e2e-testing.md) when the host is a container or when you need a disposable VM for the terminal and display stack. diff --git a/docs/ui.md b/docs/ui.md index f541286..98e914e 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,39 +1,75 @@ -# Latchd user interface +# User interface -Latchd starts on a virtual terminal. The interface keeps the session list and the login fields visible at the same time. +The screen is designed for a virtual terminal. It keeps the session list beside the account and password fields, and adapts down to 60 columns by 16 rows. -![Login screen](images/latchd-preview.png) +![Latchd login screen](images/latchd-preview.png) -The screenshot comes from the Docker test image. It uses preview mode. Preview mode does not contact PAM or start a user session. +Preview mode screenshot of the login layout. + +## Fields and status + +- session list — choose an X11, Wayland, administrator-script, or TTY entry +- username — type an account name or select one discovered through NSS +- password — enter the password; the screen clears it after an attempt +- status line — shows focus prompts, account state, faillock counts, notices, and authentication progress +- footer — shows the keys that apply to the current screen state + +When the selected account needs a new password, the password field changes to the new-password prompt. The old password stays in the pending authentication request until the password-change call finishes, then both values are cleared. + +## Keys + +| Key | Action | +| --- | --- | +| `Tab` / `Shift-Tab` | Move between session, username, and password | +| `Up` / `Down` | Move focus, or move through the filtered list | +| `Left` / `Right` | Change the session when the session list has focus | +| `/` | Start a session or account filter | +| `Ctrl-R` | Refresh accounts and sessions in the background | +| `Ctrl-N` / `Ctrl-P` | Select the next or previous account | +| `Ctrl-B` | Toggle the wallpaper and ambient animation | +| `Ctrl-M` | Toggle reduced motion | +| `Enter` | Start login or submit a new password | +| `Esc` | Clear the password, close a popup, or cancel power confirmation | +| `?` | Open the key reference | +| `Ctrl-C` | Restore the terminal and exit | +| `F1`–`F12` | Confirm the matching configured power action | + +Lowercase filters ignore case. A filter with an uppercase character uses case-sensitive matching. Press `Esc` while filtering to leave the filter field. + +## Visual settings + +The `[visual]` section controls the background: + +```toml +[visual] +background_enabled = true +animations = true +notifications = true +reduced_motion = false +max_wallpaper_bytes = 8388608 +# wallpaper_path = "/etc/latchd/wallpaper.png" +``` + +`wallpaper_path` accepts a root-owned PNG or JPEG file. The loader checks the file size and source dimensions, then scales the image before it draws it in the terminal. `animations = false` stops frame updates at startup, while `reduced_motion = true` does the same and can also be changed with `Ctrl-M`. + +Set `LATCHD_ASCII=1` for terminals without reliable Unicode cells, `NO_COLOR=1` for plain output, and `LATCHD_HIGH_CONTRAST=1` for a stronger focus style. ## Help panel -Press `?` to show the key reference. +Press `?` to open the key reference: -![Keyboard help](images/latchd-help.png) +![Latchd help panel](images/latchd-help.png) -Use the following keys: +Press `Esc` or `?` again to close it. The help panel does not change the selected account or session. -- Press `Tab` or `Shift-Tab` to move focus. -- Press the arrow keys to choose a session. -- Press `/` to filter sessions or accounts. -- Press `Ctrl-N` or `Ctrl-P` to change the account. -- Press `Ctrl-R` to refresh account and session discovery. -- Press `Ctrl-B` to toggle the background. -- Press `Ctrl-M` to toggle reduced motion. -- Press `Enter` to authenticate. -- Press `Esc` to clear the password or close the help panel. -- Press `Ctrl-C` to restore the terminal and quit. +## Capture the screen -These messages show account state before login. PAM accepts or rejects the login. - -## Capture method - -The capture uses a Docker PTY and the release binary from `latchd-test:local`: +Build the Docker test image, then start a PTY with a fixed size: ```sh +docker build -f Dockerfile.test -t latchd-test:local . tmux new-session -d -x 120 -y 36 \ 'docker run --rm -it latchd-test:local /workspace/target/release/latchd --preview --no-log' ``` -The image in this directory contains the plain terminal capture. It does not show a host VT, a host compositor, or a real PAM login. +The capture shows the preview binary and terminal renderer, preview mode is simply for testing and doesn't perform any real login. \ No newline at end of file diff --git a/extra/config.toml b/extra/config.toml index afeff03..071f11d 100644 --- a/extra/config.toml +++ b/extra/config.toml @@ -24,7 +24,7 @@ max_display_length = 18 remember_last_session = true last_session_path = "/var/lib/latchd/last-session.toml" -# Power actions use an executable and arguments. Latchd does not use a shell. +# Start power actions directly with an executable and its arguments. [power_controls] entries = [] diff --git a/legacy/go/auth/pam.go b/legacy/go/auth/pam.go index 01e7b81..59ef7f3 100644 --- a/legacy/go/auth/pam.go +++ b/legacy/go/auth/pam.go @@ -1,4 +1,4 @@ -// Keep each PAM transaction on one OS thread. PAM modules can use thread-local state. +// Keep each transaction on one OS thread because modules can use thread-local state. package auth /* @@ -7,11 +7,11 @@ package auth #include #include -// Match the signature that cgo generates for //export. +// Match the callback signature that cgo generates for //export. extern int latchd_pam_conv(int, struct pam_message **, struct pam_response **, void *); -// Store the numeric handle in C memory. Do not pass a Go pointer to PAM. +// Store the numeric handle in C memory; never pass a Go pointer to the module. static void *storeHandle(uintptr_t h) { uintptr_t *p = malloc(sizeof(uintptr_t)); if (p) *p = h; diff --git a/legacy/go/session/x11.go b/legacy/go/session/x11.go index e58021d..cb921de 100644 --- a/legacy/go/session/x11.go +++ b/legacy/go/session/x11.go @@ -44,7 +44,7 @@ func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, er dd = "0" + dd } - // Xorg sends SIGUSR1 only if the parent ignores it during startup. + // Xorg sends SIGUSR1 only when the parent ignores the signal during startup. signal.Ignore(syscall.SIGUSR1) cmd := exec.Command(cfg.SystemShell, "-c", diff --git a/legacy/go/tui/bg/automata.go b/legacy/go/tui/bg/automata.go index ac51f58..3d41624 100644 --- a/legacy/go/tui/bg/automata.go +++ b/legacy/go/tui/bg/automata.go @@ -1,5 +1,5 @@ -// The renderer packs two cell rows into each Unicode half-block row. -// Two buffers let Tick update the grid without an allocation per frame. +// Pack two cell rows into each Unicode half-block row; two buffers keep frame +// updates allocation-free. package bg import ( diff --git a/rust/main.rs b/rust/main.rs index dcc1b9e..d5ac7db 100644 --- a/rust/main.rs +++ b/rust/main.rs @@ -1,4 +1,4 @@ -//! latchd is a root-owned terminal login manager. +//! Terminal login manager entry point and service loop. #![deny(unsafe_op_in_unsafe_fn)] #![warn(missing_docs)] @@ -25,7 +25,7 @@ const BUILD_VERSION: &str = env!("LATCHD_VERSION"); const BUILD_COMMIT: &str = env!("LATCHD_COMMIT"); const BUILD_TIME: &str = env!("LATCHD_BUILD_TIME"); -/// Command-line options for latchd. +/// Command-line options for the login service. #[derive(Debug, Parser)] #[command( name = "latchd", @@ -36,7 +36,7 @@ struct Cli { /// Configuration TOML path. #[arg(short, long)] config: Option, - /// Optional variable TOML path. + /// TOML file with values for text substitution. #[arg(short = 'v', long)] variables: Option, /// Override the configured virtual terminal. @@ -51,26 +51,26 @@ struct Cli { /// Override the session PATH. #[arg(long)] initial_path: Option, - /// Run without authenticating or starting a real session. + /// Run the screen without authentication or session startup. #[arg(long)] preview: bool, - /// Validate configuration and exit without starting the TUI. + /// Validate the configuration and sessions without opening the screen. #[arg(long, alias = "preflight")] no_tui: bool, - /// Do not create a main log. + /// Skip the main log file. #[arg(long)] no_log: bool, - /// Print the validated configuration and exit. + /// Print build information and selected configuration values. #[arg(long)] show_config: bool, - /// Print embedded release metadata without accessing configuration or PAM. + /// Print embedded build metadata. #[arg(long)] build_info: bool, } fn main() -> color_eyre::Result<()> { color_eyre::install()?; - run().map_err(|error| color_eyre::eyre::eyre!(error.to_string())) + run().map_err(|error| color_eyre::eyre::eyre!(error.user_report())) } fn run() -> Result<(), Error> { diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh index 8dc20fc..7c831fa 100755 --- a/scripts/test-cli.sh +++ b/scripts/test-cli.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Use --preview so this test does not need PAM, a VT, or a display server. +# Use --preview; this test needs no credentials, VT, or display server. set -eu binary=${1:-target/debug/latchd} diff --git a/scripts/test-system-e2e.sh b/scripts/test-system-e2e.sh index 145b800..ec11e2e 100755 --- a/scripts/test-system-e2e.sh +++ b/scripts/test-system-e2e.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Report unavailable capabilities as SKIP. In strict mode, each SKIP is an error. +# Report unavailable capabilities as SKIP; strict mode treats each skip as an error. set -u binary=${1:-} @@ -43,7 +43,7 @@ else skip "latchd binary not supplied; capability-only probe" fi -# This checks the PAM runtime only. Authentication requires a test account and policy. +# Check the PAM runtime only; authentication still needs a test account and policy. if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then pass "PAM runtime library is available" else @@ -65,14 +65,14 @@ else skip "faillock helper is unavailable" fi -# A pseudo-terminal does not test VT allocation by the host or VM. +# A pseudo-terminal cannot prove that the host can allocate a VT. 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 tests X11 without access to the host display. +# Xvfb checks X11 without using the host display. if need_command Xvfb; then display_number=$((100 + ($$ % 500))) display=":$display_number" @@ -106,7 +106,7 @@ else skip "Xvfb is unavailable" fi -# A compositor must own the Wayland socket. Installed client libraries are not sufficient. +# A compositor must own the Wayland socket; installed client libraries are not enough. wayland_socket= if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then case "$WAYLAND_DISPLAY" in