refactor: split the TUI and refresh project docs

Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds.

Checks: make check; make integration-test; go test ./... (legacy/go).
This commit is contained in:
2026-10-05 21:37:15 +05:30
parent de8843471b
commit f2a885d621
33 changed files with 2866 additions and 2173 deletions

11
.gitignore vendored
View File

@@ -4,10 +4,13 @@ go_project_build_documentation.md
documentation.md documentation.md
session.md session.md
build/* build/
examples/* examples/
things_todo.md things_todo.md
ly/* ly/
# The Rust implementation replaced this reference tree.
/legacy/go/
tui-design-skill/ tui-design-skill/
FEATURE_CHECKLIST.md FEATURE_CHECKLIST.md
@@ -15,3 +18,5 @@ IMPLEMENTATION_CHECKLIST.md
audit_code.md audit_code.md
audit_rust_code.md audit_rust_code.md
.latchd-work/ .latchd-work/
reference.md
.vscode/

View File

@@ -2,127 +2,73 @@
## Unreleased ## Unreleased
- Preview mode now loads `$XDG_CONFIG_HOME/latchd/config.toml` or - 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
`~/.config/latchd/config.toml` when no `--config` path is set. - 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
- Added `make install-user` to create a user configuration without replacing - 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
an existing file. - service configuration — document `/etc/latchd/config.toml` as the service file and keep preview, build, and end-to-end instructions aligned with the Makefile
- Added Docker PTY screenshots and a short UI guide.
- Fixed Wayland compositors launched through user services, including Niri, by ## 0.5.0 — password and session handoff
providing the selected session type, desktop, seat, and VT to PAM before
`pam_open_session`.
- Session children now receive allowlisted runtime values produced by PAM,
including the real logind session ID, runtime directory, and user D-Bus
address. Latchd no longer fabricates `XDG_SESSION_ID=latchd`.
- Extended the disposable PAM fixture to verify both sides of the handoff:
selected session metadata entering PAM and PAM-generated values reaching the
session child.
## 0.5.0 — PAM password policy flow - 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 ## 0.4.0 — account and session workflow
`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 — 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, ## 0.3.0 — Rust workspace release
account expiry, inactivity, and missing shadow data without exposing hashes.
- Added active UTMPX session discovery and systemd-logind capability detection
with an explicit UTMPX fallback.
- Improved faillock parsing for legacy, username-prefixed, and timestamped
helper output. PAM remains authoritative for every login decision.
- Added smart-case filtering for users and sessions, non-blocking Ctrl-R discovery
refresh, contextual footer hints, high-contrast mode, and policy notices in
the TUI.
- Added bounded local authentication backoff after repeated failures and reset
it when switching accounts.
- Added CLI integration tests and host capability probes. Docker now runs the
release binary through the safe CLI and capability suites.
- Added documented strict VM validation for PAM policy, VT, X11, Wayland, and
systemd-logind; missing capabilities are reported as skips in ordinary CI.
- Bumped the workspace release version to 0.4.0.
## 0.3.0 — Rust workspace release preparation - Rust 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 history
`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 (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 - 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
- supports x11, wayland, and tty sessions - 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`
- pam authentication via cgo (thread-locked for safety) - 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
- faillock integration — detects lockouts, shows remaining attempts, admin bypass via ctrl+u - 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
- conway's game of life background rendered at 30 fps with unicode half-block characters - cgo handle lifetime — `legacy/go/auth/pam.go::Credentials` now owns the handle until session close; the previous `Validate` cleanup released it before `OpenSession`
- spring animation on switcher transitions - 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
- shake animation on failed login - file names — rename the `lemurs` policy and unit files in `extra/` to `latchd`
- caps lock indicator via ioctl - extra files — add `extra/config.toml`, `extra/xsetup.sh`, `extra/latchd.pam`, and `extra/latchd.service`, which lets `make install` find its source files
- configurable power controls (shutdown/reboot on f1/f2 by default) - Make targets — add `deps`, `enable`, and `update` targets for dependency checks, service enablement, and local updates
- toml configuration with `$VARIABLE` substitution - 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
- preview mode (`--preview`) to test the login screen without leaving your session - 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`
- utmpx user accounting (glibc only) - session startup — handle `pam_open_session` errors as session log entries and remove the obsolete `login` include from the prototype service file
- x11 session setup — xauthority cookies, sigusr1 handshake, server timeout - global session switching — accept `,` and `.` from every screen state while keeping arrow keys for the focused switcher
- 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

167
README.md
View File

@@ -1,106 +1,72 @@
# latchd # 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) ![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. - account discovery — reads normal login accounts through NSS; filters low UIDs, excluded names, no-login shells, and unsafe home directories
- Shows account expiry, locked-account, and faillock status before login. - account status — reads shadow aging fields and the configured faillock helper; shows expiry, lock, inactivity, and failure counts in the screen
- Lets PAM handle authentication and password changes. - 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
- Switches between users and sessions from the same screen. - 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
- Finds X11 and Wayland desktop entries from configured directories. - X11 startup — creates a private Xauthority file, waits for the X server readiness signal, and removes the file after the desktop exits
- Can offer a TTY shell as a session choice. - Wayland startup — starts the selected desktop with the session values it needs; it does not start a compositor
- Starts the selected session with a restricted environment and the correct user ID. - TTY startup — attaches the selected shell to the virtual terminal and records the user process when UTMPX is available
- Passes the selected session, seat, and virtual terminal to PAM and logind. - user interface — provides account switching, last-session preferences, filtering, refresh, notifications, wallpapers, reduced motion, and confirmed power actions
- 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.
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 ```sh
sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules 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 the release binary and run the checks:
## Build and run
Run the local verification gate and build the release binary:
```sh ```sh
make check make check
make build 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 ```sh
make run-preview
# or
./build/latchd --preview --no-log ./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. The repository pins Rust 1.88 in [`rust-toolchain.toml`](rust-toolchain.toml).
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.
## Configuration ## 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`. Create a user file from the example with:
- 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:
```sh ```sh
make install-user 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. 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).
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.
## Install on a test machine ## 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 ```sh
sudo make install sudo make install
sudo systemctl enable latchd.service sudo systemctl enable latchd.service
sudo systemctl start latchd.service
``` ```
This installs: 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.
- `/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.
## Keyboard controls ## 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 | | Up / Down | Move focus or move in a filtered list |
| Left / Right | Change the selected session | | Left / Right | Change the selected session |
| `/` | Filter sessions or accounts | | `/` | 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-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 | | 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 | | Esc | Clear the password or close a popup |
| `?` | Show keyboard help | | `?` | Show the key reference |
| Ctrl-C | Restore the terminal and quit | | Ctrl-C | Restore the terminal and quit |
| F1–F12 | Confirm a configured power action | | 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 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.
make check
make integration-test
make docker-test
make system-e2e
```
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. Create a local release archive with:
`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:
```sh ```sh
make package 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 ## License
Apache-2.0. See [`LICENSE`](LICENSE). Apache-2.0. See [`LICENSE`](LICENSE).

View File

@@ -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::{ use std::{
collections::HashMap, collections::HashMap,
@@ -108,7 +108,7 @@ unsafe extern "C" {
fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char; 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 { pub struct AuthTicket {
id: u64, id: u64,
identity: Identity, 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)] #[derive(Clone)]
pub struct AuthService { pub struct AuthService {
commands: SyncSender<Command>, commands: SyncSender<Command>,
@@ -192,10 +192,10 @@ impl AuthService {
Ok(ticket) 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. /// The configured policy checks password strength, history, and token rules
/// Zeroizing buffers hold both passwords and errors never include them. /// zeroizing buffers hold both passwords.
pub fn change_password( pub fn change_password(
&self, &self,
username: String, username: String,
@@ -218,7 +218,7 @@ impl AuthService {
.map_err(|_| Error::Worker("PAM worker disconnected".into()))? .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> { pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
let (response, result) = sync_channel(0); let (response, result) = sync_channel(0);
let id = ticket.id; let id = ticket.id;

View File

@@ -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}; use std::{io, mem, os::raw::c_char, ptr};
@@ -10,7 +10,7 @@ pub struct UtmpxGuard {
} }
impl 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<Self, Error> { pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result<Self, Error> {
let mut record: libc::utmpx = unsafe { mem::zeroed() }; let mut record: libc::utmpx = unsafe { mem::zeroed() };
record.ut_type = libc::USER_PROCESS; 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> { pub fn close(&mut self) -> Result<(), Error> {
if self.closed { if self.closed {
return Ok(()); return Ok(());

View File

@@ -1,4 +1,4 @@
//! NSS account discovery and advisory faillock status. //! Discover login accounts and display account policy state.
use std::{ use std::{
ffi::CStr, ffi::CStr,
@@ -23,7 +23,7 @@ pub struct Identity {
pub shell: PathBuf, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState { pub enum AccountState {
Active, Active,
@@ -32,7 +32,7 @@ pub enum AccountState {
AccountExpired, AccountExpired,
AccountInactive, AccountInactive,
NoLoginShell, NoLoginShell,
/// Shadow data was not available or could not be parsed. /// Shadow data was missing or could not be parsed.
Unknown, Unknown,
} }
@@ -65,7 +65,7 @@ pub struct ActiveSession {
pub host: Option<String>, pub host: Option<String>,
} }
/// Runtime session-management backend detected on this host. /// Session backend found on the host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend { pub enum SessionBackend {
SystemdLogind, SystemdLogind,
@@ -81,7 +81,7 @@ struct ShadowEntry {
account_expiry: Option<i64>, account_expiry: Option<i64>,
} }
/// A non-authoritative account lockout summary. /// Parsed faillock counts for the selected account.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus { pub struct FaillockStatus {
pub available: bool, pub available: bool,
@@ -91,7 +91,7 @@ pub struct FaillockStatus {
pub locked_out: bool, 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<Identity> { pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
let mut accounts = Vec::new(); let mut accounts = Vec::new();
unsafe { unsafe {
@@ -128,9 +128,8 @@ pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
accounts accounts
} }
/// Discovers identities and attaches account-aging state when the shadow /// Read identities and attach shadow aging state when the file is available.
/// database is readable. A missing or inaccessible shadow file yields /// Missing shadow data produces [`AccountState::Unknown`] for that account.
/// [`AccountState::Unknown`] rather than hiding the account.
pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> { pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
discover(config) discover(config)
.into_iter() .into_iter()
@@ -147,8 +146,8 @@ pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
.collect() .collect()
} }
/// Inspects password and account aging fields without returning password /// Read aging fields without returning password hashes; the shadow path must
/// hashes. The configured shadow path must be root-owned and non-writable. /// be root-owned and not writable by group or other users.
pub fn inspect_account( pub fn inspect_account(
identity: &Identity, identity: &Identity,
config: &AccountsConfig, config: &AccountsConfig,
@@ -168,7 +167,7 @@ pub fn inspect_account(
Ok(account_status_from_shadow(identity, entry, day)) 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<ActiveSession> { pub fn active_sessions() -> Vec<ActiveSession> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
unsafe { unsafe {
@@ -206,7 +205,7 @@ pub fn active_sessions() -> Vec<ActiveSession> {
sessions 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 { pub fn session_backend() -> SessionBackend {
if logind_markers_present( if logind_markers_present(
Path::new("/run/systemd/system").is_dir(), 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<FaillockStatus, Error> { pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
let helper = &config.faillock_path; let helper = &config.faillock_path;
if !helper.is_absolute() { if !helper.is_absolute() {
@@ -257,8 +256,7 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
Ok(faillock_status_from_output(&output.stdout, max_tries)) Ok(faillock_status_from_output(&output.stdout, max_tries))
} }
/// Parses the legacy count format and current rows with usernames or timestamps. /// Parse legacy counts and timestamp rows; bad output produces zero failures.
/// Malformed output reports zero failures and does not control authentication.
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus { pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
let failures = parse_failure_count(output); let failures = parse_failure_count(output);
let remaining = max_tries.map(|limit| limit.saturating_sub(failures)); let remaining = max_tries.map(|limit| limit.saturating_sub(failures));

View File

@@ -1,12 +1,10 @@
//! Redacted security audit events. //! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information.
//!
//! Audit records exclude secrets, PAM messages, and command arguments.
use std::fmt; use std::fmt;
use crate::error::Error; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction { pub enum AuditAction {
Authentication, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditOutcome { pub enum AuditOutcome {
Started, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PamReason { pub enum PamReason {
AuthenticationRejected, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditEvent<'a> { pub struct AuditEvent<'a> {
pub action: AuditAction, pub action: AuditAction,
pub outcome: AuditOutcome, pub outcome: AuditOutcome,
/// Account name supplied by the user; never a password or token. /// Account name supplied by the user, never a password or token.
pub account: &'a str, pub account: &'a str,
pub pam_reason: Option<PamReason>, pub pam_reason: Option<PamReason>,
} }
@@ -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 { pub const fn pam_reason(status: i32) -> PamReason {
match status { match status {
6 | 7 => PamReason::AuthenticationRejected, 6 | 7 => PamReason::AuthenticationRejected,

View File

@@ -13,7 +13,7 @@ pub const SYSTEM_CONFIG_PATH: &str = "/etc/latchd/config.toml";
const PROJECT_CONFIG_DIR: &str = "latchd"; const PROJECT_CONFIG_DIR: &str = "latchd";
const CONFIG_FILE: &str = "config.toml"; 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<PathBuf> { pub fn user_config_path() -> Option<PathBuf> {
let base = env::var_os("XDG_CONFIG_HOME") let base = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from) .map(PathBuf::from)
@@ -184,7 +184,7 @@ pub struct PowerAction {
pub args: Vec<String>, pub args: Vec<String>,
} }
/// 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<Config, Error> { pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
let mut config = Config::default(); let mut config = Config::default();
if let Some(variables_path) = variables { if let Some(variables_path) = variables {
@@ -231,7 +231,7 @@ fn resolve_variables(input: &str, variables: &HashMap<String, String>) -> 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> { pub fn validate(config: &Config) -> Result<(), Error> {
if config.tty == 0 || config.tty > 63 { if config.tty == 0 || config.tty > 63 {
return Err(Error::Config("tty must be between 1 and 63".into())); return Err(Error::Config("tty must be between 1 and 63".into()));
@@ -314,13 +314,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
Ok(()) 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> { pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
let metadata = let metadata =
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?; fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!( 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() path.display()
))); )));
} }

View File

@@ -1,39 +1,39 @@
use std::io; use std::{fmt::Write as _, io};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("configuration: {0}")] #[error("Configuration error: {0}")]
Config(String), Config(String),
#[error("{context}: {source}")] #[error("I/O error while {context}: {source}")]
Io { Io {
context: String, context: String,
#[source] #[source]
source: io::Error, source: io::Error,
}, },
#[error("parse {path}: {source}")] #[error("TOML error in {path}: {source}")]
Toml { Toml {
path: String, path: String,
#[source] #[source]
source: toml::de::Error, source: toml::de::Error,
}, },
#[error("PAM {operation} failed with status {status}: {message}")] #[error("PAM error during {operation} (status {status}): {message}")]
Pam { Pam {
operation: &'static str, operation: &'static str,
status: i32, status: i32,
message: String, message: String,
}, },
#[error("session: {0}")] #[error("Session error: {0}")]
Session(String), Session(String),
#[error("terminal: {0}")] #[error("Terminal error: {0}")]
Terminal(String), Terminal(String),
#[error("authentication worker: {0}")] #[error("Authentication worker error: {0}")]
Worker(String), Worker(String),
} }
impl Error { 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<i32> { pub fn pam_status(&self) -> Option<i32> {
match self { match self {
Self::Pam { status, .. } => Some(*status), 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 { pub fn requires_password_change(&self) -> bool {
matches!(self.pam_status(), Some(12 | 27)) 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<String> {
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 <account>` to check the account lock state.".into(),
"Check the PAM service file with `sudoedit /etc/pam.d/<pam-service>`.".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 <program>`.".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<String>, source: io::Error) -> Error { pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
@@ -63,3 +141,27 @@ impl<T> ResultExt<T> for io::Result<T> {
self.map_err(|source| io_error(context, source)) 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::<toml::Value>("=").expect_err("invalid TOML"),
};
let report = error.user_report();
assert!(report.contains("'/tmp/latchd config.toml'"));
assert!(report.contains("--config '/tmp/latchd config.toml'"));
}
}

View File

@@ -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)] #![deny(unsafe_op_in_unsafe_fn)]

View File

@@ -1,4 +1,4 @@
//! Bounded, owner-checked persistence for last-used session choices. //! Store the last session choice with private, atomic writes.
use std::{ use std::{
fs::{self, OpenOptions}, fs::{self, OpenOptions},
@@ -32,7 +32,7 @@ pub struct LastUsedPreferences {
} }
impl 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) { pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
if username.is_empty() || session.title.is_empty() { if username.is_empty() || session.title.is_empty() {
return; return;
@@ -49,7 +49,7 @@ impl LastUsedPreferences {
self.entries.truncate(MAX_ENTRIES); 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<usize> { pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
let entry = self let entry = self
.entries .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<LastUsedPreferences, Error> { pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
if !path.is_absolute() { if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into())); return Err(Error::Config("last-session path must be absolute".into()));
@@ -93,7 +93,7 @@ pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
Ok(preferences) 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> { pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
if !path.is_absolute() { if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into())); return Err(Error::Config("last-session path must be absolute".into()));

View File

@@ -1,4 +1,4 @@
//! Session discovery, environment construction, and child lifecycle. //! Discover sessions, build their environments, and run their children.
use std::{ use std::{
ffi::{CString, OsStr}, ffi::{CString, OsStr},
@@ -41,7 +41,7 @@ pub enum SessionKind {
pub struct SessionDescriptor { pub struct SessionDescriptor {
pub kind: SessionKind, pub kind: SessionKind,
pub title: String, pub title: String,
/// Program and arguments, without shell interpretation. /// Program and arguments kept separate from shell parsing.
pub argv: Vec<String>, pub argv: Vec<String>,
pub current_desktop: Option<String>, pub current_desktop: Option<String>,
pub session_desktop: Option<String>, pub session_desktop: Option<String>,
@@ -82,7 +82,7 @@ impl SessionSpec {
format!("tty{}", self.tty) 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<Item = (&str, &str)> { pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
self.environment self.environment
.iter() .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<SessionDescriptor> { pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
scan_desktop_dir( scan_desktop_dir(
@@ -124,7 +124,7 @@ pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
sessions 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> { pub fn preflight(config: &Config) -> Result<(), Error> {
let shell = secure_executable(&config.system_shell, "configured system shell")?; let shell = secure_executable(&config.system_shell, "configured system shell")?;
if !shell.is_file() { if !shell.is_file() {
@@ -192,7 +192,7 @@ fn secure_executable(path: &Path, label: &str) -> Result<PathBuf, Error> {
Ok(resolved) 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( pub fn build_spec(
identity: &Identity, identity: &Identity,
config: &Config, 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> { pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
let home_metadata = fs::symlink_metadata(&identity.home) let home_metadata = fs::symlink_metadata(&identity.home)
.map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?; .map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?;

View File

@@ -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<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
#[derive(Debug)]
enum AuthState {
Idle,
Pending(Receiver<Result<AuthTicket, Error>>),
PasswordChangePending(Receiver<Result<(), Error>>),
Failed,
}
enum AuthCompletion {
Login(Result<AuthTicket, Error>),
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<SessionDescriptor>,
accounts: Vec<Identity>,
account_statuses: Vec<AccountStatus>,
account_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>,
selected: usize,
focus: Focus,
username: String,
password: Zeroizing<String>,
pending_password: Zeroizing<Vec<u8>>,
pending_new_password: Zeroizing<String>,
password_change_mode: bool,
status: String,
auth_state: AuthState,
auth_failures: u32,
auth_blocked_until: Option<Instant>,
show_help: bool,
power_confirmation: Option<usize>,
should_quit: bool,
ascii: bool,
no_color: bool,
background: Background,
notice: Option<Notice>,
animation_frame: usize,
reduced_motion: bool,
filter: Option<FilterState>,
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
preferences: LastUsedPreferences,
preference_path: Option<std::path::PathBuf>,
}
impl App {
fn new(
config: Config,
auth: AuthService,
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
background: Background,
) -> Self {
let username = accounts
.first()
.map(|account| account.username.clone())
.unwrap_or_default();
let account_index = (!accounts.is_empty()).then_some(0);
let reduced_motion = config.visual.reduced_motion;
Self {
config,
auth,
preview,
sessions,
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<Option<LoginSelection>, 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<Option<LoginSelection>, 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()))
}

View File

@@ -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<AuthCompletion> {
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<Option<LoginSelection>, 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)
}

View File

@@ -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;
}
}
}

View File

@@ -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<usize> {
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<usize> {
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)
}
}

View File

@@ -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<ListItem> = 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<String>) = 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
}

View File

@@ -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::<String>())
.collect::<Vec<_>>()
.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::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("terminal too small"));
}
#[test]
fn switching_accounts_clears_password_and_auth_state() {
let auth = AuthService::new().expect("PAM worker should start");
let accounts = vec![
Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
},
Identity {
username: "bob".into(),
uid: 1001,
gid: 1001,
home: "/home/bob".into(),
shell: "/bin/sh".into(),
},
];
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
accounts,
Background::load(&Config::default().visual).expect("default background should load"),
);
app.password = Zeroizing::new("secret".into());
app.next_account();
assert_eq!(app.username, "bob");
assert!(app.password.is_empty());
assert!(matches!(app.auth_state, AuthState::Idle));
}
#[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::<String>();
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);
}

View File

@@ -1,4 +1,4 @@
//! Low-cost terminal wallpaper and procedural ambient animation. //! Draw terminal wallpaper and a small ambient animation.
use std::{ use std::{
fs, fs,
@@ -189,7 +189,7 @@ struct Automata {
impl Automata { impl Automata {
fn resize(&mut self, width: usize, height: usize) { 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 width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT); let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height { if self.width == width && self.height == height {

File diff suppressed because it is too large Load Diff

91
docs/architecture.md Normal file
View File

@@ -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.

159
docs/configuration.md Normal file
View File

@@ -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.

114
docs/development.md Normal file
View File

@@ -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.

View File

@@ -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 ## Check matrix
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.
The Docker test image runs the first layer and starts an isolated Xvfb server | Command | What it covers | Requirement |
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 | `make check` | formatting, Rust tests, Clippy, and rustdoc | Rust toolchain and PAM development files |
container, but a real PAM password rotation and login handoff require a | `make integration-test` | release CLI, configuration validation, preflight, and preview paths | local build |
disposable VM with a dedicated test account and an explicitly provisioned PAM | `make docker-test` | release build, CLI checks, Xvfb, capability probe, and PAM fixture | Docker |
policy. The container probe intentionally reports those checks as capability | `make pam-fixture-test` | denied login, password change, session metadata, and returned environment | root for temporary `/etc/pam.d/` files |
information only. | `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 ```sh
make check
make integration-test make integration-test
make pam-fixture-test
make docker-test make docker-test
make system-e2e 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 ```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 make system-e2e-strict
``` ```
Do not run strict mode on a normal developer workstation unless the VM's ## Reading failures
display-manager handoff and test PAM policy are isolated from the host.
- `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

208
docs/guide.md Normal file
View File

@@ -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.

View File

@@ -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 ## 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. ## Capture the screen
- 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.
These messages show account state before login. PAM accepts or rejects the login. Build the Docker test image, then start a PTY with a fixed size:
## Capture method
The capture uses a Docker PTY and the release binary from `latchd-test:local`:
```sh ```sh
docker build -f Dockerfile.test -t latchd-test:local .
tmux new-session -d -x 120 -y 36 \ tmux new-session -d -x 120 -y 36 \
'docker run --rm -it latchd-test:local /workspace/target/release/latchd --preview --no-log' '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.

View File

@@ -24,7 +24,7 @@ max_display_length = 18
remember_last_session = true remember_last_session = true
last_session_path = "/var/lib/latchd/last-session.toml" 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] [power_controls]
entries = [] entries = []

View File

@@ -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 package auth
/* /*
@@ -7,11 +7,11 @@ package auth
#include <stdlib.h> #include <stdlib.h>
#include <stdint.h> #include <stdint.h>
// 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 **, extern int latchd_pam_conv(int, struct pam_message **,
struct pam_response **, void *); 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) { static void *storeHandle(uintptr_t h) {
uintptr_t *p = malloc(sizeof(uintptr_t)); uintptr_t *p = malloc(sizeof(uintptr_t));
if (p) *p = h; if (p) *p = h;

View File

@@ -44,7 +44,7 @@ func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, er
dd = "0" + dd 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) signal.Ignore(syscall.SIGUSR1)
cmd := exec.Command(cfg.SystemShell, "-c", cmd := exec.Command(cfg.SystemShell, "-c",

View File

@@ -1,5 +1,5 @@
// The renderer packs two cell rows into each Unicode half-block row. // Pack two cell rows into each Unicode half-block row; two buffers keep frame
// Two buffers let Tick update the grid without an allocation per frame. // updates allocation-free.
package bg package bg
import ( import (

View File

@@ -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)] #![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)] #![warn(missing_docs)]
@@ -25,7 +25,7 @@ const BUILD_VERSION: &str = env!("LATCHD_VERSION");
const BUILD_COMMIT: &str = env!("LATCHD_COMMIT"); const BUILD_COMMIT: &str = env!("LATCHD_COMMIT");
const BUILD_TIME: &str = env!("LATCHD_BUILD_TIME"); const BUILD_TIME: &str = env!("LATCHD_BUILD_TIME");
/// Command-line options for latchd. /// Command-line options for the login service.
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
name = "latchd", name = "latchd",
@@ -36,7 +36,7 @@ struct Cli {
/// Configuration TOML path. /// Configuration TOML path.
#[arg(short, long)] #[arg(short, long)]
config: Option<PathBuf>, config: Option<PathBuf>,
/// Optional variable TOML path. /// TOML file with values for text substitution.
#[arg(short = 'v', long)] #[arg(short = 'v', long)]
variables: Option<PathBuf>, variables: Option<PathBuf>,
/// Override the configured virtual terminal. /// Override the configured virtual terminal.
@@ -51,26 +51,26 @@ struct Cli {
/// Override the session PATH. /// Override the session PATH.
#[arg(long)] #[arg(long)]
initial_path: Option<String>, initial_path: Option<String>,
/// Run without authenticating or starting a real session. /// Run the screen without authentication or session startup.
#[arg(long)] #[arg(long)]
preview: bool, preview: bool,
/// Validate configuration and exit without starting the TUI. /// Validate the configuration and sessions without opening the screen.
#[arg(long, alias = "preflight")] #[arg(long, alias = "preflight")]
no_tui: bool, no_tui: bool,
/// Do not create a main log. /// Skip the main log file.
#[arg(long)] #[arg(long)]
no_log: bool, no_log: bool,
/// Print the validated configuration and exit. /// Print build information and selected configuration values.
#[arg(long)] #[arg(long)]
show_config: bool, show_config: bool,
/// Print embedded release metadata without accessing configuration or PAM. /// Print embedded build metadata.
#[arg(long)] #[arg(long)]
build_info: bool, build_info: bool,
} }
fn main() -> color_eyre::Result<()> { fn main() -> color_eyre::Result<()> {
color_eyre::install()?; 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> { fn run() -> Result<(), Error> {

View File

@@ -1,5 +1,5 @@
#!/bin/sh #!/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 set -eu
binary=${1:-target/debug/latchd} binary=${1:-target/debug/latchd}

View File

@@ -1,5 +1,5 @@
#!/bin/sh #!/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 set -u
binary=${1:-} binary=${1:-}
@@ -43,7 +43,7 @@ else
skip "latchd binary not supplied; capability-only probe" skip "latchd binary not supplied; capability-only probe"
fi 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 if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then
pass "PAM runtime library is available" pass "PAM runtime library is available"
else else
@@ -65,14 +65,14 @@ else
skip "faillock helper is unavailable" skip "faillock helper is unavailable"
fi 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 if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then
pass "Linux virtual terminal device is available" pass "Linux virtual terminal device is available"
else else
skip "Linux virtual terminal is unavailable (container or non-console host)" skip "Linux virtual terminal is unavailable (container or non-console host)"
fi fi
# Xvfb tests X11 without access to the host display. # Xvfb checks X11 without using the host display.
if need_command Xvfb; then if need_command Xvfb; then
display_number=$((100 + ($$ % 500))) display_number=$((100 + ($$ % 500)))
display=":$display_number" display=":$display_number"
@@ -106,7 +106,7 @@ else
skip "Xvfb is unavailable" skip "Xvfb is unavailable"
fi 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= wayland_socket=
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
case "$WAYLAND_DISPLAY" in case "$WAYLAND_DISPLAY" in