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