Compare commits

...

13 Commits

Author SHA1 Message Date
2996bac2f0 chore: remove tracked editor settings 2026-10-06 14:58:11 +05:30
9e57cf2644 merge: sync GitLab main before release push
# Conflicts:
#	CHANGELOG.md
#	Dockerfile.test
#	Makefile
#	README.md
#	crates/latchd-auth/tests/pam_fixture.rs
#	crates/latchd-core/src/config.rs
#	crates/latchd-core/src/lib.rs
#	crates/latchd-core/src/preferences.rs
#	crates/latchd-core/src/session.rs
#	crates/latchd-tui/src/lib.rs
#	docs/e2e-testing.md
#	scripts/test-pam-fixture.sh
2026-10-05 21:40:43 +05:30
f2a885d621 refactor: split the TUI and refresh project docs
Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds.

Checks: make check; make integration-test; go test ./... (legacy/go).
2026-10-05 21:37:15 +05:30
de8843471b feat: support user preview configuration and document UI 2026-10-05 19:20:55 +05:30
ee6186ed0b feat: complete session preferences and PAM fixture gates 2026-10-05 17:50:15 +05:30
f266a992b4 feat: complete session preferences and PAM fixture gates 2026-09-26 02:44:29 +05:30
83345a1842 feat: handle PAM password policy changes 2026-09-26 01:50:27 +05:30
9492dbd7c9 feat: advance login workflow and integration gates 2026-09-26 01:18:57 +05:30
71de5bc6cd fix(ci): expose cached cargo-audit binary 2026-09-25 22:14:57 +05:30
860108d0da fix(ci): run Docker verification with POSIX shell 2026-09-25 22:13:31 +05:30
0617cf5472 Merge remote-tracking branch 'origin/main' 2026-09-25 21:34:20 +05:30
faf5b27328 feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
2026-09-25 21:33:46 +05:30
006a6f592a Add LICENSE 2026-07-18 17:45:10 +00:00
73 changed files with 10191 additions and 947 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
target
build
dist
legacy
.git
tui-design-skill/.git

View File

@@ -1,16 +0,0 @@
name: Integration Check CI
on:
push:
branches:
- main
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix -L flake check

View File

@@ -1,296 +0,0 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v4
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v4
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v4
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v4
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
announce:
needs:
- plan
- host
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive

View File

@@ -1,54 +0,0 @@
on:
pull_request:
push:
branches:
- main
name: Continuous integration
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: check
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add clippy
- uses: actions-rs/cargo@v1
with:
command: clippy
args: -- -D warnings

19
.gitignore vendored
View File

@@ -1,9 +1,22 @@
/target
/dist/
go_project_build_documentation.md
documentation.md
session.md
build/*
examples/*
build/
examples/
things_todo.md
ly/*
ly/
# The Rust implementation replaced this reference tree.
/legacy/go/
tui-design-skill/
FEATURE_CHECKLIST.md
IMPLEMENTATION_CHECKLIST.md
audit_code.md
audit_rust_code.md
.latchd-work/
reference.md
.vscode/

130
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,130 @@
stages:
- verify
- container
- package
- release
workflow:
rules:
- if: '$CI_COMMIT_TAG'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
variables:
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
CARGO_TERM_COLOR: never
RUST_BACKTRACE: "1"
.rust-job:
image: rust:1.88.0-bookworm
before_script:
- apt-get update
- >-
apt-get install --no-install-recommends -y
libpam0g-dev libpam-modules pkg-config xauth xvfb util-linux
- rustup component add rustfmt clippy
cache:
key:
files:
- Cargo.lock
- rust-toolchain.toml
paths:
- .cargo/registry/index/
- .cargo/registry/cache/
- .cargo/git/db/
- .cargo/bin/
- target/
rust-verify:
extends: .rust-job
stage: verify
script:
- make check COLOR=0
- make integration-test COLOR=0
- make system-e2e COLOR=0
- make version COLOR=0
artifacts:
when: always
expire_in: 7 days
paths:
- target/debug/deps/
security-audit:
image: rust:1.88.0-bookworm
stage: verify
cache:
key: cargo-audit-0.22.2
paths:
- .cargo/bin/
- .cargo/registry/index/
- .cargo/registry/cache/
before_script:
- export PATH="$CARGO_HOME/bin:$PATH"
- command -v cargo-audit >/dev/null || cargo install cargo-audit --locked --version 0.22.2
script:
- cargo audit
docker-verify:
image: docker:27.5.1-cli
stage: container
services:
- name: docker:27.5.1-dind
alias: docker
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
before_script:
- until docker info >/dev/null 2>&1; do sleep 1; done
script:
- ./scripts/test-docker.sh
rules:
- if: '$CI_COMMIT_BRANCH'
- if: '$CI_COMMIT_TAG'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
package:
extends: .rust-job
stage: package
needs:
- job: rust-verify
artifacts: false
- job: docker-verify
artifacts: false
- job: security-audit
artifacts: false
rules:
- if: '$CI_COMMIT_TAG =~ /^v?[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$/'
script:
- VERSION="${CI_COMMIT_TAG#v}" make package COLOR=0 TAG="$CI_COMMIT_TAG"
- (cd dist && sha256sum --check "$(basename "$(ls *.tar.gz)").sha256")
- printf 'PACKAGE_JOB_ID=%s\n' "$CI_JOB_ID" >> dist/release.env
artifacts:
name: "latchd-${CI_COMMIT_TAG}"
expire_in: 30 days
paths:
- dist/
reports:
dotenv: dist/release.env
release:
image: registry.gitlab.com/gitlab-org/cli:v1.103.0
stage: release
needs:
- job: package
artifacts: true
rules:
- if: '$CI_COMMIT_TAG =~ /^v?[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$/'
script:
- test -n "$PACKAGE_JOB_ID"
- test -f dist/release.env
release:
tag_name: "$CI_COMMIT_TAG"
name: "latchd ${CI_COMMIT_TAG}"
description: "Production artifacts and checksums are attached from the reproducible package job."
assets:
links:
- name: "latchd package and SHA-256"
url: "${CI_PROJECT_URL}/-/jobs/${PACKAGE_JOB_ID}/artifacts/download?file_type=archive"
link_type: package

56
.vscode/sessions.json vendored
View File

@@ -1,56 +0,0 @@
{
"$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v11/terminal-keeper.json",
"theme": "tribe",
"active": "default",
"activateOnStartup": true,
"keepExistingTerminals": false,
"sessions": {
"default": [
{
"name": "hello",
"autoExecuteCommands": true,
"icon": "person",
"color": "terminal.ansiGreen",
"commands": [
"echo hello"
]
},
[
{
"name": "docker:ros",
"commands": [
""
]
},
{
"name": "docker:k8s",
"commands": [
""
]
}
],
[
{
"name": "docker:nats",
"commands": [
""
]
},
{
"name": "docker:fleet",
"commands": [
""
]
}
]
],
"saved-session": [
{
"name": "connect",
"commands": [
""
]
}
]
}
}

View File

@@ -1,6 +0,0 @@
{
"cSpell.words": [
"BSPWM"
],
"go.testExplorer.enable": false
}

View File

@@ -1,57 +1,74 @@
# Changelog
## 0.1.0 — Unreleased
## Unreleased
first public release
- TUI module layout — `crates/latchd-tui/src/lib.rs` held state, input, authentication, discovery, rendering, and tests in one file; split those responsibilities into `app.rs`, `app/auth.rs`, `app/discovery.rs`, `app/input.rs`, `app/render.rs`, and `app/tests.rs`, which makes each change surface easier to locate
- error reports — `crates/latchd-core/src/error.rs::Error::user_report` now adds a detail line and recovery commands; `rust/main.rs` prints the report for CLI failures instead of one short error line
- TUI checks — keep the render and state tests beside the new modules; `cargo test --locked --workspace --all-targets` covers the split without changing the login flow
- service configuration — document `/etc/latchd/config.toml` as the service file and keep preview, build, and end-to-end instructions aligned with the Makefile
### What's In
## 0.5.0 — password and session handoff
- tui login screen with username/password fields, session switcher, and status messages
- supports x11, wayland, and tty sessions
- pam authentication via cgo (thread-locked for safety)
- faillock integration — detects lockouts, shows remaining attempts, admin bypass via ctrl+u
- conway's game of life background rendered at 30 fps with unicode half-block characters
- spring animation on switcher transitions
- shake animation on failed login
- caps lock indicator via ioctl
- configurable power controls (shutdown/reboot on f1/f2 by default)
- toml configuration with `$VARIABLE` substitution
- preview mode (`--preview`) to test the login screen without leaving your session
- utmpx user accounting (glibc only)
- x11 session setup — xauthority cookies, sigusr1 handshake, server timeout
- privilege dropping for child processes
- log piping with 64 mb cap
- password-change state — before this change, an expired password had no separate screen state; `crates/latchd-tui/src/app/auth.rs` now switches to a new-password prompt for `PAM_NEW_AUTHTOK_REQD` and `PAM_AUTHTOK_EXPIRED`, then starts authentication again after the change
- password conversation — `crates/latchd-auth/src/lib.rs::converse` handles the current-password and new-password prompts in order; tests cover both prompt sequences in `crates/latchd-auth/src/lib.rs`
- audit events — `crates/latchd-core/src/audit.rs` records authentication, password-change, and session results with stable `PamReason` values, so the service log can group failures without copying module text into the event fields
- session preferences — `crates/latchd-core/src/preferences.rs` stores one bounded entry per account, writes through a private temporary file, and replaces the target atomically; invalid ownership or file mode stops the save
- Wayland handoff — `crates/latchd-core/src/session.rs::build_spec` carries the selected session type, desktop, seat, VT, runtime directory, session ID, and D-Bus address into `latchd-auth` before the session opens
- PAM fixture — `crates/latchd-auth/tests/pam_fixture.rs` covers denied authentication, password change, session metadata, and child environment values; `scripts/test-pam-fixture.sh` installs the temporary service files for the test run
- test coverage — `crates/latchd-core`, `crates/latchd-auth`, and `crates/latchd-tui` now test configuration, account state, session parsing, authentication conversations, preferences, rendering, and background animation
### Recent Fixes
## 0.4.0 — account and session workflow
- **DesktopNames parsing** — `.desktop` files now parse the `DesktopNames` field. the first semicolon-delimited entry becomes `XDG_SESSION_DESKTOP`, and the full list with `:` separators becomes `XDG_CURRENT_DESKTOP`. fixes GNOME/KDE sessions not getting proper environment variables
- **utmpx accounting wired** — `AddUtmpx` and `Close` are now called from `spawnSession` so user logins appear in `who`/`w`/`last`
- **fixed hardcoded shell** — `Credentials.Shell` now reads from `/etc/passwd` instead of always returning `/bin/sh`. removes duplicate `userShell` logic in exec.go
- **automata background wired** — `Automata.Frame()` is now rendered in `View()` as the base layer with the login form card overlaid using ANSI cursor positioning. the form has a dark card background for readability
- **clock widget** — added a datetime display (`Mon Jan 02 15:04:05`) to the top bar, updated every tick
- **session specifier label** — the environment switcher now shows the session type (`x11`, `wayland`) above the session name, like ly's specifier
- **form card background** — login content is rendered inside a padded card with a dark background so it reads cleanly over the animated automata
- account status — `crates/latchd-core/src/accounts.rs::inspect_account` reads lock markers, password age, account expiry, inactivity, login shell, and shadow availability; the TUI shows the matching state beside the selected account
- faillock parsing — `crates/latchd-core/src/accounts.rs::faillock_status_from_output` accepts legacy counts, username prefixes, and timestamp rows; malformed output or a timeout removes the extra count from the screen
- account switching — `crates/latchd-tui/src/app/input.rs::select_account_index` changes the account, clears the password, resets authentication state, and reapplies the saved session for the new account
- session refresh — `crates/latchd-tui/src/app/discovery.rs::start_refresh` moves account and session discovery to a worker; the screen keeps accepting input while the lists refresh
- filtering — `crates/latchd-tui/src/app/input.rs::matches_filter` adds smart-case filtering for account and session names; uppercase query characters select case-sensitive matching
- terminal display — `crates/latchd-tui/src/app/render.rs` adds high-contrast styles, footer hints, account status text, notifications, and a minimum terminal size of 60 columns by 16 rows
- authentication backoff — `crates/latchd-tui/src/app/auth.rs::auth_backoff` delays repeated failures from one to sixteen seconds and resets the delay when the account changes
- host checks — `scripts/test-cli.sh` and `scripts/test-system-e2e.sh` cover CLI preflight and the available PAM, faillock, VT, X11, Wayland, and systemd-logind capabilities
- VM gate — `docs/e2e-testing.md` lists the host requirements for the strict check and the commands for a disposable VM
- version — set the workspace package version to `0.4.0` in `Cargo.toml`
- **utmpx time field portability** — replaced hardcoded `C.__uint32_t` / `C.__int32_t` casts with a C helper function (`auth/utmpx_time.h`) that lets the compiler handle type conversion. fixes build failure on systems where `ut_tv` uses `struct timeval` instead of the 32/64 compat struct
- **cgo pointer safety for pam** — replaced `unsafe.Pointer(&h)` (Go stack pointer passed to C) with `storeHandle`/`loadHandle` helpers that keep the `cgo.Handle` in C-heap memory. fixes `cgo argument has Go pointer to unpinned Go pointer` panic on Go 1.22+
- **pam conversation callback** — fixed `unsafe.Slice` using `&r` (pointer-to-pointer) as the backing array instead of `r` (the calloc'd response array), which wrote password bytes into random stack memory. also now only responds to `PAM_PROMPT_ECHO_OFF`/`PAM_PROMPT_ECHO_ON` messages — info and error messages get nil. fixes `pam_unix: auth could not identify password`
- **cgo handle lifecycle** — moved `cgo.Handle` and its C-heap backing from `Validate` (where they were `defer`-freed on return) into the `Credentials` struct so they stay alive through `OpenSession` and until `CloseSession`. fixes `misuse of an invalid Handle` panic
- **session spawning** — replaced the broken `ForkExec` (which relaunched the same binary with made-up flags) with a direct `session.Spawn` call that drops privileges and runs the desktop in-process. fixes `flag provided but not defined: -user`
- **renamed extra/ files** — `lemurs.pam` → `latchd.pam`, `lemurs.service` → `latchd.service`
- **readme credits** — added a credits section acknowledging lemurs for the `extra/` directory structure and session scanning approach
- **makefile `enable` and `update` targets** — `make enable` stops any running DM, disables the aliased display-manager, and enables latchd. `make update` runs uninstall → git pull → reinstall in one command
- **missing `extra/` directory** — added `extra/config.toml`, `extra/xsetup.sh`, `extra/lemurs.pam`, and `extra/lemurs.service` so `make install` can find them
- **makefile `deps` target** — added `make deps` to automatically install `libpam0g-dev` and `build-essential`, and verify go is present
- **readme overhaul** — rewrote for clarity: added "what problem does this solve", "how it works", known issues, simpler install instructions, and proper dependency listing
- **view cleanup** — removed the non-functional automata background overlay from `View()` (the `Frame()` was never called, and the black-space placeholder caused `EEEE` garbage characters on some terminals). also removed a hardcoded 80-char width on the top bar so it adapts to the terminal
- **session environment setup** — `spawnSession` now calls `SetBasicVariables`, `SetDisplay`, `SetSessionParams`, `SetSeatVars`, `SetSessionVars`, and `SetXDGCommonPaths` before spawning the desktop. fixes `setupX` failing immediately with `missing DISPLAY/XDG_VTNR/HOME env vars` because none were set
- **pam session error made non-fatal** — `pam_open_session` failure (e.g. `pam_systemd` rejecting the DM process as session leader) is now logged as a warning instead of blocking the entire session spawn. also removed `session include login` from `latchd.pam` since the DM process can't register user sessions with systemd-logind
- **global session switching with `,` and `.`** — sessions can now be switched from any mode (not just when the switcher is focused). arrows still work when switcher is focused
## 0.3.0 — Rust workspace release
### What's Missing
- Rust service — `rust/main.rs` became the supported login-manager entry point; the Go prototype stayed under `legacy/go/` and is excluded from normal builds
- workspace crates — split authentication, configuration, accounts, sessions, and the TUI into `latchd-core`, `latchd-auth`, and `latchd-tui`
- session commands — `crates/latchd-core/src/session.rs::parse_argv` stores a program and argument list instead of sending desktop entries through a shell, which rejects shell operators and desktop field codes
- terminal effects — `crates/latchd-tui/src/background.rs` adds wallpaper loading, a bounded cellular animation, reduced motion, and the background toggle
- release pipeline — `.gitlab-ci.yml` and `Makefile` add format, test, Clippy, rustdoc, Docker, version, package, checksum, and release targets
- deployment gate — `scripts/test-system-e2e.sh` and `docs/e2e-testing.md` separate container checks from live VT, display, and logind checks
- no tests
- session leader is the latchd process, not the desktop — pam session tracking via systemd-logind may be imperfect
- not all config options affect the rendering yet
- occasional terminal state corruption on abrupt exit
- automata background visible at screen edges; form card covers the center
## Legacy Go history
The entries below describe the first Go prototype. The Rust workspace does not build or ship this code.
## 0.1.0 — first public prototype
- login screen — `legacy/go/login/login.go` added username and password fields, a session switcher, status messages, and preview mode
- session types — `legacy/go/session/scan.go` added X11, Wayland, script, and TTY entries
- authentication — `legacy/go/auth/pam.go` added cgo login support with one OS thread per PAM transaction
- faillock — `legacy/go/auth/faillock.go` added lockout counts, remaining attempts, and the administrator reset key
- terminal effects — `legacy/go/tui/bg/automata.go` added the cellular background, switcher motion, and failed-login shake
- terminal details — `legacy/go/login/login.go` added the caps-lock indicator, power keys, clock, and preview mode
- configuration — `legacy/go/config/config.go` added configuration parsing, variable replacement, and configurable power programs
- accounting — `legacy/go/auth/utmpx.go` added UTMPX records for `who`, `w`, and `last` on glibc
- X11 startup — `legacy/go/session/x11.go` added Xauthority cookies, the `SIGUSR1` readiness signal, and a server timeout
- privilege drop — `legacy/go/session/exec.go` started the selected child with the account UID, GID, and supplementary groups
- logs — `legacy/go/session/exec.go` capped the client log at 64 MiB
### Fixes
- DesktopNames parsing — `legacy/go/session/scan.go::splitDesktopNames` previously dropped desktop names from the session environment; it now maps the first semicolon-delimited value to `XDG_SESSION_DESKTOP` and the full list to `XDG_CURRENT_DESKTOP`, which fixes GNOME and KDE sessions that start without their desktop values
- UTMPX time fields — `legacy/go/auth/utmpx.go` used fixed C integer types for `ut_tv`; `legacy/go/auth/utmpx_time.h` now accepts the platform time fields, which fixes builds where `ut_tv` uses `struct timeval`
- cgo pointer storage — `legacy/go/auth/pam.go` passed a Go pointer through `pam_conv`; `storeHandle` and `loadHandle` keep the numeric handle in C memory, which fixes the Go 1.22 pointer check
- PAM conversation — `legacy/go/auth/pam.go::latchd_pam_conv` used a pointer-to-pointer as the response slice; it now writes to the allocated response array and answers only prompt messages, which fixes corrupted responses and the `pam_unix` password error
- cgo handle lifetime — `legacy/go/auth/pam.go::Credentials` now owns the handle until session close; the previous `Validate` cleanup released it before `OpenSession`
- session spawning — `legacy/go/session/exec.go` starts the selected desktop directly with dropped credentials; the previous path relaunched latchd with an undefined `-user` flag
- file names — rename the `lemurs` policy and unit files in `extra/` to `latchd`
- extra files — add `extra/config.toml`, `extra/xsetup.sh`, `extra/latchd.pam`, and `extra/latchd.service`, which lets `make install` find its source files
- Make targets — add `deps`, `enable`, and `update` targets for dependency checks, service enablement, and local updates
- terminal rendering — remove the unused background layer and fixed top-bar width from the Go renderer, which fixes garbage characters and narrow-terminal layout errors
- session environment — `legacy/go/session/env.go` now sets display, VT, desktop, seat, XDG, and home values before the desktop starts, which fixes missing `DISPLAY`, `XDG_VTNR`, and `HOME`
- session startup — handle `pam_open_session` errors as session log entries and remove the obsolete `login` include from the prototype service file
- global session switching — accept `,` and `.` from every screen state while keeping arrow keys for the focused switcher

2296
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

40
Cargo.toml Normal file
View File

@@ -0,0 +1,40 @@
[workspace]
members = [".", "crates/latchd-core", "crates/latchd-auth", "crates/latchd-tui"]
resolver = "3"
[workspace.package]
version = "0.5.0"
edition = "2024"
rust-version = "1.88"
license = "Apache-2.0"
[package]
name = "latchd"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "A secure terminal login manager for Linux"
license.workspace = true
[[bin]]
name = "latchd"
path = "rust/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
color-eyre = "0.6"
latchd-auth = { path = "crates/latchd-auth" }
latchd-core = { path = "crates/latchd-core" }
latchd-tui = { path = "crates/latchd-tui" }
libc = "0.2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
debug = false
incremental = false
panic = "abort"
strip = "symbols"

26
Dockerfile.test Normal file
View File

@@ -0,0 +1,26 @@
FROM rust:1.88.0-bookworm
ENV CARGO_TERM_COLOR=never
ENV RUST_BACKTRACE=1
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
libpam0g-dev libpam-modules xauth xvfb pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY build.rs ./
COPY rust ./rust
COPY crates ./crates
COPY scripts ./scripts
RUN cargo fmt --all -- --check \
&& cargo test --locked --workspace --all-targets \
&& cargo clippy --locked --workspace --all-targets --all-features -- -D warnings \
&& RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \
&& cargo build --locked --release \
&& ./scripts/test-cli.sh target/release/latchd \
&& ./scripts/test-system-e2e.sh target/release/latchd \
&& install -m 0664 /dev/null /run/utmp \
&& ./scripts/test-pam-fixture.sh

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Zane Walker
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

301
Makefile
View File

@@ -1,146 +1,193 @@
BINARY := latchd
SRC_DIR := src
CMD_DIR := $(SRC_DIR)
BUILD_DIR := build
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
PREFIX ?= /usr
BINDIR := $(DESTDIR)$(PREFIX)/bin
CONFDIR := $(DESTDIR)/etc/$(BINARY)
SYSTEMD := $(DESTDIR)/usr/lib/systemd/system
PROJECT := latchd
BINARY := latchd
PREFIX ?= /usr
DESTDIR ?=
BINDIR := $(DESTDIR)$(PREFIX)/bin
CONFDIR := $(DESTDIR)/etc/$(PROJECT)
USER_CONFIG_HOME ?= $(if $(XDG_CONFIG_HOME),$(XDG_CONFIG_HOME),$(HOME)/.config)
USER_CONFDIR := $(USER_CONFIG_HOME)/$(PROJECT)
SYSTEMD_DIR := $(DESTDIR)/usr/lib/systemd/system
BUILD_DIR ?= build
DIST_DIR ?= dist
CARGO_TARGET_DIR ?= target
CARGO ?= cargo
GO ?= go
TARGET ?=
TAG ?=
VERSION ?= 0.1.0
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILDDATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
VERSION ?= $(shell sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
GIT_COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf unknown)
SOURCE_DATE_EPOCH ?= $(shell git log -1 --format=%ct 2>/dev/null || date +%s)
TARGET_TRIPLE ?= $(shell rustc -vV 2>/dev/null | sed -n 's/^host: //p' || printf unknown)
LDFLAGS := -s -w \
-X main.Version=$(VERSION) \
-X main.Commit=$(COMMIT) \
-X main.BuildTime=$(BUILDDATE)
TARGET_ARGS := $(if $(TARGET),--target $(TARGET),)
RELEASE_DIR := $(CARGO_TARGET_DIR)/$(if $(TARGET),$(TARGET)/)release
PACKAGE_NAME := $(PROJECT)-$(VERSION)-$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))
PACKAGE_FILE := $(DIST_DIR)/$(PACKAGE_NAME).tar.gz
PACKAGE_STAGE := $(DIST_DIR)/stage/$(PACKAGE_NAME)
CARGO_ENV := LATCHD_VERSION=$(VERSION) LATCHD_COMMIT=$(GIT_COMMIT) LATCHD_BUILD_TIME=$(SOURCE_DATE_EPOCH) SOURCE_DATE_EPOCH=$(SOURCE_DATE_EPOCH)
GO := go
GOFLAGS := -trimpath -ldflags="$(LDFLAGS)"
COLOR ?= 1
ifeq ($(COLOR),1)
RESET := \033[0m
DIM := \033[2m
CYAN := \033[36m
GREEN := \033[32m
YELLOW := \033[33m
RED := \033[31m
else
RESET :=
DIM :=
CYAN :=
GREEN :=
YELLOW :=
RED :=
endif
## ansi helpers
BOLD := \033[1m
DIM := \033[2m
RED := \033[31m
GREEN := \033[32m
YELLOW := \033[33m
BLUE := \033[34m
CYAN := \033[36m
WHITE := \033[37m
RST := \033[0m
.PHONY: all deps build release-build test check fmt lint doc audit docker-test \
integration-test pam-fixture-test system-e2e system-e2e-strict \
package release release-check version install install-user uninstall clean run-preview \
legacy-build legacy-check enable update help
## ─── targets ────────────────────────────────────────────────
all: release-build ## Build the optimized release binary
.PHONY: all deps build install uninstall clean test lint fmt vet \
run-preview release enable update help
version: ## Print the package, commit, and reproducibility metadata
@printf 'version=%s\ncommit=%s\nepoch=%s\ntarget=%s\n' \
'$(VERSION)' '$(GIT_COMMIT)' '$(SOURCE_DATE_EPOCH)' '$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))'
deps: ## install system dependencies
@printf " $(BLUE)%-10s$(RST) checking dependencies...\n" "DEPS"
@if ! dpkg -s libpam0g-dev >/dev/null 2>&1; then \
printf " $(YELLOW)%-10s$(RST) sudo apt install -y libpam0g-dev\n" ""; \
sudo apt install -y libpam0g-dev; \
deps: ## Check native dependencies needed for builds and tests
@command -v $(CARGO) >/dev/null || { echo 'cargo (Rust 1.88+) is required'; exit 1; }
@command -v rustfmt >/dev/null || { echo 'rustfmt is required'; exit 1; }
@command -v pkg-config >/dev/null || { echo 'pkg-config is required'; exit 1; }
@pkg-config --exists pam || { echo 'libpam development files are required'; exit 1; }
@printf '$(GREEN)dependencies ok$(RESET)\n'
build: release-check ## Build and copy the optimized binary to build/
@mkdir -p '$(BUILD_DIR)'
@printf '$(CYAN)building$(RESET) %s %s\n' '$(VERSION)' '$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))'
@env $(CARGO_ENV) $(CARGO) build --release --locked $(TARGET_ARGS)
@install -m755 '$(RELEASE_DIR)/$(BINARY)' '$(BUILD_DIR)/$(BINARY)'
@'$(BUILD_DIR)/$(BINARY)' --version
release-build: build ## Alias for the optimized release build
fmt: ## Check Rust formatting
@$(CARGO) fmt --all -- --check
test: ## Run all Rust tests
@$(CARGO) test --locked --workspace --all-targets
lint: ## Run Clippy with warnings denied
@$(CARGO) clippy --locked --workspace --all-targets --all-features -- -D warnings
doc: ## Build rustdoc with warnings denied
@RUSTDOCFLAGS='-D warnings' $(CARGO) doc --locked --workspace --no-deps
audit: ## Run cargo-audit when installed
@command -v cargo-audit >/dev/null || { echo 'cargo-audit is required; install with cargo install cargo-audit --locked'; exit 1; }
@cargo audit
check: fmt test lint doc ## Run the complete local verification gate
docker-test: ## Run the pinned Docker verification suite
@./scripts/test-docker.sh
integration-test: build ## Exercise safe, non-authenticating CLI paths
@./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)'
pam-fixture-test: ## Run the disposable PAM fault-injection tests when privileged
@./scripts/test-pam-fixture.sh
system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges
@./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)'
system-e2e-strict: ## Require all real login-manager capabilities (prepared VM only)
@./scripts/test-system-e2e.sh --strict '$(BUILD_DIR)/$(BINARY)'
release-check: ## Validate a release version and optional tag
@[[ '$(VERSION)' =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$$ ]] || { echo 'invalid VERSION: $(VERSION)'; exit 1; }
@cargo_version="$$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"; \
[[ "$$cargo_version" == '$(VERSION)' ]] || { echo "Cargo version $$cargo_version does not match $(VERSION)"; exit 1; }
@if [[ -n '$(TAG)' ]]; then \
tag_version="$$(printf %s '$(TAG)' | sed 's/^v//')"; \
[[ "$$tag_version" == '$(VERSION)' ]] || { echo "tag $(TAG) does not match $(VERSION)"; exit 1; }; \
fi
@if ! dpkg -s build-essential >/dev/null 2>&1; then \
printf " $(YELLOW)%-10s$(RST) sudo apt install -y build-essential\n" ""; \
sudo apt install -y build-essential; \
package: release-check build ## Create a deterministic install archive and checksum
@rm -rf '$(PACKAGE_STAGE)'
@mkdir -p '$(PACKAGE_STAGE)/usr/bin' '$(PACKAGE_STAGE)/usr/lib/systemd/system' \
'$(PACKAGE_STAGE)/etc/pam.d' '$(PACKAGE_STAGE)/etc/$(PROJECT)/wms' '$(PACKAGE_STAGE)/etc/$(PROJECT)/wayland'
@install -m755 '$(BUILD_DIR)/$(BINARY)' '$(PACKAGE_STAGE)/usr/bin/$(BINARY)'
@install -m644 extra/config.toml '$(PACKAGE_STAGE)/etc/$(PROJECT)/config.toml'
@install -m644 extra/latchd.pam '$(PACKAGE_STAGE)/etc/pam.d/$(PROJECT)'
@install -m644 extra/latchd.service '$(PACKAGE_STAGE)/usr/lib/systemd/system/$(PROJECT).service'
@install -m644 README.md LICENSE CHANGELOG.md '$(PACKAGE_STAGE)/'
@printf 'version=%s\ncommit=%s\nepoch=%s\ntarget=%s\n' \
'$(VERSION)' '$(GIT_COMMIT)' '$(SOURCE_DATE_EPOCH)' '$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))' \
> '$(PACKAGE_STAGE)/BUILD-METADATA'
@mkdir -p '$(DIST_DIR)'
@tar --sort=name --numeric-owner --owner=0 --group=0 --mtime='@$(SOURCE_DATE_EPOCH)' \
-C '$(DIST_DIR)/stage' -czf '$(PACKAGE_FILE)' '$(PACKAGE_NAME)'
@(cd '$(DIST_DIR)' && sha256sum '$(notdir $(PACKAGE_FILE))' > '$(notdir $(PACKAGE_FILE)).sha256')
@(cd '$(DIST_DIR)' && sha256sum --check '$(notdir $(PACKAGE_FILE)).sha256')
@checksum="$$(sha256sum '$(PACKAGE_FILE)' | cut -d' ' -f1)"; \
printf 'package=%s\nsha256=%s\nversion=%s\n' \
'$(PACKAGE_FILE)' "$$checksum" '$(VERSION)' > '$(DIST_DIR)/release.env'
@printf '$(GREEN)created$(RESET) %s\n' '$(PACKAGE_FILE)'
release: check audit integration-test system-e2e docker-test package ## Run all gates and produce release artifacts
install: build ## Install the binary, config, PAM policy, and systemd unit
@install -Dm755 '$(BUILD_DIR)/$(BINARY)' '$(BINDIR)/$(BINARY)'
@install -Dm644 extra/config.toml '$(CONFDIR)/config.toml'
@install -Dm644 extra/latchd.pam '$(DESTDIR)/etc/pam.d/$(PROJECT)'
@install -Dm644 extra/latchd.service '$(SYSTEMD_DIR)/$(PROJECT).service'
@install -d '$(CONFDIR)/wms' '$(CONFDIR)/wayland'
install-user: ## Copy the example config to the current user's config directory
@test -n '$(USER_CONFIG_HOME)' || { echo 'HOME or XDG_CONFIG_HOME is required'; exit 1; }
@install -d -m700 '$(USER_CONFDIR)'
@if [ -e '$(USER_CONFDIR)/config.toml' ]; then \
printf 'configuration at %s was preserved\n' '$(USER_CONFDIR)/config.toml'; \
else \
install -m600 extra/config.toml '$(USER_CONFDIR)/config.toml'; \
printf 'created %s\n' '$(USER_CONFDIR)/config.toml'; \
fi
@if ! command -v go >/dev/null 2>&1; then \
printf " $(RED)%-10s$(RST) go not found — install go 1.22+\n" ""; \
exit 1; \
fi
@printf " $(GREEN)%-10s$(RST) dependencies ok\n" "DEPS"
all: build ## build the release binary
uninstall: ## Remove files installed by make install
@rm -f '$(BINDIR)/$(BINARY)' '$(DESTDIR)/etc/pam.d/$(PROJECT)' '$(SYSTEMD_DIR)/$(PROJECT).service'
@printf '$(YELLOW)configuration at %s was preserved; remove it manually if no longer needed$(RESET)\n' '$(CONFDIR)'
build: $(BUILD_DIR)/$(BINARY) ## compile latchd
clean: ## Remove generated build, release, and Cargo artifacts
@rm -rf '$(BUILD_DIR)' '$(DIST_DIR)'
@$(CARGO) clean
$(BUILD_DIR)/$(BINARY): $(shell find $(SRC_DIR) -name '*.go')
@printf " $(CYAN)%-10s$(RST) %s\n" "GO" "$@"
@mkdir -p $(BUILD_DIR)
@cd $(SRC_DIR) && $(GO) build $(GOFLAGS) -o ../$@ .
run-preview: build ## Run the safe non-authenticating preview UI
@'$(BUILD_DIR)/$(BINARY)' --preview --no-log
install: build ## install to $(PREFIX)
@printf " $(GREEN)%-10s$(RST) %s\n" "INSTALL" "$(BINDIR)/$(BINARY)"
@install -Dm755 $(BUILD_DIR)/$(BINARY) $(BINDIR)/$(BINARY)
@install -Dm644 extra/config.toml $(CONFDIR)/config.toml
@install -Dm755 extra/xsetup.sh $(CONFDIR)/xsetup.sh
@install -Dm644 extra/latchd.pam $(DESTDIR)/etc/pam.d/$(BINARY)
@install -Dm644 extra/latchd.service $(SYSTEMD)/$(BINARY).service
@mkdir -p $(CONFDIR)/wms $(CONFDIR)/wayland
@printf " $(GREEN)%-10s$(RST) done — run 'systemctl enable $(BINARY)' to activate\n" "INSTALL"
legacy-build: ## Build the quarantined Go reference only when explicitly requested
@printf '$(YELLOW)legacy Go is reference-only and excluded from release builds$(RESET)\n'
@command -v $(GO) >/dev/null || { echo 'go is required for legacy-build'; exit 1; }
@mkdir -p '$(BUILD_DIR)'
@cd legacy/go && $(GO) build -trimpath -o '../../$(BUILD_DIR)/$(BINARY)-go' .
uninstall: ## remove installed files
@printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BINDIR)/$(BINARY)"
@rm -f $(BINDIR)/$(BINARY)
@rm -rf $(CONFDIR)
@rm -f $(DESTDIR)/etc/pam.d/$(BINARY)
@rm -f $(SYSTEMD)/$(BINARY).service
legacy-check: ## Format-check and test the quarantined Go reference
@command -v $(GO) >/dev/null || { echo 'go is required for legacy-check'; exit 1; }
@command -v gofmt >/dev/null || { echo 'gofmt is required for legacy-check'; exit 1; }
@test -z "$$(find legacy/go -type f -name '*.go' -print0 | xargs -0 gofmt -l)" || { echo 'legacy Go files need gofmt'; exit 1; }
@cd legacy/go && $(GO) test ./...
clean: ## remove build artifacts
@printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BUILD_DIR)"
@rm -rf $(BUILD_DIR)
enable: ## Enable the systemd service after host validation
@sudo systemctl enable $(PROJECT).service
test: ## run tests
@printf " $(YELLOW)%-10s$(RST) running tests...\n" "TEST"
@cd $(SRC_DIR) && $(GO) test ./...
lint: ## run linter
@printf " $(YELLOW)%-10s$(RST) linting...\n" "LINT"
@golangci-lint run $(SRC_DIR)/... 2>/dev/null || printf " $(DIM)%-10s$(RST) golangci-lint not found; skipping\n" ""
fmt: ## format source
@printf " $(BLUE)%-10s$(RST) formatting...\n" "FMT"
@cd $(SRC_DIR) && $(GO) fmt ./...
vet: ## run go vet
@printf " $(YELLOW)%-10s$(RST) vetting...\n" "VET"
@cd $(SRC_DIR) && $(GO) vet ./...
run-preview: build ## launch preview mode
@printf " $(GREEN)%-10s$(RST) starting preview...\n" "RUN"
@./$(BUILD_DIR)/$(BINARY) --preview --no-log
release: clean ## build with version info
@printf " $(BOLD)$(CYAN)╔══════════════════════════════════════╗$(RST)\n"
@printf " $(BOLD)$(CYAN)║ latchd $(VERSION) — release build ║$(RST)\n"
@printf " $(BOLD)$(CYAN)╚══════════════════════════════════════╝$(RST)\n"
@printf "\n"
@printf " $(DIM)commit$(RST) $(COMMIT)\n"
@printf " $(DIM)built$(RST) $(BUILDDATE)\n"
@printf "\n"
@$(MAKE) build
enable: ## disable other DM + enable latchd
@printf " $(YELLOW)%-10s$(RST) switching to latchd...\n" "ENABLE"
@if systemctl is-active display-manager.service >/dev/null 2>&1; then \
printf " $(DIM)%-10s$(RST) stopping current display manager\n" ""; \
sudo systemctl stop display-manager.service; \
fi
@if [ -L /etc/systemd/system/display-manager.service ] \
&& ! readlink /etc/systemd/system/display-manager.service | grep -q $(BINARY); then \
printf " $(DIM)%-10s$(RST) disabling aliased display manager\n" ""; \
sudo systemctl disable display-manager.service; \
fi
@sudo systemctl enable $(BINARY).service
@printf " $(GREEN)%-10s$(RST) latchd enabled — reboot or run 'sudo systemctl start latchd'\n" "ENABLE"
update: ## uninstall → git pull → rebuild → reinstall
@printf " $(YELLOW)%-10s$(RST) removing previous install...\n" "UPDATE"
@sudo $(MAKE) uninstall
@printf " $(CYAN)%-10s$(RST) pulling latest...\n" "UPDATE"
@git pull
@printf " $(BLUE)%-10s$(RST) rebuilding...\n" "UPDATE"
update: ## Rebuild and reinstall the current checkout without changing git state
@sudo $(MAKE) install
@printf " $(GREEN)%-10s$(RST) updated — run 'sudo make enable' to switch DMs\n" "UPDATE"
help: ## print this help
@printf " $(BOLD)latchd$(RST) — tui display manager\n"
@printf " $(DIM)version $(VERSION) commit $(COMMIT)$(RST)\n"
@printf "\n"
@printf " $(WHITE)targets:$(RST)\n"
@awk -F ':|##' '/^[a-zA-Z].*:.*##/ { \
printf " $(CYAN)%-16s$(RST) %s\n", $$1, $$NF }' $(MAKEFILE_LIST) \
| sort
@printf "\n"
help: ## Show available targets
@printf '$(CYAN)$(PROJECT)$(RESET) $(VERSION) — production build targets\n\n'
@awk -F ':|##' '/^[a-zA-Z0-9_-]+:.*##/ { printf " %-18s %s\n", $$1, $$NF }' '$(MAKEFILE_LIST)' | sort

250
README.md
View File

@@ -1,190 +1,128 @@
# latchd
a display manager that runs in your terminal. lets you pick a desktop environment and log in — supports tty, x11, and wayland.
Want a TUI-based login manager because you are tired of GDM or Plasma Login? Latchd gives you a small keyboard-first login screen on a Linux virtual terminal, then starts a TTY shell, an X11 desktop, or a Wayland desktop for the account you choose.
## what problem does this solve
Latchd is written in Rust and runs as a systemd service. It finds login accounts through NSS, reads desktop entries from the configured directories, The old Go prototype is still in [`legacy/go/`](legacy/go/), but it is not part of the build.
Most display managers are graphical. that means you need x11 or wayland running before you can even log in. latchd runs in a plain linux terminal (like tty2), so you can log in first, then start your desktop. it's lightweight, looks decent, and doesn't drag in a full graphics stack just to show a login screen.
![Latchd login screen](docs/images/latchd-preview.png)
## how it works
This screen comes from the Docker preview capture; the [UI guide](docs/ui.md) has the keyboard map and the help screen.
latchd takes over a virtual terminal (tty2 by default) and draws a login screen using ansi escape codes, powered by [bubbletea](https://github.com/charmbracelet/bubbletea). you type your username and password, pick a session from the switcher, and hit enter.
## What latchd does
The actual authentication happens through pam (pluggable authentication modules). Once you're authenticated, latchd forks a child process that opens the pam session, sets up your environment, spawns your desktop or window manager, and does utmp accounting so tools like `who` and `loginctl` can see you're logged in.
- account discovery — reads normal login accounts through NSS; filters low UIDs, excluded names, no-login shells, and unsafe home directories
- account status — reads shadow aging fields and the configured faillock helper; shows expiry, lock, inactivity, and failure counts in the screen
- session discovery — reads X11 and Wayland `.desktop` files plus administrator scripts; keeps the program and its arguments separate instead of passing them to a shell
- login handoff — builds the user environment, opens the configured login session, starts the selected program with the account UID and groups, and closes the session when it exits
- X11 startup — creates a private Xauthority file, waits for the X server readiness signal, and removes the file after the desktop exits
- Wayland startup — starts the selected desktop with the session values it needs; it does not start a compositor
- TTY startup — attaches the selected shell to the virtual terminal and records the user process when UTMPX is available
- user interface — provides account switching, last-session preferences, filtering, refresh, notifications, wallpapers, reduced motion, and confirmed power actions
For x11 sessions it handles the whole setup — xauthority cookies, sigusr1 handshake with the x server, timing out if xorg fails to start. for wayland it just runs your compositor script. for tty it drops you into your shell.
Preview mode draws the same screen and reads the same TOML structure, but it does not authenticate or start a user program, which makes it useful for layout work and config checks.
While the login screen is up, it renders a conway's game of life simulation as the background at 30fps, using unicode half-block characters for a pixelated look.
## Quick start
## what you get
Install the build requirements on Debian or Ubuntu:
- Session switcher with left/right arrows and dot indicators
- username and password fields
- Status line for errors and info
- power controls (f1 shutdown, f2 reboot by default — configurable)
- caps lock indicator
- shake animation on wrong password
- spring animation on switcher transitions
- faillock integration — shows remaining attempts, detects lockouts
- Admin override (ctrl+u) to unlock a locked-out account
- configurable color theming via toml
- variable substitution in config (`$varname`)
- Preview mode for testing inside an existing session
## Known Issues
This project is a work in progress. here's what you should know:
- **no tests**. at all. no unit tests, no integration tests. if you hit a bug, you'll find it the hard way.
- **Session spawning is a bit rough**. it uses `ForkExec` to re-launch itself as a child process, which means the pam session lifecycle isn't as clean as the original fork-then-exec model. this can cause pam modules that track session leaders to get confused.
- **a handful of open bugs** — edge cases in the switcher, occasional terminal state corruption on abrupt exits, and the automata background can flicker when the terminal resizes rapidly.
- **not all config options are fully wired**. things like `SwitcherConfig.ShowNeighbours`, various color options, and input field styling fields exist in the config struct but may not all be hooked up to the rendering yet.
## installing
### from source
you need go 1.22 or later, `libpam0g-dev` (debian/ubuntu) or `pam-devel` (rhel/fedora) or `pam` (arch), and a c compiler (`build-essential`).
```bash
make deps # install system dependencies
sudo make install # build, install binary + configs + systemd unit
sudo systemctl enable latchd
```sh
sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules
```
to test without rebooting:
Build the release binary and run the checks:
```bash
make run-preview
```sh
make check
make build
```
### arch
The binary is `build/latchd`. Open the preview screen with:
```bash
pacman -S latchd
systemctl enable latchd
```sh
./build/latchd --preview --no-log
```
## Command Line
The repository pins Rust 1.88 in [`rust-toolchain.toml`](rust-toolchain.toml).
```bash
latchd [options]
## Configuration
-c, --config <file> config path (default: /etc/latchd/config.toml)
-v, --variables <file> variables path
--tty <n> override the tty number to run on
--preview run inside an existing session for testing
--no-log disable all logging
--show-config print the parsed config and exit
-V, --version print version and exit
--xsessions <dir> override xsessions path
--wlsessions <dir> override wayland-sessions path
--initial-path <path> override initial PATH value
The service reads `/etc/latchd/config.toml`. Preview mode first checks `$XDG_CONFIG_HOME/latchd/config.toml`, then `~/.config/latchd/config.toml`, and finally the system file. `--config PATH` selects one file directly.
Create a user file from the example with:
```sh
make install-user
```
## Adding Sessions
That command creates `~/.config/latchd/` with mode `700`, writes `config.toml` with mode `600`, and leaves an existing file alone. The full field reference, variable substitution, command-line overrides, desktop entry rules, and permission checks are in [`docs/configuration.md`](docs/configuration.md).
Drop executable scripts into the right folder and they show up in the switcher.
## Install on a test machine
**x11** — put your xinitrc scripts in `/etc/latchd/wms/`:
Use a disposable machine and keep another login path available while you change the display manager:
```bash
# /etc/latchd/wms/bspwm
#!/bin/sh
sxhkd &
exec bspwm
```sh
sudo make install
sudo systemctl enable latchd.service
sudo systemctl start latchd.service
```
```bash
chmod +x /etc/latchd/wms/bspwm
The install target writes the binary, `/etc/latchd/config.toml`, `/etc/pam.d/latchd`, the session-script directories, and the systemd unit. The supplied unit uses `/dev/tty2`, so change both `tty` in the TOML file and `TTYPath` in the unit if you use another virtual terminal. The install target does not disable the current display manager for you.
## Keyboard controls
| Key | Action |
| --- | --- |
| Tab / Shift-Tab | Move focus |
| Up / Down | Move focus or move in a filtered list |
| Left / Right | Change the selected session |
| `/` | Filter sessions or accounts |
| Ctrl-R | Refresh accounts and sessions |
| Ctrl-N / Ctrl-P | Select the next or previous account |
| Ctrl-B | Toggle the background |
| Ctrl-M | Toggle reduced motion |
| Enter | Authenticate or submit a password change |
| Esc | Clear the password or close a popup |
| `?` | Show the key reference |
| Ctrl-C | Restore the terminal and quit |
| F1–F12 | Confirm a configured power action |
Lowercase filters ignore case, while a filter with an uppercase character is case-sensitive. The preview screen and the capture command are documented in [`docs/ui.md`](docs/ui.md).
## Checks and releases
Use `make check` for formatting, unit tests, Clippy, and rustdoc; use `make integration-test` for the CLI paths and `make docker-test` for the pinned container build. `make pam-fixture-test` needs root because it installs temporary test policies, while `make system-e2e` checks the services and devices available on the current host.
Run `make system-e2e-strict` only in a disposable VM that has a test policy, a real virtual terminal, systemd-logind, and the X11 or Wayland stack you want to test. [`docs/e2e-testing.md`](docs/e2e-testing.md) explains what each check can and cannot cover.
Create a local release archive with:
```sh
make package
```
**wayland** — same thing in `/etc/latchd/wayland/`:
GitLab CI runs the same Rust, CLI, container, and capability checks, then creates an archive and SHA-256 file for a SemVer tag. The release process is described in [`docs/development.md`](docs/development.md).
```bash
# /etc/latchd/wayland/sway
#!/bin/sh
exec sway
## Read next
- [`docs/guide.md`](docs/guide.md) explains the service from startup to session cleanup.
- [`docs/configuration.md`](docs/configuration.md) explains each configuration field.
- [`docs/architecture.md`](docs/architecture.md) explains the workspace and the process boundaries.
- [`docs/development.md`](docs/development.md) explains builds, tests, Docker, and packages.
- [`docs/ui.md`](docs/ui.md) shows the screen and its controls.
- [`docs/e2e-testing.md`](docs/e2e-testing.md) explains the VM and container checks.
## Project layout
```text
crates/latchd-core/ account discovery, configuration, sessions, and UTMPX
crates/latchd-auth/ authentication worker and session handoff
crates/latchd-tui/ terminal state, rendering, input, and effects
rust/main.rs command-line entry point and service loop
extra/ example configuration, login policy, and systemd unit
scripts/ CLI, Docker, fixture, and host checks
legacy/go/ reference implementation from the early prototype
```
latchd also reads freedesktop `.desktop` files from `/usr/share/xsessions` and `/usr/share/wayland-sessions` automatically.
## License
tty sessions are added automatically if no other environments are found, or you can force one with `include_tty_shell = true` in the config.
## configuring
The config lives at `/etc/latchd/config.toml`. all options are documented inline in the file. you can also use a `variables.toml` file with `$VAR` syntax:
```toml
# /etc/latchd/variables.toml
accent = "orange"
```
```toml
# /etc/latchd/config.toml
[password_field.style]
title_color_focused = "$accent"
border_color_focused = "$accent"
```
## Debugging
three log files:
- `/var/log/latchd.log` — main latchd events
- `/var/log/latchd.client.log` — stdout/stderr of your session
- `/var/log/latchd.xorg.log` — x server output (64mb cap)
run `latchd --show-config` to verify your configuration. run `latchd --preview` to test the login screen without leaving your current session.
## Keybindings
standard shell-style bindings in text fields:
| key | what it does |
|---|---|
| ctrl+a | start of line |
| ctrl+e | end of line |
| ctrl+l or ctrl+u | clear field |
| ctrl+d | delete forward |
| ctrl+h | backspace |
| ctrl+b/f | left/right |
| ctrl+p/n | up/down (previous/next field) |
| tab, shift+tab | next/prev field |
| down / up | next/prev field |
| left/right | previous/next session (switcher mode) |
| , / . | previous/next session (any mode) |
| f1, f2 | power controls |
| ctrl+u | admin unlock (when account is locked) |
## source layout
```bash
src/
├── main.go entry point, cli, vt switching, session forking
├── auth/
│ ├── pam.go cgo pam bindings (locks goroutine to os thread)
│ ├── faillock.go faillock tally detection and reset
│ └── utmpx.go user accounting records
├── config/
│ └── config.go toml parsing, variable substitution, defaults
├── session/
│ ├── env.go xdg and environment variable setup
│ ├── exec.go process spawning, privilege dropping, log limits
│ ├── scan.go session discovery and .desktop parsing
│ ├── x11.go xorg setup, xauth, sigusr1 handshake
│ └── wayland.go wayland placeholder
├── login/
│ └── login.go login form ui, state machine, animations, rendering
└── tui/
└── bg/
└── automata.go conway's game of life background
```
## license
MIT or Apache-2.0
## credits
the `extra/` directory structure and the session scanning / pam delegation approach are adapted from [lemurs](https://github.com/coastalwhite/lemurs), a rust display manager.
Apache-2.0. See [`LICENSE`](LICENSE).

41
build.rs Normal file
View File

@@ -0,0 +1,41 @@
use std::{env, process::Command};
fn main() {
for variable in [
"LATCHD_VERSION",
"LATCHD_COMMIT",
"LATCHD_BUILD_TIME",
"SOURCE_DATE_EPOCH",
] {
println!("cargo:rerun-if-env-changed={variable}");
}
let version = env::var("LATCHD_VERSION")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned());
let commit = env::var("LATCHD_COMMIT")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| git_value(["rev-parse", "--short=12", "HEAD"]))
.unwrap_or_else(|| "unknown".to_owned());
let build_time = env::var("LATCHD_BUILD_TIME")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| env::var("SOURCE_DATE_EPOCH").ok())
.unwrap_or_else(|| "unknown".to_owned());
println!("cargo:rustc-env=LATCHD_VERSION={version}");
println!("cargo:rustc-env=LATCHD_COMMIT={commit}");
println!("cargo:rustc-env=LATCHD_BUILD_TIME={build_time}");
}
fn git_value<const N: usize>(arguments: [&str; N]) -> Option<String> {
let output = Command::new("git").args(arguments).output().ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
}

View File

@@ -0,0 +1,11 @@
[package]
name = "latchd-auth"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
latchd-core = { path = "../latchd-core" }
libc = "0.2"
zeroize = "1.8"

View File

@@ -0,0 +1,775 @@
//! Authentication worker and session handoff.
//!
//! Keep handles on one thread; public tickets contain only an opaque ID.
use std::{
collections::HashMap,
ffi::{CStr, CString},
mem,
os::raw::{c_char, c_int, c_void},
ptr,
sync::mpsc::{Receiver, SyncSender, sync_channel},
thread,
};
use zeroize::{Zeroize, Zeroizing};
use latchd_core::{
accounts::Identity,
error::Error,
session::{self, SessionSpec},
};
const PAM_SUCCESS: c_int = 0;
const PAM_SYSTEM_ERR: c_int = 4;
const PAM_BUF_ERR: c_int = 5;
const PAM_CONV_ERR: c_int = 19;
const PAM_AUTHINFO_UNAVAIL: c_int = 9;
const PAM_CHANGE_EXPIRED_AUTHTOK: c_int = 0x0020;
const PAM_PROMPT_ECHO_OFF: c_int = 1;
const PAM_PROMPT_ECHO_ON: c_int = 2;
const PAM_ERROR_MSG: c_int = 3;
const PAM_TEXT_INFO: c_int = 4;
const PAM_ESTABLISH_CRED: c_int = 0x0002;
const PAM_DELETE_CRED: c_int = 0x0004;
const PAM_TTY: c_int = 3;
const PAM_CHILD_ENVIRONMENT_KEYS: [&CStr; 8] = [
c"XDG_SESSION_CLASS",
c"XDG_SESSION_TYPE",
c"XDG_SESSION_DESKTOP",
c"XDG_SEAT",
c"XDG_VTNR",
c"XDG_RUNTIME_DIR",
c"XDG_SESSION_ID",
c"DBUS_SESSION_BUS_ADDRESS",
];
#[repr(C)]
struct PamHandle {
_private: [u8; 0],
}
#[repr(C)]
struct PamMessage {
msg_style: c_int,
msg: *const c_char,
}
#[repr(C)]
struct PamResponse {
resp: *mut c_char,
resp_retcode: c_int,
}
#[repr(C)]
struct PamConv {
conv: Option<
unsafe extern "C" fn(
c_int,
*mut *const PamMessage,
*mut *mut PamResponse,
*mut c_void,
) -> c_int,
>,
appdata_ptr: *mut c_void,
}
#[link(name = "pam")]
unsafe extern "C" {
#[link_name = "pam_start"]
fn pam_start(
service: *const c_char,
user: *const c_char,
conv: *const PamConv,
pamh: *mut *mut PamHandle,
) -> c_int;
#[link_name = "pam_authenticate"]
fn pam_authenticate(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_chauthtok"]
fn pam_chauthtok(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_acct_mgmt"]
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_setcred"]
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_set_item"]
fn pam_set_item(pamh: *mut PamHandle, item_type: c_int, item: *const c_void) -> c_int;
#[link_name = "pam_putenv"]
fn pam_putenv(pamh: *mut PamHandle, name_value: *const c_char) -> c_int;
#[link_name = "pam_getenv"]
fn pam_getenv(pamh: *mut PamHandle, name: *const c_char) -> *const c_char;
#[link_name = "pam_open_session"]
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_close_session"]
fn pam_close_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_end"]
fn pam_end(pamh: *mut PamHandle, pam_status: c_int) -> c_int;
#[link_name = "pam_strerror"]
fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char;
}
/// Opaque ticket for an authenticated transaction.
pub struct AuthTicket {
id: u64,
identity: Identity,
owner: Option<AuthService>,
}
impl AuthTicket {
pub fn identity(&self) -> &Identity {
&self.identity
}
}
impl Drop for AuthTicket {
fn drop(&mut self) {
if let Some(owner) = &self.owner {
let _ = owner.commands.try_send(Command::Cancel { id: self.id });
}
}
}
enum Command {
Authenticate {
username: String,
password: Zeroizing<Vec<u8>>,
service: String,
response: std::sync::mpsc::SyncSender<Result<AuthTicket, Error>>,
},
ChangePassword {
username: String,
current_password: Zeroizing<Vec<u8>>,
new_password: Zeroizing<Vec<u8>>,
service: String,
response: std::sync::mpsc::SyncSender<Result<(), Error>>,
},
Run {
id: u64,
spec: SessionSpec,
response: std::sync::mpsc::SyncSender<Result<(), Error>>,
},
Cancel {
id: u64,
},
}
/// Run handle-based operations on one worker thread.
#[derive(Clone)]
pub struct AuthService {
commands: SyncSender<Command>,
}
impl AuthService {
pub fn new() -> Result<Self, Error> {
let (commands, requests) = sync_channel::<Command>(1);
thread::Builder::new()
.name("latchd-pam".into())
.spawn(move || worker(requests))
.map_err(|error| Error::Worker(format!("spawn PAM worker: {error}")))?;
Ok(Self { commands })
}
pub fn authenticate(
&self,
username: String,
password: Vec<u8>,
service: String,
) -> Result<AuthTicket, Error> {
let (response, result) = sync_channel(0);
self.commands
.send(Command::Authenticate {
username,
password: Zeroizing::new(password),
service,
response,
})
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
let ticket = result
.recv()
.map_err(|_| Error::Worker("PAM worker disconnected".into()))??;
let mut ticket = ticket;
ticket.owner = Some(self.clone());
Ok(ticket)
}
/// Change an expired account password through the configured policy.
///
/// The configured policy checks password strength, history, and token rules
/// zeroizing buffers hold both passwords.
pub fn change_password(
&self,
username: String,
current_password: Vec<u8>,
new_password: Vec<u8>,
service: String,
) -> Result<(), Error> {
let (response, result) = sync_channel(0);
self.commands
.send(Command::ChangePassword {
username,
current_password: Zeroizing::new(current_password),
new_password: Zeroizing::new(new_password),
service,
response,
})
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
result
.recv()
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
}
/// Run the authenticated session and close its transaction.
pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
let (response, result) = sync_channel(0);
let id = ticket.id;
ticket.owner = None;
self.commands
.send(Command::Run { id, spec, response })
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
result
.recv()
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
}
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
let id = ticket.id;
ticket.owner = None;
self.commands
.send(Command::Cancel { id })
.map_err(|_| Error::Worker("PAM worker stopped".into()))
}
}
struct ConversationData {
username: CString,
password: Zeroizing<Vec<u8>>,
new_password: Option<Zeroizing<Vec<u8>>>,
new_password_prompts: usize,
}
impl Drop for ConversationData {
fn drop(&mut self) {
self.password.zeroize();
}
}
struct PamSession {
handle: *mut PamHandle,
conversation: Box<ConversationData>,
opened: bool,
credentials_established: bool,
identity: Option<Identity>,
}
impl PamSession {
fn close(&mut self, status: c_int) -> Result<(), Error> {
let mut first_error = None;
if self.opened {
let result = unsafe { pam_close_session(self.handle, 0) };
if result != PAM_SUCCESS {
first_error = Some(pam_error(self.handle, "pam_close_session", result));
}
self.opened = false;
}
if self.credentials_established {
let credentials = unsafe { pam_setcred(self.handle, PAM_DELETE_CRED) };
if credentials != PAM_SUCCESS && first_error.is_none() {
first_error = Some(pam_error(self.handle, "pam_setcred(delete)", credentials));
}
self.credentials_established = false;
}
let ended = unsafe { pam_end(self.handle, status) };
if ended != PAM_SUCCESS && first_error.is_none() {
first_error = Some(pam_error(self.handle, "pam_end", ended));
}
self.handle = ptr::null_mut();
first_error.map_or(Ok(()), Err)
}
}
impl Drop for PamSession {
fn drop(&mut self) {
if !self.handle.is_null() {
let _ = self.close(PAM_SYSTEM_ERR);
}
}
}
fn worker(requests: Receiver<Command>) {
let mut sessions = HashMap::<u64, PamSession>::new();
let mut next_id = 1u64;
while let Ok(command) = requests.recv() {
match command {
Command::Authenticate {
username,
password,
service,
response,
} => {
let result =
authenticate(&username, password, &service, &mut next_id, &mut sessions);
let _ = response.send(result);
}
Command::ChangePassword {
username,
current_password,
new_password,
service,
response,
} => {
let result = change_password(&username, current_password, new_password, &service);
let _ = response.send(result);
}
Command::Run { id, spec, response } => {
let result = match sessions.remove(&id) {
Some(session) => run_authenticated_session(session, spec),
None => Err(Error::Worker("unknown authentication ticket".into())),
};
let _ = response.send(result);
}
Command::Cancel { id } => {
sessions.remove(&id);
}
}
}
}
fn run_authenticated_session(mut session: PamSession, mut spec: SessionSpec) -> Result<(), Error> {
if let Err(error) = configure_pam_session(session.handle, &spec) {
let _ = session.close(PAM_SYSTEM_ERR);
return Err(error);
}
// SAFETY: This worker owns the live handle until `PamSession::close`.
let opened = unsafe { pam_open_session(session.handle, 0) };
if opened != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_open_session", opened);
let _ = session.close(opened);
return Err(error);
}
session.opened = true;
let run = apply_pam_environment(session.handle, &mut spec).and_then(|()| {
session.identity.as_ref().map_or_else(
|| Err(Error::Worker("ticket has no account identity".into())),
|identity| session::run_user_session(identity, &spec),
)
});
let close = session.close(if run.is_ok() {
PAM_SUCCESS
} else {
PAM_SYSTEM_ERR
});
match (run, close) {
(Err(error), _) | (Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
fn configure_pam_session(handle: *mut PamHandle, spec: &SessionSpec) -> Result<(), Error> {
let tty = CString::new(spec.pam_tty())
.map_err(|_| Error::Session("PAM terminal name contains NUL".into()))?;
// SAFETY: The worker owns the live handle. `tty` is NUL-terminated, and PAM copies the value.
let result = unsafe { pam_set_item(handle, PAM_TTY, tty.as_ptr().cast()) };
if result != PAM_SUCCESS {
return Err(pam_error(handle, "pam_set_item(PAM_TTY)", result));
}
for (key, value) in spec.pam_environment() {
let assignment = CString::new(format!("{key}={value}"))
.map_err(|_| Error::Session("PAM session environment contains NUL".into()))?;
// SAFETY: The worker owns the live handle. PAM copies the NUL-terminated assignment.
let result = unsafe { pam_putenv(handle, assignment.as_ptr()) };
if result != PAM_SUCCESS {
return Err(pam_error(handle, "pam_putenv", result));
}
}
Ok(())
}
fn apply_pam_environment(handle: *mut PamHandle, spec: &mut SessionSpec) -> Result<(), Error> {
for name in PAM_CHILD_ENVIRONMENT_KEYS {
// SAFETY: The worker owns the live handle. Each name is a static C string.
let value = unsafe { pam_getenv(handle, name.as_ptr()) };
if value.is_null() {
continue;
}
// SAFETY: PAM owns this C string. Copy it before the next PAM call.
let value = unsafe { CStr::from_ptr(value) }
.to_str()
.map_err(|_| Error::Session("PAM session environment is not UTF-8".into()))?
.to_owned();
let key = name
.to_str()
.map_err(|_| Error::Session("PAM environment key is not UTF-8".into()))?;
if let Some((_, existing)) = spec
.environment
.iter_mut()
.find(|(existing, _)| existing == key)
{
*existing = value;
} else {
spec.environment.push((key.to_owned(), value));
}
}
Ok(())
}
fn authenticate(
username: &str,
password: Zeroizing<Vec<u8>>,
service: &str,
next_id: &mut u64,
sessions: &mut HashMap<u64, PamSession>,
) -> Result<AuthTicket, Error> {
let service =
CString::new(service).map_err(|_| Error::Config("PAM service contains NUL".into()))?;
let username_c =
CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
let conversation = Box::new(ConversationData {
username: username_c.clone(),
password,
new_password: None,
new_password_prompts: 0,
});
let data = Box::into_raw(conversation);
let conversation_ptr = PamConv {
conv: Some(converse),
appdata_ptr: data.cast(),
};
let mut handle = ptr::null_mut();
let started = unsafe {
pam_start(
service.as_ptr(),
username_c.as_ptr(),
&conversation_ptr,
&mut handle,
)
};
// SAFETY: `data` came from `Box::into_raw`. PAM borrows `appdata_ptr`.
let conversation = unsafe { Box::from_raw(data) };
if started != PAM_SUCCESS {
let error = pam_error(handle, "pam_start", started);
if !handle.is_null() {
let _ = unsafe { pam_end(handle, started) };
}
return Err(error);
}
if handle.is_null() {
return Err(Error::Pam {
operation: "pam_start",
status: PAM_SYSTEM_ERR,
message: "returned a null handle".into(),
});
}
let mut session = PamSession {
handle,
conversation,
opened: false,
credentials_established: false,
identity: None,
};
let authenticated = unsafe { pam_authenticate(session.handle, 0) };
if authenticated != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_authenticate", authenticated);
let _ = session.close(authenticated);
return Err(error);
}
let account = unsafe { pam_acct_mgmt(session.handle, 0) };
if account != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_acct_mgmt", account);
let _ = session.close(account);
return Err(error);
}
let identity = match lookup_identity(username) {
Ok(identity) => identity,
Err(error) => {
let _ = session.close(PAM_SYSTEM_ERR);
return Err(error);
}
};
session.identity = Some(identity);
let credentials = unsafe { pam_setcred(session.handle, PAM_ESTABLISH_CRED) };
if credentials != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_setcred(establish)", credentials);
let _ = session.close(credentials);
return Err(error);
}
session.credentials_established = true;
// Erase the password before the PAM session remains open.
session.conversation.password.zeroize();
let identity = session
.identity
.clone()
.ok_or_else(|| Error::Worker("authenticated session has no identity".into()))?;
let id = *next_id;
*next_id = next_id
.checked_add(1)
.ok_or_else(|| Error::Worker("authentication ticket counter exhausted".into()))?;
sessions.insert(id, session);
Ok(AuthTicket {
id,
identity,
owner: None,
})
}
fn change_password(
username: &str,
current_password: Zeroizing<Vec<u8>>,
new_password: Zeroizing<Vec<u8>>,
service: &str,
) -> Result<(), Error> {
let service =
CString::new(service).map_err(|_| Error::Config("PAM service contains NUL".into()))?;
let username_c =
CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
let conversation = Box::new(ConversationData {
username: username_c.clone(),
password: current_password,
new_password: Some(new_password),
new_password_prompts: 0,
});
let data = Box::into_raw(conversation);
let conversation_ptr = PamConv {
conv: Some(converse),
appdata_ptr: data.cast(),
};
let mut handle = ptr::null_mut();
let started = unsafe {
pam_start(
service.as_ptr(),
username_c.as_ptr(),
&conversation_ptr,
&mut handle,
)
};
let conversation = unsafe { Box::from_raw(data) };
if started != PAM_SUCCESS {
let error = pam_error(handle, "pam_start", started);
if !handle.is_null() {
let _ = unsafe { pam_end(handle, started) };
}
drop(conversation);
return Err(error);
}
if handle.is_null() {
drop(conversation);
return Err(Error::Pam {
operation: "pam_start",
status: PAM_SYSTEM_ERR,
message: "returned a null handle".into(),
});
}
let mut session = PamSession {
handle,
conversation,
opened: false,
credentials_established: false,
identity: None,
};
let changed = unsafe { pam_chauthtok(session.handle, PAM_CHANGE_EXPIRED_AUTHTOK) };
let error =
(changed != PAM_SUCCESS).then(|| pam_error(session.handle, "pam_chauthtok", changed));
let close = session.close(changed);
if let Some(error) = error {
return Err(error);
}
close
}
fn lookup_identity(username: &str) -> Result<Identity, Error> {
let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
let passwd = unsafe { libc::getpwnam(name.as_ptr()) };
if passwd.is_null() {
return Err(Error::Pam {
operation: "getpwnam",
status: PAM_AUTHINFO_UNAVAIL,
message: format!("unknown account {username}"),
});
}
let passwd = unsafe { &*passwd };
let home = c_path(passwd.pw_dir, "home")?;
let shell = c_path(passwd.pw_shell, "shell")?;
if shell == std::path::Path::new("/bin/false")
|| shell == std::path::Path::new("/usr/sbin/nologin")
|| shell == std::path::Path::new("/sbin/nologin")
{
return Err(Error::Config(
"account does not have an interactive shell".into(),
));
}
Ok(Identity {
username: username.into(),
uid: passwd.pw_uid,
gid: passwd.pw_gid,
home,
shell,
})
}
fn c_path(pointer: *const c_char, field: &str) -> Result<std::path::PathBuf, Error> {
if pointer.is_null() {
return Err(Error::Config(format!("account has no {field}")));
}
let value = unsafe { CStr::from_ptr(pointer) }
.to_str()
.map_err(|_| Error::Config(format!("account {field} is not UTF-8")))?;
if value.is_empty() {
return Err(Error::Config(format!("account has empty {field}")));
}
Ok(value.into())
}
#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn converse(
count: c_int,
messages: *mut *const PamMessage,
responses: *mut *mut PamResponse,
data: *mut c_void,
) -> c_int {
if count <= 0 || messages.is_null() || responses.is_null() || data.is_null() {
return PAM_CONV_ERR;
}
let count = count as usize;
let output = libc::calloc(count, mem::size_of::<PamResponse>()) as *mut PamResponse;
if output.is_null() {
return PAM_BUF_ERR;
}
let context = &mut *(data.cast::<ConversationData>());
for index in 0..count {
let message = *messages.add(index);
if message.is_null() {
free_responses(output, index);
return PAM_CONV_ERR;
}
let value: Option<&[u8]> = match (*message).msg_style {
PAM_PROMPT_ECHO_OFF => {
let prompt = context.new_password_prompts;
context.new_password_prompts = prompt.saturating_add(1);
if prompt == 0 {
Some(context.password.as_slice())
} else {
match context.new_password.as_ref() {
Some(password) => Some(password.as_slice()),
None => Some(context.password.as_slice()),
}
}
}
PAM_PROMPT_ECHO_ON => Some(context.username.as_bytes()),
PAM_ERROR_MSG | PAM_TEXT_INFO => None,
_ => {
free_responses(output, index);
return PAM_CONV_ERR;
}
};
if let Some(value) = value {
let destination = libc::malloc(value.len().saturating_add(1)) as *mut u8;
if destination.is_null() {
free_responses(output, index);
return PAM_BUF_ERR;
}
ptr::copy_nonoverlapping(value.as_ptr(), destination, value.len());
*destination.add(value.len()) = 0;
(*output.add(index)).resp = destination.cast();
}
}
*responses = output;
PAM_SUCCESS
}
#[allow(unsafe_op_in_unsafe_fn)]
unsafe fn free_responses(responses: *mut PamResponse, count: usize) {
for index in 0..count {
libc::free((*responses.add(index)).resp.cast());
}
libc::free(responses.cast());
}
fn pam_error(handle: *mut PamHandle, operation: &'static str, status: c_int) -> Error {
let message = if handle.is_null() {
format!("status {status}")
} else {
let pointer = unsafe { pam_strerror(handle, status) };
if pointer.is_null() {
format!("status {status}")
} else {
unsafe { CStr::from_ptr(pointer) }
.to_string_lossy()
.into_owned()
}
};
Error::Pam {
operation,
status,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conversation_never_returns_password_for_echo_on() {
let context = Box::new(ConversationData {
username: CString::new("alice").unwrap(),
password: Zeroizing::new(b"secret".to_vec()),
new_password: None,
new_password_prompts: 0,
});
let pointer = Box::into_raw(context);
let message = PamMessage {
msg_style: PAM_PROMPT_ECHO_ON,
msg: ptr::null(),
};
let mut message_pointer = &message as *const PamMessage;
let mut responses = ptr::null_mut();
let status = unsafe { converse(1, &mut message_pointer, &mut responses, pointer.cast()) };
assert_eq!(status, PAM_SUCCESS);
let response = unsafe { &*responses };
assert_eq!(
unsafe { CStr::from_ptr(response.resp) }.to_bytes(),
b"alice"
);
unsafe {
libc::free(response.resp.cast());
libc::free(responses.cast());
drop(Box::from_raw(pointer));
}
}
#[test]
fn password_change_conversation_uses_old_then_new_password() {
let context = Box::new(ConversationData {
username: CString::new("alice").unwrap(),
password: Zeroizing::new(b"old-secret".to_vec()),
new_password: Some(Zeroizing::new(b"new-secret".to_vec())),
new_password_prompts: 0,
});
let pointer = Box::into_raw(context);
let message = PamMessage {
msg_style: PAM_PROMPT_ECHO_OFF,
msg: ptr::null(),
};
let mut message_pointer = &message as *const PamMessage;
for expected in [b"old-secret".as_slice(), b"new-secret".as_slice()] {
let mut responses = ptr::null_mut();
let status =
unsafe { converse(1, &mut message_pointer, &mut responses, pointer.cast()) };
assert_eq!(status, PAM_SUCCESS);
let response = unsafe { &*responses };
assert_eq!(
unsafe { CStr::from_ptr(response.resp) }.to_bytes(),
expected
);
unsafe {
libc::free(response.resp.cast());
libc::free(responses.cast());
}
}
unsafe { drop(Box::from_raw(pointer)) };
}
}

View File

@@ -0,0 +1,133 @@
use std::env;
use latchd_auth::AuthService;
use latchd_core::{
accounts::Identity,
config::Config,
error::Error,
session::{self, SessionDescriptor, SessionKind},
};
fn fixture_service() -> String {
env::var("LATCHD_PAM_FIXTURE_SERVICE")
.expect("LATCHD_PAM_FIXTURE_SERVICE must point at the disposable PAM fixture")
}
fn session_fixture_service() -> String {
env::var("LATCHD_PAM_SESSION_FIXTURE_SERVICE")
.expect("LATCHD_PAM_SESSION_FIXTURE_SERVICE must point at the disposable PAM fixture")
}
fn assert_sanitized_pam_failure(result: Result<(), Error>, secret: &str) {
let error = result.expect_err("the fault-injection PAM fixture must reject the operation");
match error {
Error::Pam {
status, message, ..
} => {
assert_ne!(status, 0);
assert!(!message.contains(secret));
}
_ => panic!("fault-injection fixture returned a non-PAM error"),
}
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn authentication_fault_is_rejected_without_secret_leakage() {
let service = AuthService::new().expect("PAM worker should start");
let secret = "fixture-auth-secret";
let result = service.authenticate("root".into(), secret.as_bytes().to_vec(), fixture_service());
let error = match result {
Ok(_) => panic!("pam_deny fixture must reject authentication"),
Err(error) => error,
};
match error {
Error::Pam {
status, message, ..
} => {
assert_ne!(status, 0);
assert!(!message.contains(secret));
}
_ => panic!("fault-injection fixture returned a non-PAM error"),
}
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn password_change_fault_is_rejected_without_secret_leakage() {
let service = AuthService::new().expect("PAM worker should start");
assert_sanitized_pam_failure(
service.change_password(
"root".into(),
b"fixture-old-secret".to_vec(),
b"fixture-new-secret".to_vec(),
fixture_service(),
),
"fixture-new-secret",
);
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn selected_session_metadata_reaches_pam_before_open() {
let service = AuthService::new().expect("PAM worker should start");
let ticket = service
.authenticate("root".into(), Vec::new(), session_fixture_service())
.expect("permit fixture should authenticate root");
let identity = Identity {
username: "root".into(),
uid: 0,
gid: 0,
home: "/root".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "Niri fixture".into(),
argv: vec!["/bin/true".into()],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
.expect("fixture session should build");
service
.run_session(ticket, spec)
.expect("PAM must receive the selected graphical session metadata");
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn pam_runtime_environment_reaches_the_session_child() {
let service = AuthService::new().expect("PAM worker should start");
let ticket = service
.authenticate("root".into(), Vec::new(), session_fixture_service())
.expect("permit fixture should authenticate root");
let identity = Identity {
username: "root".into(),
uid: 0,
gid: 0,
home: "/root".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "PAM environment fixture".into(),
argv: vec![
"/bin/sh".into(),
"-c".into(),
"test \"$XDG_SESSION_ID\" = fixture-session && \
test \"$XDG_RUNTIME_DIR\" = /tmp/latchd-runtime-fixture && \
test \"$DBUS_SESSION_BUS_ADDRESS\" = unix:path=/tmp/latchd-runtime-fixture/bus"
.into(),
],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
.expect("fixture session should build");
service
.run_session(ticket, spec)
.expect("the child must receive runtime values produced by PAM");
}

View File

@@ -0,0 +1,17 @@
[package]
name = "latchd-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
libc = "0.2"
rand = "0.9"
serde = { version = "1.0", features = ["derive"] }
signal-hook = "0.3"
thiserror = "2.0"
toml = "0.9"
[dev-dependencies]
unicode-width = "0.2"

View File

@@ -0,0 +1,95 @@
//! Write and close UTMPX records for user sessions.
use std::{io, mem, os::raw::c_char, ptr};
use crate::error::Error;
pub struct UtmpxGuard {
record: libc::utmpx,
closed: bool,
}
impl UtmpxGuard {
/// Add a user-process record and return the cleanup guard.
pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result<Self, Error> {
let mut record: libc::utmpx = unsafe { mem::zeroed() };
record.ut_type = libc::USER_PROCESS;
record.ut_pid = pid;
write_field(&mut record.ut_line, &format!("tty{tty}"));
write_field(&mut record.ut_id, &format!("l{tty}"));
write_field(&mut record.ut_user, username);
write_timestamp(&mut record);
put_record(&record, "write UTMPX login record")?;
Ok(Self {
record,
closed: false,
})
}
pub fn close(&mut self) -> Result<(), Error> {
if self.closed {
return Ok(());
}
self.record.ut_type = libc::DEAD_PROCESS;
self.record.ut_user.fill(0);
self.record.ut_host.fill(0);
write_timestamp(&mut self.record);
put_record(&self.record, "write UTMPX logout record")?;
self.closed = true;
Ok(())
}
}
impl Drop for UtmpxGuard {
fn drop(&mut self) {
let _ = self.close();
}
}
fn write_field<const N: usize>(field: &mut [c_char; N], value: &str) {
field.fill(0);
for (destination, byte) in field.iter_mut().zip(value.as_bytes().iter().copied()) {
*destination = byte as c_char;
}
}
fn write_timestamp(record: &mut libc::utmpx) {
let seconds = unsafe { libc::time(ptr::null_mut()) };
record.ut_tv.tv_sec = seconds as _;
record.ut_tv.tv_usec = 0;
}
fn put_record(record: &libc::utmpx, operation: &'static str) -> Result<(), Error> {
unsafe {
libc::setutxent();
let result = libc::pututxline(record);
libc::endutxent();
if result.is_null() {
return Err(Error::Session(format!(
"{operation}: {}",
io::Error::last_os_error()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_writer_truncates_without_panicking() {
let mut field = [0 as c_char; 4];
write_field(&mut field, "abcdef");
assert_eq!(
field,
[
b'a' as c_char,
b'b' as c_char,
b'c' as c_char,
b'd' as c_char
]
);
}
}

View File

@@ -0,0 +1,578 @@
//! Discover login accounts and display account policy state.
use std::{
ffi::CStr,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use crate::{
config::AccountsConfig,
error::{Error, ResultExt},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub username: String,
pub uid: u32,
pub gid: u32,
pub home: PathBuf,
pub shell: PathBuf,
}
/// Account state derived from the shell and shadow aging fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState {
Active,
Locked,
PasswordExpired,
AccountExpired,
AccountInactive,
NoLoginShell,
/// Shadow data was missing or could not be parsed.
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountStatus {
pub state: AccountState,
pub password_changed_day: Option<i64>,
pub password_expiry_day: Option<i64>,
pub account_expiry_day: Option<i64>,
pub inactive_after_day: Option<i64>,
}
impl AccountStatus {
pub const fn is_login_allowed(self) -> bool {
matches!(self.state, AccountState::Active)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub identity: Identity,
pub status: AccountStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSession {
pub username: String,
pub terminal: String,
pub pid: libc::pid_t,
pub host: Option<String>,
}
/// Session backend found on the host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend {
SystemdLogind,
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>,
}
/// Parsed faillock counts for the selected account.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus {
pub available: bool,
pub failures: u32,
pub max_tries: Option<u32>,
pub remaining: Option<u32>,
pub locked_out: bool,
}
/// Read interactive accounts through NSS and sort them by username.
pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
let mut accounts = Vec::new();
unsafe {
libc::setpwent();
loop {
let entry = libc::getpwent();
if entry.is_null() {
break;
}
let passwd = &*entry;
let Some(username) = c_string(passwd.pw_name) else {
continue;
};
let Some(home) = c_path(passwd.pw_dir) else {
continue;
};
let Some(shell) = c_path(passwd.pw_shell) else {
continue;
};
if !eligible(&username, passwd.pw_uid, &home, &shell, config) {
continue;
}
accounts.push(Identity {
username,
uid: passwd.pw_uid,
gid: passwd.pw_gid,
home,
shell,
});
}
libc::endpwent();
}
accounts.sort_by(|left, right| left.username.cmp(&right.username));
accounts
}
/// Read identities and attach shadow aging state when the file is available.
/// Missing shadow data produces [`AccountState::Unknown`] for that account.
pub fn discover_with_status(config: &AccountsConfig) -> Vec<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()
}
/// Read aging fields without returning password hashes; the shadow path must
/// be root-owned and not writable by group or other users.
pub fn inspect_account(
identity: &Identity,
config: &AccountsConfig,
) -> 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))
}
/// Read active UTMPX user-process records in sorted 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
}
/// Check whether systemd-logind has initialized its runtime seat state.
pub fn session_backend() -> SessionBackend {
if logind_markers_present(
Path::new("/run/systemd/system").is_dir(),
Path::new("/run/systemd/seats").is_dir(),
) {
SessionBackend::SystemdLogind
} else {
SessionBackend::Utmpx
}
}
/// Run the configured helper and parse its numeric output.
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
let helper = &config.faillock_path;
if !helper.is_absolute() {
return Err(Error::Config("faillock_path must be absolute".into()));
}
if !trusted_helper(helper) {
return Ok(FaillockStatus::default());
}
let mut child = Command::new(helper)
.args(["--user", username, "--legacy-output"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.io_context("start faillock query")?;
let deadline = Instant::now() + Duration::from_millis(750);
loop {
if child
.try_wait()
.io_context("poll faillock query")?
.is_some()
{
break;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Ok(FaillockStatus::default());
}
thread::sleep(Duration::from_millis(10));
}
let output = child.wait_with_output().io_context("read faillock query")?;
if !output.status.success() {
return Ok(FaillockStatus::default());
}
let max_tries = read_deny(&config.faillock_config);
Ok(faillock_status_from_output(&output.stdout, max_tries))
}
/// Parse legacy counts and timestamp rows; bad output produces zero failures.
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));
FaillockStatus {
available: true,
failures,
max_tries,
remaining,
locked_out: remaining.is_some_and(|value| value == 0),
}
}
fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool {
if uid < config.minimum_uid || config.excluded_users.iter().any(|value| value == username) {
return false;
}
if shell == Path::new("/bin/false")
|| shell == Path::new("/usr/sbin/nologin")
|| shell == Path::new("/sbin/nologin")
|| !shell.is_absolute()
{
return false;
}
let Ok(metadata) = std::fs::symlink_metadata(home) else {
return false;
};
metadata.is_dir()
&& (metadata.uid() == uid || metadata.uid() == 0)
&& metadata.mode() & 0o022 == 0
}
fn c_string(pointer: *const libc::c_char) -> Option<String> {
(!pointer.is_null())
.then(|| {
unsafe { CStr::from_ptr(pointer) }
.to_str()
.ok()
.map(str::to_owned)
})
.flatten()
}
fn c_path(pointer: *const libc::c_char) -> Option<PathBuf> {
c_string(pointer)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn parse_failure_count(bytes: &[u8]) -> u32 {
let mut rows = 0u32;
for line in String::from_utf8_lossy(bytes).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((_, count)) = line.split_once(':')
&& let Some(value) = count.split_whitespace().next()
&& let Ok(parsed) = value.parse::<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> {
if !trusted_helper(path) {
return None;
}
let contents = std::fs::read_to_string(path).ok()?;
contents.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "deny")
.then(|| value.trim().parse().ok())
.flatten()
})
}
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 {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
metadata.is_file()
&& metadata.uid() == 0
&& metadata.mode() & 0o022 == 0
&& (metadata.mode() & 0o111 != 0 || path.ends_with("faillock.conf"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_faillock_rows_with_username_and_timestamp_prefixes() {
assert_eq!(parse_failure_count(b"alice: 4\n"), 4);
assert_eq!(
parse_failure_count(b"2026-01-01 12:00:00 TTY 1\n2026-01-02 12:00:00 TTY 1\n"),
2
);
}
#[test]
fn parses_shadow_aging_and_lock_markers_without_exposing_hashes() {
let entry = parse_shadow_entry("alice:$6$redacted:0:0:30:7:14:20000:").expect("entry");
assert_eq!(entry.last_change, Some(0));
assert_eq!(entry.max_days, Some(30));
assert_eq!(entry.inactive_days, Some(14));
assert_eq!(entry.account_expiry, Some(20_000));
assert!(shadow_hash_is_locked("!$6$hash"));
assert!(!shadow_hash_is_locked("$6$hash"));
}
#[test]
fn account_state_prioritises_non_login_shell_and_expiry() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/usr/sbin/nologin".into(),
};
let status = account_status_from_shadow(
&identity,
Some(ShadowEntry {
hash: "*".into(),
last_change: Some(1),
max_days: Some(1),
inactive_days: Some(1),
account_expiry: Some(1),
}),
20_000,
);
assert_eq!(status.state, AccountState::NoLoginShell);
}
#[test]
fn detects_systemd_logind_only_when_runtime_markers_exist() {
assert!(logind_markers_present(true, true));
assert!(!logind_markers_present(true, false));
assert!(!logind_markers_present(false, true));
}
#[test]
fn parses_legacy_failure_count() {
assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4);
assert_eq!(parse_failure_count(b""), 0);
}
#[test]
fn filters_non_login_shells_and_untrusted_homes() {
let config = AccountsConfig::default();
assert!(!eligible(
"service",
1000,
Path::new("/tmp"),
Path::new("/bin/false"),
&config,
));
assert!(!eligible(
"service",
1000,
Path::new("/definitely/missing"),
Path::new("/bin/sh"),
&config,
));
}
#[cfg(unix)]
#[test]
fn unavailable_faillock_helper_is_advisory() {
let config = AccountsConfig {
faillock_path: PathBuf::from("/bin/false"),
..AccountsConfig::default()
};
let status = faillock_status("alice", &config).expect("false helper should be handled");
assert_eq!(status, FaillockStatus::default());
}
}

View File

@@ -0,0 +1,155 @@
//! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information.
use std::fmt;
use crate::error::Error;
/// Login operation that produced an audit event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction {
Authentication,
PasswordChange,
Session,
}
impl fmt::Display for AuditAction {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Authentication => "authentication",
Self::PasswordChange => "password_change",
Self::Session => "session",
})
}
}
/// Result recorded for a login operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditOutcome {
Started,
Succeeded,
Failed,
PasswordChangeRequired,
}
impl fmt::Display for AuditOutcome {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Started => "started",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::PasswordChangeRequired => "password_change_required",
})
}
}
/// Stable category for a PAM status without its free-form message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PamReason {
AuthenticationRejected,
AuthenticationUnavailable,
AccountExpired,
PasswordChangeRequired,
TooManyAttempts,
Conversation,
System,
Unknown,
}
impl fmt::Display for PamReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::AuthenticationRejected => "authentication_rejected",
Self::AuthenticationUnavailable => "authentication_unavailable",
Self::AccountExpired => "account_expired",
Self::PasswordChangeRequired => "password_change_required",
Self::TooManyAttempts => "too_many_attempts",
Self::Conversation => "conversation_error",
Self::System => "system_error",
Self::Unknown => "unknown_pam_error",
})
}
}
/// Event fields intended for the audit logger.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditEvent<'a> {
pub action: AuditAction,
pub outcome: AuditOutcome,
/// Account name supplied by the user, never a password or token.
pub account: &'a str,
pub pam_reason: Option<PamReason>,
}
impl<'a> AuditEvent<'a> {
pub const fn started(action: AuditAction, account: &'a str) -> Self {
Self {
action,
outcome: AuditOutcome::Started,
account,
pam_reason: None,
}
}
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
Self {
action,
outcome: AuditOutcome::Succeeded,
account,
pam_reason: None,
}
}
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
Self {
action,
outcome: if error.requires_password_change() {
AuditOutcome::PasswordChangeRequired
} else {
AuditOutcome::Failed
},
account,
pam_reason: error.pam_status().map(pam_reason),
}
}
}
/// Map Linux-PAM status codes to stable categories.
pub const fn pam_reason(status: i32) -> PamReason {
match status {
6 | 7 => PamReason::AuthenticationRejected,
9 | 10 => PamReason::AuthenticationUnavailable,
11 => PamReason::TooManyAttempts,
12 | 27 => PamReason::PasswordChangeRequired,
13 => PamReason::AccountExpired,
19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 => PamReason::Conversation,
1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 28 | 29 => PamReason::System,
_ => PamReason::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_change_status_is_classified_without_raw_message() {
let error = Error::Pam {
operation: "pam_acct_mgmt",
status: 12,
message: "secret module detail".into(),
};
let event = AuditEvent::failed(AuditAction::Authentication, "alice", &error);
assert_eq!(event.outcome, AuditOutcome::PasswordChangeRequired);
assert_eq!(event.pam_reason, Some(PamReason::PasswordChangeRequired));
assert!(!format!("{event:?}").contains("secret module detail"));
}
#[test]
fn every_known_pam_family_has_a_stable_reason() {
assert_eq!(pam_reason(7), PamReason::AuthenticationRejected);
assert_eq!(pam_reason(9), PamReason::AuthenticationUnavailable);
assert_eq!(pam_reason(13), PamReason::AccountExpired);
assert_eq!(pam_reason(11), PamReason::TooManyAttempts);
assert_eq!(pam_reason(999), PamReason::Unknown);
}
}

View File

@@ -0,0 +1,360 @@
use std::{
collections::HashMap,
env, fs,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::error::{Error, ResultExt};
pub const SYSTEM_CONFIG_PATH: &str = "/etc/latchd/config.toml";
const PROJECT_CONFIG_DIR: &str = "latchd";
const CONFIG_FILE: &str = "config.toml";
/// Build the current user's configuration path from XDG variables.
pub fn user_config_path() -> Option<PathBuf> {
let base = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(base.join(PROJECT_CONFIG_DIR).join(CONFIG_FILE))
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub tty: u8,
pub main_log_path: PathBuf,
pub client_log_path: PathBuf,
pub pam_service: String,
pub system_shell: PathBuf,
pub initial_path: String,
pub x11: X11Config,
pub wayland: WaylandConfig,
pub environment_switcher: SwitcherConfig,
pub power_controls: PowerControls,
pub accounts: AccountsConfig,
pub visual: VisualConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
tty: 2,
main_log_path: PathBuf::from("/var/log/latchd.log"),
client_log_path: PathBuf::from("/var/log/latchd.client.log"),
pam_service: "latchd".into(),
system_shell: PathBuf::from("/bin/sh"),
initial_path: "/usr/local/sbin:/usr/local/bin:/usr/bin".into(),
x11: X11Config::default(),
wayland: WaylandConfig::default(),
environment_switcher: SwitcherConfig::default(),
power_controls: PowerControls::default(),
accounts: AccountsConfig::default(),
visual: VisualConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AccountsConfig {
pub minimum_uid: u32,
pub excluded_users: Vec<String>,
pub enumerate: bool,
pub faillock_path: PathBuf,
pub faillock_config: PathBuf,
pub shadow_path: PathBuf,
}
impl Default for AccountsConfig {
fn default() -> Self {
Self {
minimum_uid: 1000,
excluded_users: Vec::new(),
enumerate: true,
faillock_path: PathBuf::from("/usr/sbin/faillock"),
faillock_config: PathBuf::from("/etc/security/faillock.conf"),
shadow_path: PathBuf::from("/etc/shadow"),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VisualConfig {
pub wallpaper_path: Option<PathBuf>,
pub background_enabled: bool,
pub animations: bool,
pub notifications: bool,
pub reduced_motion: bool,
pub max_wallpaper_bytes: u64,
}
impl Default for VisualConfig {
fn default() -> Self {
Self {
wallpaper_path: None,
background_enabled: true,
animations: true,
notifications: true,
reduced_motion: false,
max_wallpaper_bytes: 8 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct X11Config {
pub display: String,
pub server_path: PathBuf,
pub xauth_path: PathBuf,
pub server_timeout_secs: u16,
pub server_log_path: PathBuf,
pub sessions_path: PathBuf,
pub scripts_path: PathBuf,
}
impl Default for X11Config {
fn default() -> Self {
Self {
display: ":1".into(),
server_path: "/usr/bin/X".into(),
xauth_path: "/usr/bin/xauth".into(),
server_timeout_secs: 60,
server_log_path: "/var/log/latchd.xorg.log".into(),
sessions_path: "/usr/share/xsessions".into(),
scripts_path: "/etc/latchd/wms".into(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WaylandConfig {
pub sessions_path: PathBuf,
pub scripts_path: PathBuf,
}
impl Default for WaylandConfig {
fn default() -> Self {
Self {
sessions_path: "/usr/share/wayland-sessions".into(),
scripts_path: "/etc/latchd/wayland".into(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SwitcherConfig {
pub include_tty_shell: bool,
pub max_display_length: usize,
pub remember_last_session: bool,
pub last_session_path: PathBuf,
}
impl Default for SwitcherConfig {
fn default() -> Self {
Self {
include_tty_shell: false,
max_display_length: 18,
remember_last_session: true,
last_session_path: PathBuf::from("/var/lib/latchd/last-session.toml"),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PowerControls {
pub entries: Vec<PowerAction>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PowerAction {
pub key: String,
pub hint: String,
pub program: PathBuf,
#[serde(default)]
pub args: Vec<String>,
}
/// Load a TOML file over the built-in defaults and validate it.
pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
let mut config = Config::default();
if let Some(variables_path) = variables {
let values = load_variables(variables_path)?;
if let Some(config_path) = path {
let raw = fs::read_to_string(config_path)
.io_context(format!("read {}", config_path.display()))?;
let expanded = resolve_variables(&raw, &values);
config = toml::from_str(&expanded).map_err(|source| Error::Toml {
path: config_path.display().to_string(),
source,
})?;
}
} else if let Some(config_path) = path {
let raw = fs::read_to_string(config_path)
.io_context(format!("read {}", config_path.display()))?;
config = toml::from_str(&raw).map_err(|source| Error::Toml {
path: config_path.display().to_string(),
source,
})?;
}
validate(&config)?;
Ok(config)
}
fn load_variables(path: &Path) -> Result<HashMap<String, String>, Error> {
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
let values: HashMap<String, toml::Value> =
toml::from_str(&raw).map_err(|source| Error::Toml {
path: path.display().to_string(),
source,
})?;
Ok(values
.into_iter()
.map(|(key, value)| (key, value.to_string().trim_matches('"').to_owned()))
.collect())
}
fn resolve_variables(input: &str, variables: &HashMap<String, String>) -> String {
let mut keys: Vec<_> = variables.keys().collect();
keys.sort_by_key(|key| std::cmp::Reverse(key.len()));
keys.into_iter().fold(input.to_owned(), |result, key| {
result.replace(&format!("${key}"), &variables[key])
})
}
/// Validate values and paths before the login screen starts.
pub fn validate(config: &Config) -> Result<(), Error> {
if config.tty == 0 || config.tty > 63 {
return Err(Error::Config("tty must be between 1 and 63".into()));
}
if config.pam_service.is_empty() || config.pam_service.contains('\0') {
return Err(Error::Config("pam_service must be a non-empty name".into()));
}
if config.initial_path.is_empty() {
return Err(Error::Config("initial_path must not be empty".into()));
}
if config
.initial_path
.split(':')
.any(|entry| entry.is_empty() || !Path::new(entry).is_absolute())
{
return Err(Error::Config(
"initial_path must contain only non-empty absolute directories".into(),
));
}
if config.environment_switcher.max_display_length == 0 {
return Err(Error::Config(
"environment_switcher.max_display_length must be greater than zero".into(),
));
}
if config.environment_switcher.remember_last_session
&& !config.environment_switcher.last_session_path.is_absolute()
{
return Err(Error::Config(
"environment_switcher.last_session_path must be absolute".into(),
));
}
for (name, path) in [
("main_log_path", &config.main_log_path),
("client_log_path", &config.client_log_path),
("system_shell", &config.system_shell),
("x11.server_path", &config.x11.server_path),
("x11.xauth_path", &config.x11.xauth_path),
("x11.server_log_path", &config.x11.server_log_path),
("accounts.faillock_path", &config.accounts.faillock_path),
("accounts.faillock_config", &config.accounts.faillock_config),
("accounts.shadow_path", &config.accounts.shadow_path),
] {
if !path.is_absolute() {
return Err(Error::Config(format!("{name} must be an absolute path")));
}
}
if config.visual.max_wallpaper_bytes == 0
|| config.visual.max_wallpaper_bytes > 64 * 1024 * 1024
{
return Err(Error::Config(
"visual.max_wallpaper_bytes must be between 1 and 67108864".into(),
));
}
if let Some(path) = &config.visual.wallpaper_path {
if !path.is_absolute() {
return Err(Error::Config(
"visual.wallpaper_path must be an absolute path".into(),
));
}
}
if config.x11.display.trim().is_empty() || config.x11.server_timeout_secs == 0 {
return Err(Error::Config(
"x11 display must be non-empty and server_timeout_secs must be greater than zero"
.into(),
));
}
for action in &config.power_controls.entries {
let valid_key = action
.key
.strip_prefix('F')
.and_then(|number| number.parse::<u8>().ok())
.is_some_and(|number| (1..=12).contains(&number));
if !valid_key || action.hint.is_empty() || !action.program.is_absolute() {
return Err(Error::Config(format!(
"power action {} must use F1-F12, an absolute program path, and a non-empty label",
action.key
)));
}
}
Ok(())
}
/// Require a root-owned regular file without group or other write permission.
pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
let metadata =
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"{label} must be owned by root, non-group-writable regular file: {}",
path.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variables_are_replaced_longest_first() {
let mut vars = HashMap::new();
vars.insert("A".into(), "short".into());
vars.insert("AB".into(), "long".into());
assert_eq!(resolve_variables("$AB/$A", &vars), "long/short");
}
#[test]
fn defaults_are_valid() {
validate(&Config::default()).expect("defaults are static and valid");
}
#[test]
fn unknown_fields_are_rejected() {
assert!(toml::from_str::<Config>("unexpected = true").is_err());
}
#[test]
fn relative_path_entries_are_rejected() {
let config = Config {
initial_path: "relative:/usr/bin".into(),
..Config::default()
};
assert!(validate(&config).is_err());
}
}

View File

@@ -0,0 +1,167 @@
use std::{fmt::Write as _, io};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Configuration error: {0}")]
Config(String),
#[error("I/O error while {context}: {source}")]
Io {
context: String,
#[source]
source: io::Error,
},
#[error("TOML error in {path}: {source}")]
Toml {
path: String,
#[source]
source: toml::de::Error,
},
#[error("PAM error during {operation} (status {status}): {message}")]
Pam {
operation: &'static str,
status: i32,
message: String,
},
#[error("Session error: {0}")]
Session(String),
#[error("Terminal error: {0}")]
Terminal(String),
#[error("Authentication worker error: {0}")]
Worker(String),
}
impl Error {
/// Return the PAM status when the error came from PAM.
pub fn pam_status(&self) -> Option<i32> {
match self {
Self::Pam { status, .. } => Some(*status),
_ => None,
}
}
/// Check whether the status requests a password change.
pub fn requires_password_change(&self) -> bool {
matches!(self.pam_status(), Some(12 | 27))
}
/// Build a user-facing report with context and recovery commands.
pub fn user_report(&self) -> String {
let mut report = format!("{}\n\nDetails: {}\n\nTry:\n", self, self.details());
for fix in self.fixes() {
let _ = writeln!(report, " - {fix}");
}
report
}
fn details(&self) -> &'static str {
match self {
Self::Config(_) => {
"latchd rejected a configuration value before it opened the login screen."
}
Self::Io { .. } => {
"The operating system rejected a file operation. The path, owner, mode, or free space can be wrong."
}
Self::Toml { .. } => {
"The TOML parser found an invalid key, value, or separator at the location shown above."
}
Self::Pam { .. } => {
"The login service returned a PAM status. The status and module message appear above."
}
Self::Session(_) => "latchd could not prepare or start the selected desktop session.",
Self::Terminal(_) => {
"The terminal does not provide the input or output device that latchd needs."
}
Self::Worker(_) => {
"A background latchd task stopped before it completed the requested operation."
}
}
}
fn fixes(&self) -> Vec<String> {
match self {
Self::Config(_) => vec![
"Edit the value named in the error.".into(),
"Run `latchd --preview --no-tui` to check the user configuration.".into(),
"Run `sudo latchd --no-tui` to check the system configuration.".into(),
],
Self::Io { .. } => vec![
"Check the path and permissions named in the error.".into(),
"Run `df -h` to check free space.".into(),
"Run `sudo journalctl -u latchd.service -b` to read the service log.".into(),
],
Self::Toml { path, .. } => {
let path = shell_quote(path);
vec![
format!("Open {path} at the reported line and column."),
format!("Run `latchd --preview --no-tui --config {path}` after the edit."),
]
}
Self::Pam { .. } => vec![
"Run `sudo journalctl -u latchd.service -b` to read the login service log.".into(),
"Run `sudo faillock --user <account>` to check the account lock state.".into(),
"Check the PAM service file with `sudoedit /etc/pam.d/<pam-service>`.".into(),
],
Self::Session(_) => vec![
"Run `latchd --preview --no-tui` to validate the configuration and discovered sessions.".into(),
"Run `sudo journalctl -u latchd.service -b` to read the session log.".into(),
"Check the selected desktop command with `command -v <program>`.".into(),
],
Self::Terminal(_) => vec![
"Run `tty` to confirm that the process has a controlling terminal.".into(),
"Run `latchd --preview` to test the screen without a real login.".into(),
"Run `sudo systemctl restart latchd.service` after you attach latchd to a real VT.".into(),
],
Self::Worker(_) => vec![
"Run `sudo systemctl restart latchd.service` to restart the worker.".into(),
"Run `sudo journalctl -u latchd.service -b` to find the worker failure.".into(),
],
}
}
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
Error::Io {
context: context.into(),
source,
}
}
pub trait ResultExt<T> {
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
}
impl<T> ResultExt<T> for io::Result<T> {
fn io_context(self, context: impl Into<String>) -> Result<T, Error> {
self.map_err(|source| io_error(context, source))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configuration_report_contains_details_and_commands() {
let report = Error::Config("tty must be between 1 and 63".into()).user_report();
assert!(report.contains("Details:"));
assert!(report.contains("latchd --preview --no-tui"));
assert!(report.contains("sudo latchd --no-tui"));
}
#[test]
fn toml_report_quotes_the_source_path() {
let error = Error::Toml {
path: "/tmp/latchd config.toml".into(),
source: toml::from_str::<toml::Value>("=").expect_err("invalid TOML"),
};
let report = error.user_report();
assert!(report.contains("'/tmp/latchd config.toml'"));
assert!(report.contains("--config '/tmp/latchd config.toml'"));
}
}

View File

@@ -0,0 +1,11 @@
//! Shared configuration, account, session, and accounting types.
#![deny(unsafe_op_in_unsafe_fn)]
pub mod accounting;
pub mod accounts;
pub mod audit;
pub mod config;
pub mod error;
pub mod preferences;
pub mod session;

View File

@@ -0,0 +1,253 @@
//! Store the last session choice with private, atomic writes.
use std::{
fs::{self, OpenOptions},
io::Write,
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use crate::{
error::{Error, ResultExt, io_error},
session::{SessionDescriptor, SessionKind},
};
const MAX_ENTRIES: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LastUsedEntry {
pub username: String,
pub kind: SessionKind,
pub title: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LastUsedPreferences {
entries: Vec<LastUsedEntry>,
}
impl LastUsedPreferences {
/// Record a successful selection and move it to the front.
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
if username.is_empty() || session.title.is_empty() {
return;
}
self.entries.retain(|entry| entry.username != username);
self.entries.insert(
0,
LastUsedEntry {
username: username.to_owned(),
kind: session.kind,
title: session.title.clone(),
},
);
self.entries.truncate(MAX_ENTRIES);
}
/// Find the saved session for an account.
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
let entry = self
.entries
.iter()
.find(|entry| entry.username == username)?;
sessions
.iter()
.position(|session| session.kind == entry.kind && session.title == entry.title)
}
pub fn entries(&self) -> &[LastUsedEntry] {
&self.entries
}
}
/// Load preferences from an owner-checked TOML file.
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
}
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LastUsedPreferences::default());
}
Err(error) => return Err(io_error(format!("inspect {}", path.display()), error)),
};
if !trusted_file(&metadata) {
return Err(Error::Config(format!(
"last-session file must be an owner-only regular file: {}",
path.display()
)));
}
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
let preferences =
toml::from_str::<LastUsedPreferences>(&raw).map_err(|source| Error::Toml {
path: path.display().to_string(),
source,
})?;
validate(&preferences, path)?;
Ok(preferences)
}
/// Save preferences with mode `0600` under a trusted directory.
pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
}
validate(preferences, path)?;
let parent = path.parent().ok_or_else(|| {
Error::Config(format!(
"last-session path has no parent: {}",
path.display()
))
})?;
fs::create_dir_all(parent).io_context(format!("create {}", parent.display()))?;
let parent_metadata =
fs::symlink_metadata(parent).io_context(format!("inspect {}", parent.display()))?;
if !trusted_directory(&parent_metadata) {
return Err(Error::Config(format!(
"last-session directory must be owner-owned and non-writable: {}",
parent.display()
)));
}
let content = toml::to_string_pretty(preferences)
.map_err(|error| Error::Config(format!("serialize last-session preferences: {error}")))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let temporary = parent.join(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("last-session"),
std::process::id(),
nonce
));
let write_result = (|| -> Result<(), Error> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&temporary)
.io_context(format!("create {}", temporary.display()))?;
file.write_all(content.as_bytes())
.io_context(format!("write {}", temporary.display()))?;
file.sync_all()
.io_context(format!("sync {}", temporary.display()))?;
fs::rename(&temporary, path).io_context(format!("replace {}", path.display()))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.io_context(format!("secure {}", path.display()))?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result
}
fn validate(preferences: &LastUsedPreferences, path: &Path) -> Result<(), Error> {
if preferences.entries.len() > MAX_ENTRIES {
return Err(Error::Config(format!(
"last-session file contains more than {MAX_ENTRIES} entries: {}",
path.display()
)));
}
if preferences
.entries
.iter()
.any(|entry| entry.username.is_empty() || entry.title.is_empty())
{
return Err(Error::Config(format!(
"last-session entries must have a username and title: {}",
path.display()
)));
}
Ok(())
}
fn trusted_file(metadata: &std::fs::Metadata) -> bool {
metadata.is_file() && metadata.uid() == effective_uid() && metadata.mode() & 0o077 == 0
}
fn trusted_directory(metadata: &std::fs::Metadata) -> bool {
metadata.is_dir() && metadata.uid() == effective_uid() && metadata.mode() & 0o022 == 0
}
fn effective_uid() -> u32 {
// SAFETY: geteuid has no pointer arguments or memory preconditions.
unsafe { libc::geteuid() }
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor(kind: SessionKind, title: &str) -> SessionDescriptor {
SessionDescriptor {
kind,
title: title.into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}
}
fn test_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"latchd-preferences-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
))
}
#[test]
fn latest_successful_session_is_preferred_per_account() {
let wayland = descriptor(SessionKind::Wayland, "Sway");
let tty = descriptor(SessionKind::Tty, "TTY shell");
let mut preferences = LastUsedPreferences::default();
preferences.remember("alice", &wayland);
assert_eq!(
preferences.preferred_index("alice", &[tty.clone(), wayland]),
Some(1)
);
preferences.remember("alice", &tty);
assert_eq!(
preferences.preferred_index("alice", &[tty, descriptor(SessionKind::Wayland, "Sway")]),
Some(0)
);
assert_eq!(preferences.entries()[0].username, "alice");
}
#[test]
fn preferences_round_trip_atomically_with_private_permissions() {
let directory = test_path();
let path = directory.join("last-session.toml");
let session = descriptor(SessionKind::X11, "GNOME");
let mut preferences = LastUsedPreferences::default();
preferences.remember("alice", &session);
save(&path, &preferences).expect("preference save should succeed");
let loaded = load(&path).expect("preference load should succeed");
assert_eq!(loaded, preferences);
let mode = fs::symlink_metadata(&path)
.expect("preference metadata should exist")
.mode();
assert_eq!(mode & 0o077, 0);
let _ = fs::remove_dir_all(directory);
}
#[test]
fn missing_preferences_are_a_safe_empty_store() {
let path = test_path().join("missing.toml");
assert_eq!(
load(&path).expect("missing preferences are optional"),
LastUsedPreferences::default()
);
}
}

View File

@@ -0,0 +1,895 @@
//! Discover sessions, build their environments, and run their children.
use std::{
ffi::{CString, OsStr},
fs::{self, File, OpenOptions},
io::{self, Write},
os::unix::{
fs::{MetadataExt, OpenOptionsExt},
io::AsRawFd,
process::CommandExt,
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use crate::{
accounting::UtmpxGuard,
accounts::Identity,
config::Config,
error::{Error, ResultExt, io_error},
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use signal_hook::{consts::SIGUSR1, flag};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionKind {
Tty,
X11,
Wayland,
}
#[derive(Debug, Clone)]
pub struct SessionDescriptor {
pub kind: SessionKind,
pub title: String,
/// Program and arguments kept separate from shell parsing.
pub argv: Vec<String>,
pub current_desktop: Option<String>,
pub session_desktop: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SessionSpec {
pub kind: SessionKind,
pub title: String,
pub argv: Vec<String>,
pub environment: Vec<(String, String)>,
pub working_directory: PathBuf,
pub client_log_path: PathBuf,
pub shell: PathBuf,
pub tty: u8,
pub x11: Option<X11Runtime>,
}
#[derive(Debug, Clone)]
pub struct X11Runtime {
pub display: String,
pub server_path: PathBuf,
pub xauth_path: PathBuf,
pub timeout: Duration,
pub log_path: PathBuf,
}
const PAM_SESSION_ENVIRONMENT_KEYS: [&str; 5] = [
"XDG_SESSION_CLASS",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"XDG_SEAT",
"XDG_VTNR",
];
impl SessionSpec {
pub fn pam_tty(&self) -> String {
format!("tty{}", self.tty)
}
/// Return session metadata for the login stack and logind.
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
self.environment
.iter()
.filter(|(key, _)| PAM_SESSION_ENVIRONMENT_KEYS.contains(&key.as_str()))
.map(|(key, value)| (key.as_str(), value.as_str()))
}
}
/// Discover configured sessions in sorted directory order.
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
let mut sessions = Vec::new();
scan_desktop_dir(
&config.x11.sessions_path,
SessionKind::X11,
&config.initial_path,
&mut sessions,
);
scan_desktop_dir(
&config.wayland.sessions_path,
SessionKind::Wayland,
&config.initial_path,
&mut sessions,
);
scan_script_dir(&config.x11.scripts_path, SessionKind::X11, &mut sessions);
scan_script_dir(
&config.wayland.scripts_path,
SessionKind::Wayland,
&mut sessions,
);
if sessions.is_empty() || config.environment_switcher.include_tty_shell {
sessions.push(SessionDescriptor {
kind: SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
});
}
sessions
}
/// Check configured binaries and session entries without starting them.
pub fn preflight(config: &Config) -> Result<(), Error> {
let shell = secure_executable(&config.system_shell, "configured system shell")?;
if !shell.is_file() {
return Err(Error::Config(format!(
"configured system shell is not a regular file: {}",
shell.display()
)));
}
let sessions = discover(config);
for session in &sessions {
if session.kind == SessionKind::Tty {
continue;
}
let Some(program) = session.argv.first() else {
return Err(Error::Config(format!(
"session {} has no executable",
session.title
)));
};
let executable = find_executable(program, &config.initial_path).ok_or_else(|| {
Error::Config(format!(
"session {} executable is not available: {program}",
session.title
))
})?;
secure_executable(&executable, "session executable")?;
}
if sessions
.iter()
.any(|session| session.kind == SessionKind::X11)
{
secure_executable(&config.x11.server_path, "X11 server")?;
secure_executable(&config.x11.xauth_path, "xauth")?;
}
Ok(())
}
fn find_executable(program: &str, path: &str) -> Option<PathBuf> {
let candidate = Path::new(program);
if candidate.is_absolute() {
return candidate.exists().then(|| candidate.to_owned());
}
path.split(':')
.map(|directory| Path::new(directory).join(program))
.find(|candidate| candidate.is_file())
}
fn secure_executable(path: &Path, label: &str) -> Result<PathBuf, Error> {
let resolved =
fs::canonicalize(path).io_context(format!("resolve {label} {}", path.display()))?;
let metadata = fs::symlink_metadata(&resolved)
.io_context(format!("inspect {label} {}", resolved.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"{label} must resolve to a root-owned, non-writable regular file: {}",
path.display()
)));
}
if metadata.mode() & 0o111 == 0 {
return Err(Error::Config(format!(
"{label} is not executable: {}",
path.display()
)));
}
Ok(resolved)
}
/// Build the environment and process specification for one account.
pub fn build_spec(
identity: &Identity,
config: &Config,
descriptor: &SessionDescriptor,
) -> Result<SessionSpec, Error> {
let mut environment = inherited_safe_environment();
insert_env(
&mut environment,
"HOME",
identity.home.to_string_lossy().into_owned(),
);
insert_env(
&mut environment,
"SHELL",
identity.shell.to_string_lossy().into_owned(),
);
insert_env(&mut environment, "USER", identity.username.clone());
insert_env(&mut environment, "LOGNAME", identity.username.clone());
insert_env(&mut environment, "PATH", config.initial_path.clone());
insert_env(
&mut environment,
"PWD",
identity.home.to_string_lossy().into_owned(),
);
insert_env(&mut environment, "XDG_SESSION_CLASS", "user".into());
insert_env(
&mut environment,
"XDG_SESSION_TYPE",
match descriptor.kind {
SessionKind::Tty => "tty",
SessionKind::X11 => "x11",
SessionKind::Wayland => "wayland",
}
.into(),
);
insert_env(&mut environment, "XDG_SEAT", "seat0".into());
insert_env(&mut environment, "XDG_VTNR", config.tty.to_string());
insert_env(
&mut environment,
"XDG_RUNTIME_DIR",
format!("/run/user/{}", identity.uid),
);
insert_env(
&mut environment,
"XDG_CONFIG_HOME",
identity.home.join(".config").to_string_lossy().into_owned(),
);
insert_env(
&mut environment,
"XDG_CACHE_HOME",
identity.home.join(".cache").to_string_lossy().into_owned(),
);
insert_env(
&mut environment,
"XDG_DATA_HOME",
identity
.home
.join(".local/share")
.to_string_lossy()
.into_owned(),
);
insert_env(
&mut environment,
"XDG_STATE_HOME",
identity
.home
.join(".local/state")
.to_string_lossy()
.into_owned(),
);
insert_env(
&mut environment,
"XDG_DATA_DIRS",
"/usr/local/share:/usr/share".into(),
);
insert_env(&mut environment, "XDG_CONFIG_DIRS", "/etc/xdg".into());
let x11 = if descriptor.kind == SessionKind::X11 {
insert_env(&mut environment, "DISPLAY", config.x11.display.clone());
Some(X11Runtime {
display: config.x11.display.clone(),
server_path: config.x11.server_path.clone(),
xauth_path: config.x11.xauth_path.clone(),
timeout: Duration::from_secs(config.x11.server_timeout_secs as u64),
log_path: config.x11.server_log_path.clone(),
})
} else {
None
};
if let Some(value) = descriptor.current_desktop.as_deref() {
insert_env(&mut environment, "XDG_CURRENT_DESKTOP", value.to_owned());
}
if let Some(value) = descriptor.session_desktop.as_deref() {
insert_env(&mut environment, "XDG_SESSION_DESKTOP", value.to_owned());
}
let mut argv = if descriptor.kind == SessionKind::Tty {
Vec::new()
} else if descriptor.argv.is_empty() {
return Err(Error::Session(format!(
"session {} has no executable",
descriptor.title
)));
} else {
descriptor.argv.clone()
};
if let Some(program) = argv.first_mut() {
if !Path::new(program).is_absolute() {
let resolved = find_executable(program, &config.initial_path).ok_or_else(|| {
Error::Session(format!(
"session {} executable is not available: {program}",
descriptor.title
))
})?;
*program = resolved.to_string_lossy().into_owned();
}
}
if !identity.shell.is_absolute() {
return Err(Error::Session(
"account shell must be an absolute path".into(),
));
}
Ok(SessionSpec {
kind: descriptor.kind,
title: descriptor.title.clone(),
argv,
environment,
working_directory: identity.home.clone(),
client_log_path: config.client_log_path.clone(),
shell: identity.shell.clone(),
tty: config.tty,
x11,
})
}
/// Run a user session and clean up its child process.
pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
let home_metadata = fs::symlink_metadata(&identity.home)
.map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?;
if !home_metadata.is_dir()
|| (home_metadata.uid() != identity.uid && home_metadata.uid() != 0)
|| home_metadata.mode() & 0o022 != 0
{
return Err(Error::Session(format!(
"home directory is missing or insecure: {}",
identity.home.display()
)));
}
if spec.kind == SessionKind::X11 {
return run_x11(identity, spec);
}
let mut command = user_command(identity, spec)?;
let mut child = command.spawn().io_context("spawn user session")?;
let mut accounting = match UtmpxGuard::start(&identity.username, spec.tty, child.id() as _) {
Ok(guard) => guard,
Err(error) => {
let _ = terminate_child(&mut child);
return Err(error);
}
};
let status = match child.wait().io_context("wait for user session") {
Ok(status) => status,
Err(error) => {
let _ = terminate_child(&mut child);
return Err(error);
}
};
if !status.success() {
let _ = accounting.close();
return Err(Error::Session(format!(
"{} exited with {status}",
spec.title
)));
}
accounting.close()?;
Ok(())
}
fn user_command(identity: &Identity, spec: &SessionSpec) -> Result<Command, Error> {
let (program, args): (&OsStr, &[String]) = if spec.kind == SessionKind::Tty {
(spec.shell.as_os_str(), &[] as &[String])
} else {
let program = spec
.argv
.first()
.ok_or_else(|| Error::Session("empty session argv".into()))?;
(OsStr::new(program), &spec.argv[1..])
};
let mut command = Command::new(program);
command
.args(args)
.current_dir(&spec.working_directory)
.env_clear()
.envs(spec.environment.iter().map(|(key, value)| (key, value)))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if spec.kind != SessionKind::Tty {
let output = private_log(&spec.client_log_path)?;
let error = output.try_clone().io_context("clone client log")?;
command
.stdout(Stdio::from(output))
.stderr(Stdio::from(error));
}
install_user_credentials(&mut command, identity, spec.kind == SessionKind::Tty)?;
Ok(command)
}
fn install_user_credentials(
command: &mut Command,
identity: &Identity,
controlling_tty: bool,
) -> Result<(), Error> {
let username = CString::new(identity.username.as_str())
.map_err(|_| Error::Session("username contains NUL".into()))?;
let uid = identity.uid;
let gid = identity.gid;
unsafe {
command.pre_exec(move || {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
if controlling_tty && libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 {
return Err(io::Error::last_os_error());
}
if libc::initgroups(username.as_ptr(), gid) == -1 {
return Err(io::Error::last_os_error());
}
if libc::setgid(gid) == -1 || libc::setuid(uid) == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
Ok(())
}
fn run_x11(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
let runtime = spec
.x11
.as_ref()
.ok_or_else(|| Error::Session("missing X11 runtime configuration".into()))?;
let authority = create_xauthority(identity, runtime)?;
let ready = Arc::new(AtomicBool::new(false));
flag::register(SIGUSR1, Arc::clone(&ready))
.map_err(|error| Error::Session(format!("register X11 readiness signal: {error}")))?;
let x_log = private_log(&runtime.log_path)?;
let x_log_err = x_log.try_clone().io_context("clone Xorg log")?;
let mut server = Command::new(&runtime.server_path);
let vt = format!("vt{}", spec.tty);
server
.args([runtime.display.as_str(), vt.as_str()])
.arg("-auth")
.arg(authority.path())
.stdin(Stdio::null())
.stdout(Stdio::from(x_log))
.stderr(Stdio::from(x_log_err));
unsafe {
server.pre_exec(|| {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut xserver = server.spawn().io_context("spawn X server")?;
let deadline = Instant::now() + runtime.timeout;
while !ready.load(Ordering::Acquire) {
if let Some(status) = xserver.try_wait().io_context("poll X server")? {
return Err(Error::Session(format!("X server exited with {status}")));
}
if runtime.timeout != Duration::ZERO && Instant::now() >= deadline {
terminate_child(&mut xserver)?;
return Err(Error::Session("X server readiness timeout".into()));
}
thread::sleep(Duration::from_millis(20));
}
let mut environment = spec.environment.clone();
insert_env(
&mut environment,
"XAUTHORITY",
authority.path().to_string_lossy().into_owned(),
);
let mut client_spec = spec.clone();
client_spec.environment = environment;
let result = user_command(identity, &client_spec).and_then(|mut client| {
let mut child = client.spawn().io_context("spawn X11 client")?;
match child.wait().io_context("wait for X11 client") {
Ok(status) => Ok(status),
Err(error) => {
let _ = terminate_child(&mut child);
Err(error)
}
}
});
let cleanup = terminate_child(&mut xserver);
cleanup?;
let result = result?;
if !result.success() {
return Err(Error::Session(format!("X11 client exited with {result}")));
}
Ok(())
}
struct AuthorityFile(PathBuf);
impl AuthorityFile {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for AuthorityFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn create_xauthority(identity: &Identity, runtime: &X11Runtime) -> Result<AuthorityFile, Error> {
let mut cookie = [0u8; 16];
rand::rng().fill_bytes(&mut cookie);
let path = identity
.home
.join(format!(".Xauthority.latchd-{}", std::process::id()));
let file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.io_context(format!("create {}", path.display()))?;
let owner = unsafe { libc::fchown(file.as_raw_fd(), identity.uid, identity.gid) };
if owner == -1 {
let error = io::Error::last_os_error();
let _ = fs::remove_file(&path);
return Err(io_error(format!("chown {}", path.display()), error));
}
drop(file);
let mut command = Command::new(&runtime.xauth_path);
command
.arg("-f")
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Err(error) = install_user_credentials(&mut command, identity, false) {
let _ = fs::remove_file(&path);
return Err(error);
}
let mut child = match command.spawn().io_context("spawn xauth") {
Ok(child) => child,
Err(error) => {
let _ = fs::remove_file(&path);
return Err(error);
}
};
if let Some(mut stdin) = child.stdin.take() {
if let Err(error) = writeln!(stdin, "add {} . {}", runtime.display, hex_cookie(&cookie))
.io_context("write xauth command")
{
let _ = terminate_child(&mut child);
let _ = fs::remove_file(&path);
return Err(error);
}
}
let status = match child.wait().io_context("wait for xauth") {
Ok(status) => status,
Err(error) => {
let _ = fs::remove_file(&path);
return Err(error);
}
};
if !status.success() {
let _ = fs::remove_file(&path);
return Err(Error::Session(format!("xauth exited with {status}")));
}
Ok(AuthorityFile(path))
}
fn hex_cookie(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn terminate_child(child: &mut Child) -> Result<(), Error> {
if child.try_wait().io_context("poll child")?.is_some() {
return Ok(());
}
unsafe {
libc::kill(-(child.id() as i32), libc::SIGTERM);
}
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if child
.try_wait()
.io_context("wait for child termination")?
.is_some()
{
return Ok(());
}
thread::sleep(Duration::from_millis(25));
}
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
child.wait().io_context("reap killed child")?;
Ok(())
}
fn private_log(path: &Path) -> Result<File, Error> {
const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024;
let parent = path
.parent()
.ok_or_else(|| Error::Config(format!("log path has no parent: {}", path.display())))?;
let metadata = fs::symlink_metadata(parent)
.io_context(format!("inspect log directory {}", parent.display()))?;
if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"log directory must be root-owned and not group-writable: {}",
parent.display()
)));
}
let file = OpenOptions::new()
.create(true)
.append(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600)
.open(path)
.io_context(format!("open log {}", path.display()))?;
if file
.metadata()
.io_context(format!("inspect log {}", path.display()))?
.len()
> MAX_LOG_BYTES
{
return Err(Error::Config(format!(
"log exceeds {MAX_LOG_BYTES} bytes: {}",
path.display()
)));
}
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))
.io_context(format!("secure log {}", path.display()))?;
Ok(file)
}
fn inherited_safe_environment() -> Vec<(String, String)> {
std::env::vars()
.filter(|(key, _)| key == "TERM" || key == "LANG" || key.starts_with("LC_"))
.collect()
}
fn insert_env(environment: &mut Vec<(String, String)>, key: &str, value: String) {
if let Some((_, existing)) = environment.iter_mut().find(|(name, _)| name == key) {
*existing = value;
} else {
environment.push((key.into(), value));
}
}
fn scan_desktop_dir(
path: &Path,
kind: SessionKind,
session_path: &str,
output: &mut Vec<SessionDescriptor>,
) {
let Ok(entries) = fs::read_dir(path) else {
return;
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if entry.path().extension().and_then(|value| value.to_str()) != Some("desktop") {
continue;
}
let Ok(metadata) = fs::symlink_metadata(entry.path()) else {
continue;
};
if !metadata.is_file() || metadata.mode() & 0o022 != 0 || metadata.uid() != 0 {
continue;
}
let Ok(raw) = fs::read_to_string(entry.path()) else {
continue;
};
let fields = desktop_fields(&raw);
if fields
.get("Hidden")
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
|| fields
.get("NoDisplay")
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
{
continue;
}
if fields
.get("Type")
.is_some_and(|value| !value.eq_ignore_ascii_case("Application"))
{
continue;
}
if let Some(try_exec) = fields.get("TryExec")
&& find_executable(try_exec, session_path).is_none()
{
continue;
}
let Some(exec) = fields.get("Exec").and_then(|value| parse_argv(value).ok()) else {
continue;
};
if exec.is_empty() {
continue;
}
let title = fields
.get("Name")
.cloned()
.unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned());
let (current_desktop, session_desktop) = fields
.get("DesktopNames")
.map(|value| {
(
Some(value.replace(';', ":")),
value.split(';').next().map(str::to_owned),
)
})
.unwrap_or((None, None));
output.push(SessionDescriptor {
kind,
title,
argv: exec,
current_desktop,
session_desktop,
});
}
}
fn scan_script_dir(path: &Path, kind: SessionKind, output: &mut Vec<SessionDescriptor>) {
let Ok(entries) = fs::read_dir(path) else {
return;
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if !metadata.is_file()
|| metadata.mode() & 0o111 == 0
|| metadata.mode() & 0o022 != 0
|| metadata.uid() != 0
{
continue;
}
let file_name = entry.file_name();
let Some(title) = file_name.to_str() else {
continue;
};
output.push(SessionDescriptor {
kind,
title: title.into(),
argv: vec![path.to_string_lossy().into_owned()],
current_desktop: None,
session_desktop: None,
});
}
}
fn desktop_fields(raw: &str) -> std::collections::HashMap<String, String> {
let mut fields = std::collections::HashMap::new();
let mut in_desktop = false;
for line in raw.lines().map(str::trim) {
if line.starts_with('[') && line.ends_with(']') {
in_desktop = line == "[Desktop Entry]";
continue;
}
if !in_desktop || line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
fields.insert(key.trim().into(), value.trim().into());
}
}
fields
}
fn parse_argv(value: &str) -> Result<Vec<String>, Error> {
let mut output = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
for character in value.chars() {
if escaped {
current.push(character);
escaped = false;
continue;
}
match (quote, character) {
(_, '\\') => escaped = true,
(Some(expected), character) if expected == character => quote = None,
(None, '\'' | '"') => quote = Some(character),
(None, character) if character.is_whitespace() => {
if !current.is_empty() {
output.push(std::mem::take(&mut current));
}
}
(_, character) => current.push(character),
}
}
if escaped || quote.is_some() {
return Err(Error::Session("unterminated session command quote".into()));
}
if !current.is_empty() {
output.push(current);
}
if output.iter().any(|argument| argument.contains('%')) {
return Err(Error::Session(
"desktop Exec field codes are not supported".into(),
));
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_width::UnicodeWidthStr;
#[test]
fn parses_argv_without_shell_expansion() {
let argv = parse_argv("/bin/example --title 'hello world'").expect("valid argv");
assert_eq!(argv, ["/bin/example", "--title", "hello world"]);
}
#[test]
fn rejects_field_codes() {
assert!(parse_argv("foo %f").is_err());
}
#[test]
fn display_width_is_not_byte_length() {
assert_eq!(UnicodeWidthStr::width("界"), 2);
}
#[test]
fn environment_does_not_copy_display_or_xauthority() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "test".into(),
argv: vec!["/bin/true".into()],
current_desktop: None,
session_desktop: None,
};
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
assert!(
spec.environment
.iter()
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
);
}
#[test]
fn selected_wayland_metadata_is_ready_for_pam() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "Niri".into(),
argv: vec!["/usr/bin/niri-session".into()],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
let pam_environment = spec
.pam_environment()
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(spec.pam_tty(), "tty2");
assert_eq!(pam_environment.get("XDG_SESSION_CLASS"), Some(&"user"));
assert_eq!(pam_environment.get("XDG_SESSION_TYPE"), Some(&"wayland"));
assert_eq!(pam_environment.get("XDG_SESSION_DESKTOP"), Some(&"niri"));
assert_eq!(pam_environment.get("XDG_SEAT"), Some(&"seat0"));
assert_eq!(pam_environment.get("XDG_VTNR"), Some(&"2"));
assert!(
spec.environment
.iter()
.all(|(key, _)| key != "XDG_SESSION_ID"),
"logind, not latchd, owns the session identifier"
);
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "latchd-tui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
crossterm = "0.29"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] }
latchd-auth = { path = "../latchd-auth" }
latchd-core = { path = "../latchd-core" }
ratatui = "0.30.2"
tracing = "0.1"
unicode-width = "0.2"
zeroize = "1.8"

View File

@@ -0,0 +1,264 @@
//! Login screen state and orchestration.
use std::{
sync::mpsc::Receiver,
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use zeroize::Zeroizing;
use latchd_auth::{AuthService, AuthTicket};
use latchd_core::{
accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
config::Config,
error::{Error, ResultExt},
preferences::{self, LastUsedPreferences},
session::{self, SessionDescriptor},
};
use crate::background::Background;
mod auth;
mod discovery;
mod input;
mod render;
#[cfg(test)]
mod tests;
const MIN_WIDTH: u16 = 60;
const MIN_HEIGHT: u16 = 16;
const MAX_INPUT: usize = 256;
fn unknown_account_status() -> AccountStatus {
AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
}
}
pub struct LoginSelection {
pub ticket: AuthTicket,
pub session: SessionDescriptor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Sessions,
Username,
Password,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FilterTarget {
Sessions,
Accounts,
}
#[derive(Debug, Clone)]
struct FilterState {
target: FilterTarget,
query: String,
}
type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
#[derive(Debug)]
enum AuthState {
Idle,
Pending(Receiver<Result<AuthTicket, Error>>),
PasswordChangePending(Receiver<Result<(), Error>>),
Failed,
}
enum AuthCompletion {
Login(Result<AuthTicket, Error>),
PasswordChange(Result<(), Error>),
}
#[derive(Debug, Clone, Copy)]
enum NoticeLevel {
Info,
Warning,
}
#[derive(Debug)]
struct Notice {
message: String,
level: NoticeLevel,
expires: Instant,
}
struct App {
config: Config,
auth: AuthService,
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
account_statuses: Vec<AccountStatus>,
account_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>,
selected: usize,
focus: Focus,
username: String,
password: Zeroizing<String>,
pending_password: Zeroizing<Vec<u8>>,
pending_new_password: Zeroizing<String>,
password_change_mode: bool,
status: String,
auth_state: AuthState,
auth_failures: u32,
auth_blocked_until: Option<Instant>,
show_help: bool,
power_confirmation: Option<usize>,
should_quit: bool,
ascii: bool,
no_color: bool,
background: Background,
notice: Option<Notice>,
animation_frame: usize,
reduced_motion: bool,
filter: Option<FilterState>,
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
preferences: LastUsedPreferences,
preference_path: Option<std::path::PathBuf>,
}
impl App {
fn new(
config: Config,
auth: AuthService,
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
background: Background,
) -> Self {
let username = accounts
.first()
.map(|account| account.username.clone())
.unwrap_or_default();
let account_index = (!accounts.is_empty()).then_some(0);
let reduced_motion = config.visual.reduced_motion;
Self {
config,
auth,
preview,
sessions,
account_statuses: vec![unknown_account_status(); accounts.len()],
accounts,
account_index,
faillock_receiver: None,
faillock_status: None,
selected: 0,
focus: Focus::Username,
username,
password: Zeroizing::new(String::new()),
pending_password: Zeroizing::new(Vec::new()),
pending_new_password: Zeroizing::new(String::new()),
password_change_mode: false,
status: "Enter your username".into(),
auth_state: AuthState::Idle,
auth_failures: 0,
auth_blocked_until: None,
show_help: false,
power_confirmation: None,
should_quit: false,
ascii: std::env::var_os("LATCHD_ASCII").is_some(),
no_color: std::env::var_os("NO_COLOR").is_some(),
background,
notice: None,
animation_frame: 0,
reduced_motion,
filter: None,
refresh_receiver: None,
refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
preferences: LastUsedPreferences::default(),
preference_path: None,
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
loop {
self.poll_faillock();
self.poll_refresh();
let now = Instant::now();
if self.background.tick_if_due(now) {
self.animation_frame = self.animation_frame.wrapping_add(1);
}
if self
.notice
.as_ref()
.is_some_and(|notice| notice.expires <= now)
{
self.notice = None;
}
terminal
.draw(|frame| self.draw(frame))
.map_err(|error| Error::Terminal(error.to_string()))?;
if let Some(completion) = self.poll_auth() {
if let Some(selection) = self.handle_auth_completion(completion)? {
return Ok(Some(selection));
}
}
if self.should_quit {
return Ok(None);
}
if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? {
if let Event::Key(key) = event::read().io_context("read terminal event")? {
if key.kind == KeyEventKind::Press {
self.handle_key(key);
}
}
}
}
}
}
/// Run the login screen and restore the terminal on every return path.
pub fn run(
config: Config,
auth: AuthService,
preview: bool,
) -> Result<Option<LoginSelection>, Error> {
let sessions = session::discover(&config);
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
(Vec::new(), Vec::new())
};
let background = Background::load(&config.visual)?;
let preference_path = config
.environment_switcher
.remember_last_session
.then(|| config.environment_switcher.last_session_path.clone());
let preferences =
preference_path
.as_deref()
.map_or_else(LastUsedPreferences::default, |path| {
preferences::load(path).unwrap_or_else(|error| {
tracing::debug!(%error, "could not load last-session preferences");
LastUsedPreferences::default()
})
});
let mut app = App::new(config, auth, preview, sessions, accounts, background);
app.account_statuses = account_statuses;
app.account_index = (!app.accounts.is_empty()).then_some(0);
app.preferences = preferences;
app.preference_path = preference_path;
app.apply_preferred_session();
app.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
}

View File

@@ -0,0 +1,229 @@
use std::{
sync::mpsc,
thread,
time::{Duration, Instant},
};
use zeroize::Zeroizing;
use latchd_core::{
audit::{AuditAction, AuditEvent},
error::Error,
};
use super::{App, AuthCompletion, AuthState, Focus, LoginSelection, NoticeLevel};
impl App {
pub(super) fn poll_auth(&mut self) -> Option<AuthCompletion> {
match &self.auth_state {
AuthState::Pending(receiver) => match receiver.try_recv() {
Ok(result) => Some(AuthCompletion::Login(result)),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err(
Error::Worker("authentication task disconnected".into()),
))),
},
AuthState::PasswordChangePending(receiver) => match receiver.try_recv() {
Ok(result) => Some(AuthCompletion::PasswordChange(result)),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::PasswordChange(Err(
Error::Worker("password-change task disconnected".into()),
))),
},
AuthState::Idle | AuthState::Failed => None,
}
}
pub(super) fn handle_auth_completion(
&mut self,
completion: AuthCompletion,
) -> Result<Option<LoginSelection>, Error> {
match completion {
AuthCompletion::Login(Ok(ticket)) => {
self.audit(AuditEvent::succeeded(
AuditAction::Authentication,
&self.username,
));
self.auth_failures = 0;
self.auth_blocked_until = None;
self.pending_password.clear();
let selected = self
.sessions
.get(self.selected)
.cloned()
.ok_or_else(|| Error::Session("no session selected".into()))?;
self.remember_selected_session(&selected);
Ok(Some(LoginSelection {
ticket,
session: selected,
}))
}
AuthCompletion::Login(Err(error)) => {
let audit = AuditEvent::failed(AuditAction::Authentication, &self.username, &error);
self.audit(audit);
if error.requires_password_change() {
self.auth_state = AuthState::Idle;
self.password_change_mode = true;
self.password.clear();
self.auth_failures = 0;
self.auth_blocked_until = None;
self.focus = Focus::Password;
self.status = "PAM requires a new password; enter it and press Enter".into();
self.notify("Password change required by PAM", NoticeLevel::Warning);
} else {
self.auth_state = AuthState::Failed;
self.pending_password.clear();
self.password.clear();
self.auth_failures = self.auth_failures.saturating_add(1);
self.auth_blocked_until =
Some(Instant::now() + auth_backoff(self.auth_failures));
self.status = "Authentication failed".into();
self.notify("Authentication failed", NoticeLevel::Warning);
self.start_faillock_query();
}
Ok(None)
}
AuthCompletion::PasswordChange(Ok(())) => {
self.audit(AuditEvent::succeeded(
AuditAction::PasswordChange,
&self.username,
));
self.password = std::mem::replace(
&mut self.pending_new_password,
Zeroizing::new(String::new()),
);
self.pending_password.clear();
self.password_change_mode = false;
self.auth_state = AuthState::Idle;
self.status = "Password changed; authenticating…".into();
self.notify("Password changed; signing in", NoticeLevel::Info);
self.begin_authentication();
Ok(None)
}
AuthCompletion::PasswordChange(Err(error)) => {
self.audit(AuditEvent::failed(
AuditAction::PasswordChange,
&self.username,
&error,
));
self.auth_state = AuthState::Failed;
self.password_change_mode = false;
self.pending_password.clear();
self.pending_new_password.clear();
self.password.clear();
self.status = "PAM rejected the password change".into();
self.notify("Password change failed", NoticeLevel::Warning);
Ok(None)
}
}
}
pub(super) fn audit(&self, event: AuditEvent<'_>) {
tracing::info!(
target: "latchd.audit",
action = %event.action,
outcome = %event.outcome,
account = %event.account,
pam_reason = ?event.pam_reason,
"security event"
);
}
pub(super) fn begin_authentication(&mut self) {
if self.preview {
self.status = "Preview mode — authentication and session startup are disabled".into();
return;
}
if self.password_change_mode {
self.begin_password_change();
return;
}
if self.username.is_empty() || self.password.is_empty() {
self.status = "Username and password are required".into();
return;
}
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
self.status = format!(
"Authentication paused after failed attempts; try again in {}s",
remaining.as_secs().saturating_add(1)
);
return;
}
self.auth_blocked_until = None;
}
let username = self.username.clone();
let password = self.password.as_bytes().to_vec();
self.pending_password = Zeroizing::new(password.clone());
let service = self.config.pam_service.clone();
let auth = self.auth.clone();
let (sender, receiver) = mpsc::sync_channel(0);
let task = thread::Builder::new()
.name("latchd-auth-attempt".into())
.spawn(move || {
let result = auth.authenticate(username, password, service);
let _ = sender.send(result);
});
match task {
Ok(_) => {
self.audit(AuditEvent::started(
AuditAction::Authentication,
&self.username,
));
self.auth_state = AuthState::Pending(receiver);
self.status = "Authenticating…".into();
self.password.clear();
}
Err(error) => {
self.pending_password.clear();
self.status = format!("Could not start authentication: {error}");
}
}
}
pub(super) fn begin_password_change(&mut self) {
if self.pending_password.is_empty() {
self.password_change_mode = false;
self.status = "Password-change context expired; authenticate again".into();
return;
}
if self.password.is_empty() {
self.status = "A new password is required".into();
return;
}
let username = self.username.clone();
let current_password = self.pending_password.to_vec();
let new_password = self.password.as_bytes().to_vec();
self.pending_new_password = Zeroizing::new(self.password.as_str().to_owned());
let service = self.config.pam_service.clone();
let auth = self.auth.clone();
let (sender, receiver) = mpsc::sync_channel(0);
let task = thread::Builder::new()
.name("latchd-password-change".into())
.spawn(move || {
let result =
auth.change_password(username, current_password, new_password, service);
let _ = sender.send(result);
});
match task {
Ok(_) => {
self.audit(AuditEvent::started(
AuditAction::PasswordChange,
&self.username,
));
self.auth_state = AuthState::PasswordChangePending(receiver);
self.status = "Changing password through PAM…".into();
self.password.clear();
}
Err(error) => {
self.pending_new_password.clear();
self.status = format!("Could not start password change: {error}");
}
}
}
}
pub(super) fn auth_backoff(failures: u32) -> Duration {
let shift = failures.saturating_sub(1).min(4);
Duration::from_secs(1u64 << shift)
}

View File

@@ -0,0 +1,172 @@
use std::sync::mpsc;
use std::thread;
use latchd_core::{accounts, preferences, session, session::SessionDescriptor};
use super::{App, NoticeLevel, unknown_account_status};
impl App {
pub(super) fn apply_preferred_session(&mut self) {
if let Some(index) = self
.preferences
.preferred_index(&self.username, &self.sessions)
{
self.selected = index;
}
}
pub(super) fn remember_selected_session(&mut self, session: &SessionDescriptor) {
self.preferences.remember(&self.username, session);
let Some(path) = self.preference_path.clone() else {
return;
};
if let Err(error) = preferences::save(&path, &self.preferences) {
tracing::debug!(%error, "could not persist last-session preference");
}
}
pub(super) fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else {
return;
};
match receiver.try_recv() {
Ok(Ok(status)) => {
self.faillock_status = Some(status);
self.faillock_receiver = None;
if status.available {
if status.locked_out {
self.notify(
"Account has reached the advisory faillock threshold",
NoticeLevel::Warning,
);
} else if status.failures > 0 {
self.notify(
&format!("{} failed attempt(s) recorded", status.failures),
NoticeLevel::Info,
);
}
}
}
Ok(Err(error)) => {
tracing::debug!(%error, "faillock query failed");
self.faillock_receiver = None;
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None,
}
}
pub(super) fn poll_refresh(&mut self) {
let Some(receiver) = &self.refresh_receiver else {
return;
};
match receiver.try_recv() {
Ok((sessions, accounts, account_statuses)) => {
self.sessions = sessions;
self.accounts = accounts;
self.account_statuses = account_statuses;
self.refresh_receiver = None;
self.refreshing = false;
self.reconcile_selection();
self.status = format!(
"Discovery refreshed: {} session(s), {} account(s)",
self.sessions.len(),
self.accounts.len()
);
self.notify("Account and session discovery refreshed", NoticeLevel::Info);
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.refresh_receiver = None;
self.refreshing = false;
self.status = "Discovery refresh failed".into();
self.notify(
"Could not refresh account and session discovery",
NoticeLevel::Warning,
);
}
}
}
pub(super) fn start_refresh(&mut self) {
if self.refreshing {
self.status = "Discovery refresh is already running".into();
return;
}
let config = self.config.clone();
let (sender, receiver) = mpsc::sync_channel(1);
let task = thread::Builder::new()
.name("latchd-discovery".into())
.spawn(move || {
let sessions = session::discover(&config);
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
(Vec::new(), Vec::new())
};
let _ = sender.send((sessions, accounts, account_statuses));
});
match task {
Ok(_) => {
self.refresh_receiver = Some(receiver);
self.refreshing = true;
self.status = "Refreshing accounts and sessions…".into();
}
Err(error) => {
tracing::debug!(%error, "could not start discovery refresh");
self.status = format!("Could not start discovery refresh: {error}");
}
}
}
pub(super) fn reconcile_selection(&mut self) {
if self.sessions.is_empty() {
self.selected = 0;
} else if self.selected >= self.sessions.len() {
self.selected = self.sessions.len() - 1;
}
if self.accounts.is_empty() {
self.account_index = None;
self.account_statuses.clear();
} else if self.account_statuses.len() != self.accounts.len() {
self.account_statuses
.resize(self.accounts.len(), unknown_account_status());
} else if self
.account_index
.is_some_and(|index| index >= self.accounts.len())
{
self.account_index = Some(0);
}
if let Some(index) = self.account_index {
if let Some(account) = self.accounts.get(index) {
self.username = account.username.clone();
}
}
}
pub(super) fn start_faillock_query(&mut self) {
let username = self.username.clone();
if username.is_empty() {
self.faillock_receiver = None;
self.faillock_status = None;
return;
}
let config = self.config.accounts.clone();
let (sender, receiver) = mpsc::sync_channel(1);
self.faillock_receiver = Some(receiver);
let task = thread::Builder::new()
.name("latchd-faillock".into())
.spawn(move || {
let result = accounts::faillock_status(&username, &config);
let _ = sender.send(result);
});
if let Err(error) = task {
tracing::debug!(%error, "could not start faillock query");
self.faillock_receiver = None;
}
}
}

View File

@@ -0,0 +1,449 @@
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{App, AuthState, FilterState, FilterTarget, Focus, MAX_INPUT, Notice, NoticeLevel};
impl App {
pub(super) fn notify(&mut self, message: &str, level: NoticeLevel) {
if self.config.visual.notifications {
self.notice = Some(Notice {
message: message.to_owned(),
level,
expires: Instant::now() + Duration::from_secs(4),
});
}
}
pub(super) fn begin_filter(&mut self) {
let target = match self.focus {
Focus::Sessions => FilterTarget::Sessions,
Focus::Username => FilterTarget::Accounts,
Focus::Password => return,
};
self.filter = Some(FilterState {
target,
query: String::new(),
});
self.status = match target {
FilterTarget::Sessions => {
"Filter sessions: type text, Enter selects, Esc clears".into()
}
FilterTarget::Accounts => {
"Filter accounts: type text, Enter selects, Esc clears".into()
}
};
}
pub(super) fn filter_key(&mut self, key: KeyEvent) {
let Some(target) = self.filter.as_ref().map(|filter| filter.target) else {
return;
};
match key.code {
KeyCode::Esc => {
self.filter = None;
self.status = "Filter cleared".into();
}
KeyCode::Enter => self.accept_filter(),
KeyCode::Backspace => {
if let Some(filter) = &mut self.filter {
filter.query.pop();
}
self.update_filter_status();
}
KeyCode::Up | KeyCode::Left => match target {
FilterTarget::Sessions => self.previous_session(),
FilterTarget::Accounts => self.previous_account(),
},
KeyCode::Down | KeyCode::Right => match target {
FilterTarget::Sessions => self.next_session(),
FilterTarget::Accounts => self.next_account(),
},
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& self
.filter
.as_ref()
.is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) =>
{
if let Some(filter) = &mut self.filter {
filter.query.push(character);
}
self.update_filter_status();
}
_ => {}
}
}
pub(super) fn update_filter_status(&mut self) {
let Some(filter) = &self.filter else {
return;
};
let count = match filter.target {
FilterTarget::Sessions => self.matching_session_indices().len(),
FilterTarget::Accounts => self.matching_account_indices().len(),
};
self.status = format!(
"Filter {}: {} match(es) — Enter selects, Esc clears",
match filter.target {
FilterTarget::Sessions => "sessions",
FilterTarget::Accounts => "accounts",
},
count
);
}
pub(super) fn accept_filter(&mut self) {
let Some(filter) = self.filter.take() else {
return;
};
match filter.target {
FilterTarget::Sessions => {
let index = self
.sessions
.iter()
.enumerate()
.find(|(_, session)| matches_filter(&session.title, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.selected = index;
self.status = format!("Selected session {}", self.sessions[index].title);
} else {
self.status = "No matching sessions".into();
}
}
FilterTarget::Accounts => {
let index = self
.accounts
.iter()
.enumerate()
.find(|(_, account)| matches_filter(&account.username, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.select_account_index(index);
} else {
self.status = "No matching accounts".into();
}
}
}
}
pub(super) fn matching_session_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Sessions)
.map_or("", |filter| filter.query.as_str());
self.sessions
.iter()
.enumerate()
.filter(|(_, session)| matches_filter(&session.title, query))
.map(|(index, _)| index)
.collect()
}
pub(super) fn matching_account_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Accounts)
.map_or("", |filter| filter.query.as_str());
self.accounts
.iter()
.enumerate()
.filter(|(_, account)| matches_filter(&account.username, query))
.map(|(index, _)| index)
.collect()
}
pub(super) fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
if matches!(
self.auth_state,
AuthState::Pending(_) | AuthState::PasswordChangePending(_)
) {
if key.code == KeyCode::Esc {
self.status = "Authentication is still in progress".into();
}
return;
}
if self.show_help {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
self.show_help = false;
}
return;
}
if self.filter.is_some() {
self.filter_key(key);
return;
}
if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('n') => {
self.next_account();
return;
}
KeyCode::Char('p') => {
self.previous_account();
return;
}
KeyCode::Char('b') => {
let enabled = self.background.toggle_enabled();
self.notify(
if enabled {
"Ambient background enabled"
} else {
"Ambient background disabled"
},
NoticeLevel::Info,
);
return;
}
KeyCode::Char('m') => {
let reduced = self.background.toggle_motion();
self.reduced_motion = reduced;
self.notify(
if reduced {
"Reduced motion enabled"
} else {
"Reduced motion disabled"
},
NoticeLevel::Info,
);
return;
}
KeyCode::Char('r') => {
self.start_refresh();
return;
}
_ => {}
}
}
if let Some(index) = self.power_confirmation {
if key.code == KeyCode::Enter {
self.run_power_action(index);
} else if key.code == KeyCode::Esc {
self.power_confirmation = None;
self.status = "Power action cancelled".into();
}
return;
}
match key.code {
KeyCode::Char('?') => self.show_help = true,
KeyCode::Char('/') => self.begin_filter(),
KeyCode::Esc => {
self.password.clear();
self.status = "Login cancelled".into();
}
KeyCode::Tab | KeyCode::Down => self.next_focus(),
KeyCode::BackTab | KeyCode::Up => self.previous_focus(),
KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(),
KeyCode::Right if self.focus == Focus::Sessions => self.next_session(),
KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(),
KeyCode::F(number) => self.confirm_power_action(number),
KeyCode::Backspace => self.backspace(),
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.insert(character)
}
_ => {}
}
}
pub(super) fn confirm_power_action(&mut self, number: u8) {
if self.preview {
self.status = "Preview mode — power actions are disabled".into();
return;
}
let key = format!("F{number}");
if let Some((index, action)) = self
.config
.power_controls
.entries
.iter()
.enumerate()
.find(|(_, action)| action.key.eq_ignore_ascii_case(&key))
{
self.power_confirmation = Some(index);
self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint);
}
}
pub(super) fn run_power_action(&mut self, index: usize) {
if self.preview {
self.power_confirmation = None;
self.status = "Preview mode — power actions are disabled".into();
return;
}
let Some(action) = self.config.power_controls.entries.get(index).cloned() else {
self.power_confirmation = None;
self.status = "Power action is no longer available".into();
return;
};
if !action.program.is_absolute() {
self.power_confirmation = None;
self.status = "Power action must use an absolute program path".into();
return;
}
match std::process::Command::new(&action.program)
.args(&action.args)
.spawn()
{
Ok(_) => {
self.power_confirmation = None;
self.status = format!("Started {}", action.hint);
}
Err(error) => {
self.power_confirmation = None;
self.status = format!("Could not start {}: {error}", action.hint);
}
}
}
pub(super) fn insert(&mut self, character: char) {
if self.focus == Focus::Username {
self.account_index = None;
}
let target = match self.focus {
Focus::Username => &mut self.username,
Focus::Password => &mut self.password,
Focus::Sessions => return,
};
if target.chars().count() < MAX_INPUT {
target.push(character);
}
}
pub(super) fn backspace(&mut self) {
if self.focus == Focus::Username {
self.account_index = None;
}
let target = match self.focus {
Focus::Username => &mut self.username,
Focus::Password => &mut self.password,
Focus::Sessions => return,
};
target.pop();
}
pub(super) fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Username,
Focus::Username => Focus::Password,
Focus::Password => Focus::Sessions,
};
self.update_hint();
}
pub(super) fn previous_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Password,
Focus::Username => Focus::Sessions,
Focus::Password => Focus::Username,
};
self.update_hint();
}
pub(super) fn update_hint(&mut self) {
self.status = match self.focus {
Focus::Sessions => "Choose a session with ←/→".into(),
Focus::Username => "Enter your username".into(),
Focus::Password if self.password_change_mode => {
"Enter a new password and press Enter".into()
}
Focus::Password => "Enter your password and press Enter".into(),
};
}
pub(super) fn next_session(&mut self) {
let indices = self.matching_session_indices();
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices[(position + 1) % indices.len()];
} else if let Some(&index) = indices.first() {
self.selected = index;
}
}
pub(super) fn previous_session(&mut self) {
let indices = self.matching_session_indices();
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices
.get(position.checked_sub(1).unwrap_or(indices.len() - 1))
.copied()
.unwrap_or(self.selected);
} else if let Some(&index) = indices.first() {
self.selected = index;
}
}
pub(super) fn next_account(&mut self) {
self.select_account(1);
}
pub(super) fn previous_account(&mut self) {
self.select_account(-1);
}
pub(super) fn select_account(&mut self, delta: isize) {
let indices = self.matching_account_indices();
if indices.is_empty() {
self.notify(
"No eligible accounts discovered; enter a username manually",
NoticeLevel::Info,
);
return;
}
let current = self
.account_index
.and_then(|index| indices.iter().position(|&candidate| candidate == index))
.unwrap_or(0) as isize;
let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize];
self.select_account_index(index);
}
pub(super) fn select_account_index(&mut self, index: usize) {
let Some(username) = self
.accounts
.get(index)
.map(|account| account.username.clone())
else {
return;
};
self.account_index = Some(index);
self.username = username;
self.password.clear();
self.pending_password.clear();
self.pending_new_password.clear();
self.password_change_mode = false;
self.auth_state = AuthState::Idle;
self.auth_failures = 0;
self.auth_blocked_until = None;
self.faillock_status = None;
self.status = format!("Selected account {}", self.username);
self.apply_preferred_session();
self.start_faillock_query();
self.notify(
&format!("Selected account {}", self.username),
NoticeLevel::Info,
);
}
}
fn matches_filter(value: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
if query.chars().any(char::is_uppercase) {
value.contains(query)
} else {
value.to_lowercase().contains(query)
}
}

View File

@@ -0,0 +1,398 @@
use std::time::Instant;
use ratatui::{
prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use latchd_core::accounts::AccountState;
use super::{App, AuthState, FilterTarget, Focus, MIN_HEIGHT, MIN_WIDTH, NoticeLevel};
impl App {
pub(super) fn draw(&mut self, frame: &mut ratatui::Frame) {
let area = frame.area();
self.background.draw(frame, area, self.ascii, self.no_color);
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(format!(
"terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}"
))
.alignment(ratatui::prelude::Alignment::Center),
area,
);
return;
}
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(8),
Constraint::Length(2),
Constraint::Length(1),
])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled("latchd", self.style_accent()),
Span::raw(" secure terminal login"),
]))
.alignment(ratatui::prelude::Alignment::Center);
frame.render_widget(title, vertical[0]);
let body = if area.width < 80 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Length(8)])
.split(vertical[1])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(vertical[1])
};
let session_area = body[0];
let form_area = if body.len() == 2 {
body[1]
} else {
vertical[1]
};
let session_indices = self.matching_session_indices();
let session_items: Vec<ListItem> = session_indices
.iter()
.filter_map(|&index| self.sessions.get(index).map(|session| (index, session)))
.map(|(index, session)| {
let marker = if index == self.selected {
if self.ascii { ">" } else { "▶" }
} else {
" "
};
ListItem::new(format!(
"{marker} {}",
truncate_cells(
&session.title,
self.config.environment_switcher.max_display_length
)
))
.style(if index == self.selected {
self.style_selected()
} else {
self.style_muted()
})
})
.collect();
let session_items = if session_items.is_empty() {
vec![ListItem::new(" No matching sessions").style(self.style_muted())]
} else {
session_items
};
let session_title = if self
.filter
.as_ref()
.is_some_and(|filter| filter.target == FilterTarget::Sessions)
{
format!("Session {}/{}", session_indices.len(), self.sessions.len())
} else {
"Session".into()
};
let sessions = List::new(session_items)
.block(self.block(&session_title, self.focus == Focus::Sessions));
frame.render_widget(sessions, session_area);
let fields = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(1),
])
.split(form_area);
let account_title = self.account_title();
let username = Paragraph::new(self.username.as_str())
.block(self.block(&account_title, self.focus == Focus::Username));
let password_marker = if self.ascii { "*" } else { "•" };
let password = Paragraph::new(password_marker.repeat(self.password.chars().count())).block(
self.block(
if self.password_change_mode {
"New password"
} else {
"Password"
},
self.focus == Focus::Password,
),
);
frame.render_widget(username, fields[0]);
frame.render_widget(password, fields[1]);
let status = self.status_line();
frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]);
frame.render_widget(Paragraph::new(self.footer_hint()), vertical[2]);
frame.render_widget(
Paragraph::new("PAM authentication • session output stays off the login screen"),
vertical[3],
);
if self.show_help {
self.draw_help(frame, area);
} else if self.filter.is_some() {
self.draw_filter(frame, area);
}
}
pub(super) fn footer_hint(&self) -> String {
let mut hint = match self.focus {
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help",
Focus::Password if self.password_change_mode => {
"Tab focus Enter change password Ctrl-M motion ? help"
}
Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
}
.to_owned();
if self.no_color {
hint.push_str(" [monochrome]");
} else if self.high_contrast {
hint.push_str(" [high contrast]");
}
if self.reduced_motion {
hint.push_str(" [reduced motion]");
}
hint
}
pub(super) fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) {
let Some(filter) = &self.filter else {
return;
};
let popup = centered_rect(72, 52, area);
frame.render_widget(Clear, popup);
let (label, values): (&str, Vec<String>) = match filter.target {
FilterTarget::Sessions => (
"Sessions",
self.matching_session_indices()
.iter()
.filter_map(|&index| self.sessions.get(index))
.map(|session| session.title.clone())
.collect(),
),
FilterTarget::Accounts => (
"Accounts",
self.matching_account_indices()
.iter()
.filter_map(|&index| self.accounts.get(index))
.map(|account| account.username.clone())
.collect(),
),
};
let mut lines = vec![
Line::from(format!("{label} /{}", filter.query)),
Line::raw(""),
];
if values.is_empty() {
lines.push(Line::from("No matches"));
} else {
for value in values.iter().take(8) {
lines.push(Line::from(format!(
" {}",
truncate_cells(value, popup.width.saturating_sub(5) as usize)
)));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(
"Type to filter • ↑/↓ choose • Enter accept • Esc clear",
));
frame.render_widget(
Paragraph::new(lines)
.block(self.block("Filter", true))
.wrap(Wrap { trim: true }),
popup,
);
}
pub(super) fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
let popup = centered_rect(70, 50, area);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(
"Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / change password\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help",
)
.block(self.block("Help", false))
.wrap(Wrap { trim: true }),
popup,
);
}
pub(super) fn block(&self, title: &str, focused: bool) -> Block<'static> {
let mut block = Block::default()
.borders(Borders::ALL)
.title(title.to_owned())
.border_style(if focused {
self.style_accent()
} else {
self.style_muted()
});
if !self.no_color {
block = block.style(Style::default().bg(ratatui::prelude::Color::Black));
}
block
}
pub(super) fn account_title(&self) -> String {
self.account_index
.map(|index| format!("Username {}/{}", index + 1, self.accounts.len()))
.unwrap_or_else(|| "Username".into())
}
pub(super) fn status_line(&self) -> String {
let mut status = self.status.clone();
if matches!(
self.auth_state,
AuthState::Pending(_) | AuthState::PasswordChangePending(_)
) {
let spinner = if self.config.visual.animations && !self.reduced_motion {
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
.get(self.animation_frame % 10)
.copied()
.unwrap_or("⠋")
} else if self.ascii {
"|"
} else {
"…"
};
status = if matches!(self.auth_state, AuthState::PasswordChangePending(_)) {
format!("{spinner} Changing password through PAM…")
} else {
format!("{spinner} Authenticating…")
};
}
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
status.push_str(&format!(
" • retry cooldown {}s",
remaining.as_secs().saturating_add(1)
));
}
}
if let Some(lockout) = self.faillock_status {
if lockout.locked_out {
status
.push_str(" • advisory faillock threshold reached; PAM remains authoritative");
} else if lockout.failures > 0 {
let remaining = lockout
.remaining
.map_or_else(|| "unknown".into(), |value| value.to_string());
status.push_str(&format!(
" • {} failed attempt(s), {remaining} remaining",
lockout.failures
));
}
}
if let Some(index) = self.account_index {
if let Some(account) = self.account_statuses.get(index) {
let policy = match account.state {
AccountState::Active | AccountState::Unknown => None,
AccountState::Locked => Some("account password is locked"),
AccountState::PasswordExpired => Some("password must be changed"),
AccountState::AccountExpired => Some("account has expired"),
AccountState::AccountInactive => Some("account is inactive"),
AccountState::NoLoginShell => Some("account has no login shell"),
};
if let Some(policy) = policy {
status.push_str(&format!(" • {policy}; PAM remains authoritative"));
}
}
}
if let Some(notice) = &self.notice {
let marker = match notice.level {
NoticeLevel::Info => {
if self.ascii {
"[i]"
} else {
"ℹ"
}
}
NoticeLevel::Warning => {
if self.ascii {
"[!]"
} else {
"⚠"
}
}
};
status.push_str(&format!(" {marker} {}", notice.message));
}
if self.refreshing {
status.push_str(" • refreshing discovery");
}
status
}
pub(super) fn style_accent(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::White)
.bg(ratatui::prelude::Color::Blue)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Cyan)
.add_modifier(ratatui::prelude::Modifier::BOLD)
}
}
pub(super) fn style_selected(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::Black)
.bg(ratatui::prelude::Color::White)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Yellow)
.add_modifier(ratatui::prelude::Modifier::BOLD)
}
}
pub(super) fn style_muted(&self) -> Style {
Style::default().add_modifier(ratatui::prelude::Modifier::DIM)
}
}
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
pub(super) fn truncate_cells(value: &str, width: usize) -> String {
if UnicodeWidthStr::width(value) <= width {
return value.into();
}
let mut output = String::new();
let mut used = 0;
for character in value.chars() {
let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1);
if used + character_width + 1 > width {
break;
}
output.push(character);
used += character_width;
}
output.push('…');
output
}

View File

@@ -0,0 +1,345 @@
use std::sync::mpsc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{Terminal, backend::TestBackend};
use zeroize::Zeroizing;
use super::{auth::auth_backoff, render::truncate_cells, *};
#[test]
fn truncation_respects_terminal_cells() {
assert_eq!(truncate_cells("界界界", 5), "界界…");
}
#[test]
fn renders_the_login_screen_at_the_floor() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let buffer = terminal.backend().buffer();
let rendered = buffer
.content()
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("latchd"));
assert!(rendered.contains("Username"));
assert!(rendered.contains("Password"));
}
#[test]
fn renders_without_layout_errors_at_supported_breakpoints() {
for (width, height) in [(80, 24), (79, 23), (60, 24)] {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let rendered = terminal.backend().buffer().content();
assert!(rendered.iter().any(|cell| cell.symbol() == "l"));
assert!(
!rendered
.iter()
.any(|cell| cell.symbol() == "terminal too small")
);
}
}
#[test]
fn renders_a_truthful_too_small_state() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(59, 15);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let buffer = terminal.backend().buffer();
let rendered = buffer
.content()
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("terminal too small"));
}
#[test]
fn switching_accounts_clears_password_and_auth_state() {
let auth = AuthService::new().expect("PAM worker should start");
let accounts = vec![
Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
},
Identity {
username: "bob".into(),
uid: 1001,
gid: 1001,
home: "/home/bob".into(),
shell: "/bin/sh".into(),
},
];
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
accounts,
Background::load(&Config::default().visual).expect("default background should load"),
);
app.password = Zeroizing::new("secret".into());
app.next_account();
assert_eq!(app.username, "bob");
assert!(app.password.is_empty());
assert!(matches!(app.auth_state, AuthState::Idle));
}
#[test]
fn account_policy_state_is_visible_without_overriding_pam() {
let auth = AuthService::new().expect("PAM worker should start");
let account = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
vec![account],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.account_statuses[0].state = AccountState::Locked;
let status = app.status_line();
assert!(status.contains("password is locked"));
assert!(status.contains("PAM remains authoritative"));
}
#[test]
fn authentication_backoff_is_exponential_and_bounded() {
assert_eq!(auth_backoff(1), Duration::from_secs(1));
assert_eq!(auth_backoff(2), Duration::from_secs(2));
assert_eq!(auth_backoff(5), Duration::from_secs(16));
assert_eq!(auth_backoff(100), Duration::from_secs(16));
}
#[test]
fn pam_password_change_requirement_enters_explicit_new_password_mode() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.pending_password = Zeroizing::new(b"old-secret".to_vec());
app.handle_auth_completion(AuthCompletion::Login(Err(Error::Pam {
operation: "pam_acct_mgmt",
status: 12,
message: "module detail must stay out of the UI".into(),
})))
.expect("policy transition should not fail");
assert!(app.password_change_mode);
assert_eq!(app.focus, Focus::Password);
assert!(app.status.contains("new password"));
}
#[test]
fn password_change_mode_has_a_distinct_prompt() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.password_change_mode = true;
app.focus = Focus::Password;
app.update_hint();
assert!(app.status.contains("new password"));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal");
terminal.draw(|frame| app.draw(frame)).expect("frame");
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("New password"));
}
#[test]
fn last_session_preference_follows_account_and_persists_success() {
let auth = AuthService::new().expect("PAM worker should start");
let sessions = vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
];
let mut app = App::new(
Config::default(),
auth,
true,
sessions,
vec![Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
}],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.preferences.remember("alice", &app.sessions[1]);
app.apply_preferred_session();
assert_eq!(app.selected, 1);
let directory =
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
app.preference_path = Some(directory.join("last-session.toml"));
let selected = app.sessions[1].clone();
app.remember_selected_session(&selected);
let loaded = preferences::load(&directory.join("last-session.toml"))
.expect("selected session should persist");
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway session".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.focus = Focus::Sessions;
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert_eq!(app.matching_session_indices(), vec![1]);
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.selected, 1);
assert!(app.filter.is_none());
}
#[test]
fn refresh_reconciles_selection_without_blocking_state() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.refresh_receiver = Some({
let (sender, receiver) = mpsc::sync_channel(1);
sender
.send((
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Vec::new(),
))
.expect("refresh result should queue");
receiver
});
app.refreshing = true;
app.poll_refresh();
assert!(!app.refreshing);
assert_eq!(app.sessions.len(), 1);
}

View File

@@ -0,0 +1,282 @@
//! Draw terminal wallpaper and a small ambient animation.
use std::{
fs,
path::Path,
time::{Duration, Instant},
};
use image::{ImageReader, RgbImage, imageops::FilterType};
use latchd_core::{
config::VisualConfig,
error::{Error, ResultExt},
};
use ratatui::{
Frame,
prelude::{Buffer, Color, Rect},
};
const MAX_IMAGE_SIDE: u32 = 480;
const MAX_SOURCE_SIDE: u32 = 8_192;
const MAX_SOURCE_PIXELS: u64 = 16_777_216;
const MAX_ANIMATION_WIDTH: usize = 160;
const MAX_ANIMATION_HEIGHT: usize = 64;
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
pub(crate) struct Background {
wallpaper: Option<RgbImage>,
automata: Automata,
enabled: bool,
animations: bool,
reduced_motion: bool,
last_tick: Instant,
}
impl Background {
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
let wallpaper = config
.wallpaper_path
.as_deref()
.map(|path| load_wallpaper(path, config.max_wallpaper_bytes))
.transpose()?;
Ok(Self {
wallpaper,
automata: Automata::default(),
enabled: config.background_enabled,
animations: config.animations,
reduced_motion: config.reduced_motion,
last_tick: Instant::now(),
})
}
pub(crate) fn tick_if_due(&mut self, now: Instant) -> bool {
if !self.enabled
|| !self.animations
|| self.reduced_motion
|| now.duration_since(self.last_tick) < FRAME_INTERVAL
{
return false;
}
self.automata.tick();
self.last_tick = now;
true
}
pub(crate) fn toggle_enabled(&mut self) -> bool {
self.enabled = !self.enabled;
self.enabled
}
pub(crate) fn toggle_motion(&mut self) -> bool {
self.reduced_motion = !self.reduced_motion;
self.reduced_motion
}
pub(crate) fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, ascii: bool, no_color: bool) {
if !self.enabled || area.width < 80 || area.width == 0 || area.height == 0 {
return;
}
self.automata
.resize(area.width as usize, area.height as usize);
let buffer = frame.buffer_mut();
draw_wallpaper(buffer, area, self.wallpaper.as_ref(), no_color);
draw_automata(buffer, area, &self.automata, ascii, no_color);
}
}
fn load_wallpaper(path: &Path, max_bytes: u64) -> Result<RgbImage, Error> {
let metadata =
fs::symlink_metadata(path).io_context(format!("inspect wallpaper {}", path.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"wallpaper must be a root-owned, non-group-writable regular file: {}",
path.display()
)));
}
if metadata.len() > max_bytes {
return Err(Error::Config(format!(
"wallpaper exceeds visual.max_wallpaper_bytes: {}",
path.display()
)));
}
let reader = ImageReader::open(path)
.io_context(format!("open wallpaper {}", path.display()))?
.with_guessed_format()
.io_context(format!("inspect wallpaper format {}", path.display()))?;
let (width, height) = reader.into_dimensions().map_err(|error| {
Error::Config(format!(
"inspect wallpaper dimensions {}: {error}",
path.display()
))
})?;
if width == 0
|| height == 0
|| width > MAX_SOURCE_SIDE
|| height > MAX_SOURCE_SIDE
|| u64::from(width).saturating_mul(u64::from(height)) > MAX_SOURCE_PIXELS
{
return Err(Error::Config(format!(
"wallpaper dimensions exceed {MAX_SOURCE_SIDE}x{MAX_SOURCE_SIDE} or {MAX_SOURCE_PIXELS} pixels"
)));
}
let image = ImageReader::open(path)
.io_context(format!("open wallpaper {}", path.display()))?
.with_guessed_format()
.io_context(format!("inspect wallpaper format {}", path.display()))?
.decode()
.map_err(|error| Error::Config(format!("decode wallpaper {}: {error}", path.display())))?;
if image.width() == 0 || image.height() == 0 {
return Err(Error::Config(
"wallpaper dimensions must be non-zero".into(),
));
}
let image = image.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, FilterType::Triangle);
Ok(image.to_rgb8())
}
fn draw_wallpaper(buffer: &mut Buffer, area: Rect, wallpaper: Option<&RgbImage>, no_color: bool) {
let Some(image) = wallpaper else { return };
if no_color {
return;
}
let width = image.width().max(1);
let height = image.height().max(1);
for y in 0..area.height {
for x in 0..area.width {
let source_x = (u32::from(x) * width / u32::from(area.width)).min(width - 1);
let source_y = (u32::from(y) * height / u32::from(area.height)).min(height - 1);
let pixel = image.get_pixel(source_x, source_y).0;
if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) {
cell.set_symbol(" ");
cell.set_bg(Color::Rgb(pixel[0] / 3, pixel[1] / 3, pixel[2] / 3));
}
}
}
}
fn draw_automata(
buffer: &mut Buffer,
area: Rect,
automata: &Automata,
ascii: bool,
no_color: bool,
) {
for y in 0..area.height as usize {
for x in 0..area.width as usize {
if !automata.alive(x, y) {
continue;
}
let x = x as u16;
let y = y as u16;
if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) {
cell.set_symbol(if ascii { "." } else { "·" });
if !no_color {
cell.set_fg(Color::Rgb(60, 100, 120));
}
}
}
}
}
#[derive(Default)]
struct Automata {
width: usize,
height: usize,
current: Vec<u8>,
next: Vec<u8>,
seed: u64,
}
impl Automata {
fn resize(&mut self, width: usize, height: usize) {
// Keep the state bounded when a terminal reports an extreme size.
let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height {
return;
}
self.width = width;
self.height = height;
self.current = vec![0; width.saturating_mul(height)];
self.next = vec![0; width.saturating_mul(height)];
self.seed = self.seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
for index in 0..self.current.len() {
self.current[index] = u8::from(self.random() % 11 == 0);
}
}
fn tick(&mut self) {
if self.width == 0 || self.height == 0 {
return;
}
for y in 0..self.height {
for x in 0..self.width {
let neighbors = self.neighbors(x, y);
let index = y * self.width + x;
self.next[index] =
u8::from(neighbors == 3 || (neighbors == 2 && self.current[index] == 1));
}
}
std::mem::swap(&mut self.current, &mut self.next);
}
fn neighbors(&self, x: usize, y: usize) -> u8 {
let mut count = 0;
for dy in [self.height.wrapping_sub(1), 0, 1] {
for dx in [self.width.wrapping_sub(1), 0, 1] {
let nx = (x + dx) % self.width;
let ny = (y + dy) % self.height;
if nx == x && ny == y {
continue;
}
count += self.current[ny * self.width + nx];
}
}
count
}
fn alive(&self, x: usize, y: usize) -> bool {
self.current
.get(y.saturating_mul(self.width).saturating_add(x))
.copied()
== Some(1)
}
fn random(&mut self) -> u64 {
let mut value = self.seed;
value ^= value << 13;
value ^= value >> 7;
value ^= value << 17;
self.seed = value;
value
}
}
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automata_is_bounded_and_progresses() {
let mut automata = Automata::default();
automata.resize(8, 4);
assert_eq!(automata.current.len(), 32);
automata.tick();
assert_eq!(automata.current.len(), 32);
}
#[test]
fn automata_caps_untrusted_terminal_dimensions() {
let mut automata = Automata::default();
automata.resize(usize::MAX, usize::MAX);
assert_eq!(automata.width, MAX_ANIMATION_WIDTH);
assert_eq!(automata.height, MAX_ANIMATION_HEIGHT);
assert_eq!(
automata.current.len(),
MAX_ANIMATION_WIDTH * MAX_ANIMATION_HEIGHT
);
}
}

View File

@@ -0,0 +1,6 @@
//! Terminal login screen and session selection.
mod app;
mod background;
pub use app::{LoginSelection, run};

91
docs/architecture.md Normal file
View File

@@ -0,0 +1,91 @@
# Architecture
Latchd is one Rust binary split into three workspace crates; `rust/main.rs` joins them and owns the service loop.
## Crates
| Path | Role |
| --- | --- |
| `crates/latchd-core` | Configuration, account discovery, session discovery, environment construction, UTMPX, preferences, and audit records |
| `crates/latchd-auth` | PAM FFI, the conversation callback, authentication tickets, password changes, and session cleanup |
| `crates/latchd-tui` | Ratatui state, terminal input, rendering, filters, notices, wallpaper, and animation |
| `rust/main.rs` | CLI options, configuration, logging, preflight, and the service loop |
## Startup
The systemd unit starts latchd on the configured VT, then the process follows this path:
1. Parse the CLI options.
2. Read `/etc/latchd/config.toml`.
3. Apply command-line overrides.
4. Validate values, paths, and power actions.
5. Check the shell, desktop programs, X11 tools, and session scripts.
6. Start the auth worker and the TUI.
The service path also checks the terminal and the absence of an existing user session. Preview skips these checks and stops before authentication.
## Authentication worker
The auth worker owns each PAM handle and receives requests through a bounded channel. The TUI receives an opaque ticket after authentication.
For a login, the worker starts a transaction, answers prompts, checks the account, resolves it through NSS, and establishes credentials. The password-change call accepts the current and new passwords when required.
The worker drops the saved transaction when the user cancels a ticket.
## Session handoff
After the user selects a session, the main loop creates a `SessionSpec` with the account, command, environment, terminal, and optional X11 runtime. The worker then:
1. sets the terminal name to `ttyN`;
2. supplies the selected session type, desktop, seat, and VT;
3. opens the login session;
4. copies a fixed list of runtime values into the child environment;
5. starts the user process;
6. closes the session and removes the credentials.
The child starts with a cleared environment, receives locale values plus the account and XDG paths, creates a new session ID, initializes supplementary groups, and switches to the account UID and GID before it runs.
The imported runtime list contains `XDG_SESSION_CLASS`, `XDG_SESSION_TYPE`, `XDG_SESSION_DESKTOP`, `XDG_SEAT`, `XDG_VTNR`, `XDG_RUNTIME_DIR`, `XDG_SESSION_ID`, and `DBUS_SESSION_BUS_ADDRESS`.
## X11, Wayland, and TTY
- X11 — starts the configured server on `vtN`, waits for its readiness signal, writes a private authority file with `xauth`, starts the desktop client, then removes the file and stops the server
- Wayland — starts the selected desktop program with the session values; the compositor and its socket come from the user service or desktop setup
- TTY — attaches the selected shell to the controlling terminal and keeps the terminal as the child standard input, output, and error
The session runner writes a UTMPX user-process record when the platform supports it. It closes that record when the child exits. The runtime can report systemd-logind markers, but it does not call a logind D-Bus API.
## Session discovery
The core crate scans the configured X11 and Wayland directories in sorted file-name order. It keeps a `.desktop` file only when the file is root-owned, regular, and not writable by group or other users.
The parser reads `Name`, `Type`, `Hidden`, `NoDisplay`, `TryExec`, `Exec`, and `DesktopNames`; it rejects field codes and shell operators, so each session remains a program plus an argument list. Administrator scripts use the same ownership checks, and the file name becomes the display title.
The TTY shell appears when the configuration requests it or when no graphical entry survives the scan.
## Account discovery
The account scanner reads NSS records, sorts them by username, and filters by UID, excluded name, login shell, home ownership, and home permissions. Shadow aging data adds the status shown beside the selected account, while the faillock helper adds a short failure count with a 750 millisecond limit.
A missing shadow file or a failed faillock query produces an unknown status. The account remains in the list.
## Preferences and visual state
The last-session store keeps one bounded entry per account, writes through a private temporary file, syncs it, and renames it into place. A missing or invalid store falls back to the first discovered session.
The visual module loads an optional PNG or JPEG, scales it to a terminal-sized RGB image, and draws a small cellular animation over it. The animation state is capped at 160 columns by 64 rows; `Ctrl-B` toggles the background and `Ctrl-M` toggles motion.
## File and process rules
- The service configuration must belong to root and must not be writable by group or other users.
- Desktop files, X11 tools, scripts, shells, and wallpapers must pass ownership and mode checks.
- Session logs and Xauthority files use private permissions.
- Power actions use absolute program paths and require Enter confirmation.
## Changing the code
Add a config field in `crates/latchd-core/src/config.rs`, add its validation, and add one valid and one invalid test. Add a new session source as a `SessionDescriptor`; keep command parsing separate from shell execution.
Keep handle-based authentication work inside `crates/latchd-auth`, add a fault test when the operation crosses the C boundary, and keep password storage zeroizing. Add TUI behavior in the state machine, then add a state test and a render test at the minimum terminal size.
Run `cargo fmt --all -- --check`, `cargo test --locked --workspace --all-targets`, `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, and rustdoc before you send the change.

159
docs/configuration.md Normal file
View File

@@ -0,0 +1,159 @@
# Configuration reference
Latchd uses `/etc/latchd/config.toml` as its service configuration.
The complete example is [`../extra/config.toml`](../extra/config.toml). Copy it to the main file, then change the required values.
## Command-line overrides
These command-line options replace selected values for one run:
| Option | Field changed |
| --- | --- |
| `--tty N` | `tty` |
| `--xsessions PATH` | `x11.sessions_path` |
| `--wlsessions PATH` | `wayland.sessions_path` |
| `--initial-path PATHS` | `initial_path` |
Use these options for a one-time test. Put permanent values in the main file.
## Top-level fields
| Field | Default | Description |
| --- | --- | --- |
| `tty` | `2` | Virtual terminal number. Valid values are `1` through `63`. |
| `main_log_path` | `/var/log/latchd.log` | Main service log. |
| `client_log_path` | `/var/log/latchd.client.log` | Output log for graphical session clients. |
| `pam_service` | `latchd` | Name of the PAM service file in `/etc/pam.d/`. |
| `system_shell` | `/bin/sh` | Shell used by the TTY session and the preflight check. |
| `initial_path` | `/usr/local/sbin:/usr/local/bin:/usr/bin` | Absolute directories used to resolve session programs. |
The path fields in this table must be absolute. `initial_path` must contain only non-empty absolute directories separated by colons.
## `[x11]`
The X server starts only when the selected session has kind `X11`.
| Field | Default | Description |
| --- | --- | --- |
| `display` | `:1` | X display name passed to Xorg and the session. |
| `server_path` | `/usr/bin/X` | X server executable. |
| `xauth_path` | `/usr/bin/xauth` | Program that writes the temporary Xauthority file. |
| `server_timeout_secs` | `60` | Maximum wait for the X server readiness signal. |
| `server_log_path` | `/var/log/latchd.xorg.log` | X server log. |
| `sessions_path` | `/usr/share/xsessions` | Directory with X11 `.desktop` files. |
| `scripts_path` | `/etc/latchd/wms` | Directory with administrator X11 session scripts. |
Set absolute paths for the X server and `xauth`. Latchd passes `display`, `vtN`, and the authority file to Xorg.
## `[wayland]`
| Field | Default | Description |
| --- | --- | --- |
| `sessions_path` | `/usr/share/wayland-sessions` | Directory with Wayland `.desktop` files. |
| `scripts_path` | `/etc/latchd/wayland` | Directory with administrator Wayland session scripts. |
The selected Wayland program starts with these values; latchd does not start a compositor or create a Wayland socket.
## `[environment_switcher]`
| Field | Default | Description |
| --- | --- | --- |
| `include_tty_shell` | `false` | Add the TTY shell when graphical sessions exist. |
| `max_display_length` | `18` | Maximum display width used for session titles. Must be greater than zero. |
| `remember_last_session` | `true` | Remember the last successful session for each account. |
| `last_session_path` | `/var/lib/latchd/last-session.toml` | File for the saved session preference. |
The preference file stores a session kind and title for each account; it holds at most 128 entries, writes a synced temporary file, and renames it into place. The file and its parent directory must belong to the account that runs latchd and must not be writable by group or other users.
Set `remember_last_session = false` to disable reads and writes; latchd ignores the path in that case.
## `[accounts]`
| Field | Default | Description |
| --- | --- | --- |
| `minimum_uid` | `1000` | Lowest UID that account enumeration includes. |
| `excluded_users` | `[]` | Usernames that account enumeration skips. |
| `enumerate` | `true` | Discover accounts through NSS for the account list. |
| `faillock_path` | `/usr/sbin/faillock` | Faillock helper executable. |
| `faillock_config` | `/etc/security/faillock.conf` | File from which Latchd reads the `deny` value. |
| `shadow_path` | `/etc/shadow` | Shadow file used for account status display. |
Account enumeration also requires an absolute login shell and a safe home directory; `/bin/false`, `/usr/sbin/nologin`, and `/sbin/nologin` are excluded.
Latchd reads the shadow file only when it is a root-owned, non-writable regular file. The screen can show these states:
- `Locked` when the password hash starts with `!` or `*`;
- `PasswordExpired` when the last-change day is zero or the maximum age passed;
- `AccountExpired` when the account expiry day passed;
- `AccountInactive` when the inactive period passed;
- `NoLoginShell` when the shell does not permit login;
- `Unknown` when shadow data is not available.
The faillock check only adds status text. The helper runs:
```text
faillock --user USERNAME --legacy-output
```
The query stops after 750 milliseconds. Bad output, a missing helper, or a timeout removes the faillock status from the screen.
## `[visual]`
| Field | Default | Description |
| --- | --- | --- |
| `wallpaper_path` | not set | Optional PNG or JPEG file for the TUI background. |
| `background_enabled` | `true` | Draw the wallpaper and ambient background. |
| `animations` | `true` | Update the ambient cellular animation. |
| `notifications` | `true` | Show short status notices in the TUI. |
| `reduced_motion` | `false` | Start with the ambient animation disabled. |
| `max_wallpaper_bytes` | `8388608` | Maximum wallpaper file size. Valid range is 1 to 67108864 bytes. |
The wallpaper path must be absolute, and the file must be root-owned, regular, and not group-writable or other-writable. Source images are limited to 8192 by 8192 pixels and 16,777,216 pixels total, then scaled for the terminal.
The TUI also caps the animation state at 160 columns by 64 rows. Press `Ctrl-B` to toggle the background. Press `Ctrl-M` to toggle reduced motion.
## `[power_controls]`
Power actions are disabled in preview mode. Each action needs an absolute program path and a function-key label:
```toml
[[power_controls.entries]]
key = "F1"
hint = "Power off"
program = "/usr/bin/systemctl"
args = ["poweroff"]
```
`key` must be `F1` through `F12`, and `hint` must not be empty; `program` starts directly with `args`, without a shell, after the TUI asks for confirmation.
## Desktop entry rules
Only the `[Desktop Entry]` section is read, with these keys:
| Key | Use |
| --- | --- |
| `Name` | Session title. |
| `Type` | Must be `Application` when present. |
| `Hidden` | `true` hides the entry. |
| `NoDisplay` | `true` hides the entry. |
| `TryExec` | Optional executable check. |
| `Exec` | Program and arguments. |
| `DesktopNames` | Values for `XDG_CURRENT_DESKTOP` and `XDG_SESSION_DESKTOP`. |
Plain and quoted arguments are accepted; desktop field codes such as `%u` and `%f`, plus shell syntax, are rejected.
## Validation failures
The parser rejects unknown fields. The validator rejects these common errors:
- a virtual terminal outside `1` through `63`;
- an empty PAM service name;
- an empty path or a relative directory in `initial_path`;
- a relative path in a required absolute-path field;
- a zero session title width;
- a relative last-session path when preferences are enabled;
- a zero or oversized wallpaper limit;
- an empty X display or zero X server timeout;
- an invalid power-action key, label, or program path.
Use `--preview --no-tui` to report these errors without starting the screen.

114
docs/development.md Normal file
View File

@@ -0,0 +1,114 @@
# Development and release
This page lists the commands to build, test, and package latchd. The service configuration is `/etc/latchd/config.toml`.
## Toolchain
Install Rustup with Rust 1.88, then install the native packages used by the PAM link step:
```sh
sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules
```
Keep `Cargo.lock` with dependency changes.
## Build commands
| Command | Result |
| --- | --- |
| `make deps` | Checks Cargo, rustfmt, pkg-config, and the PAM development files |
| `make build` | Builds the optimized binary and copies it to `build/latchd` |
| `make run-preview` | Builds the binary and opens preview mode |
| `make version` | Prints version, commit, epoch, and target metadata |
| `make package` | Creates a deterministic archive and SHA-256 file in `dist/` |
| `make clean` | Removes `build/`, `dist/`, and Cargo build output |
`make build` reads the version from `Cargo.toml`, embeds the current commit, and uses `SOURCE_DATE_EPOCH` for repeatable package metadata. Set `VERSION`, `TARGET`, or `SOURCE_DATE_EPOCH` when you need a different release value.
Build for another Rust target with:
```sh
make build TARGET=x86_64-unknown-linux-gnu
```
The target toolchain and system libraries must already be installed.
## Test commands
Run the fast checks before the host checks:
```sh
make fmt
make test
make lint
make doc
make integration-test
```
`make check` runs the first four commands together. The integration script uses preview mode and checks metadata, configuration validation, preflight, and safe CLI paths.
Run the optional security audit after you install the tool:
```sh
cargo install cargo-audit --locked
make audit
```
The Go prototype has its own opt-in checks:
```sh
make legacy-check
make legacy-build
```
The release build never calls these targets.
## Docker check
`make docker-test` builds `Dockerfile.test` as `latchd-test:local`. The image installs the PAM development files, Xvfb, and `xauth`, then runs formatting, workspace tests, Clippy, rustdoc, a release build, the CLI script, the capability probe, and the disposable PAM fixture.
The container checks build, test, preview, and fixture paths. Run the host checks in a disposable VM when you need a real VT, user session, compositor, or systemd-logind seat.
Remove the local image after a test run if you need the space:
```sh
docker image rm latchd-test:local
```
## PAM fixture
`make pam-fixture-test` must run as root because it writes temporary files below `/etc/pam.d/`. The script uses unique service names, runs the ignored Rust fixture tests, and removes the files in its exit trap.
The fixture checks authentication failure, password-change failure, selected session metadata, and environment values returned by the session module.
## Host checks
Use `make system-e2e` to inspect the binary, PAM library, faillock helper, VT device, Xvfb, Wayland socket, and systemd-logind.
Use the strict form only in a disposable VM:
```sh
make system-e2e-strict
```
The VM needs a test policy, a real VT, a working logind seat, and the X11 or Wayland stack under test. See [End-to-end testing](e2e-testing.md) for the setup and limits.
## Release archive
Run the release gates before packaging:
```sh
make check
make audit
make integration-test
make docker-test
make package
```
`make package` stages the binary, configuration, login policy, systemd unit, README, license, changelog, and build metadata. It sorts the archive, sets a fixed owner and timestamp, then writes the archive checksum and `dist/release.env`.
GitLab CI runs the verification and Docker jobs on branch and merge-request pipelines. A tag that matches the SemVer rule runs the package and release jobs. The package job checks that the tag version matches `Cargo.toml` before it creates the artifact.
## Repository changes
Keep Rust source under the workspace crates, keep host scripts in `scripts/`, and put sample service files in `extra/`. Add tests beside the behavior they cover, update the changelog with the implementation detail and the user-visible result, then run `git diff --check` before you commit.

85
docs/e2e-testing.md Normal file
View File

@@ -0,0 +1,85 @@
# End-to-end checks
Latchd has checks for a development host, the Docker image, and a disposable VM.
## Check matrix
| Command | What it covers | Requirement |
| --- | --- | --- |
| `make check` | formatting, Rust tests, Clippy, and rustdoc | Rust toolchain and PAM development files |
| `make integration-test` | release CLI, configuration validation, preflight, and preview paths | local build |
| `make docker-test` | release build, CLI checks, Xvfb, capability probe, and PAM fixture | Docker |
| `make pam-fixture-test` | denied login, password change, session metadata, and returned environment | root for temporary `/etc/pam.d/` files |
| `make system-e2e` | PAM library, faillock, VT device, X11, Wayland socket, and logind seat | host capabilities |
| `make system-e2e-strict` | all host capability checks | prepared VM |
Run the local set with:
```sh
make check
make integration-test
make docker-test
make system-e2e
```
## Docker
`Dockerfile.test` installs Rust 1.88, the PAM development files, Xvfb, and `xauth`, then runs the release checks inside the image.
Use a disposable VM for VT, compositor, and systemd-logind checks.
The Docker test keeps the image as `latchd-test:local`. Remove it after the run when required:
```sh
docker image rm latchd-test:local
```
The UI screenshots use a Docker PTY and preview mode.
## PAM fixture
Run the fixture as root:
```sh
sudo make pam-fixture-test
```
The script creates temporary service names below `/etc/pam.d/`, builds a small session module, runs the Rust fixture tests, and removes the temporary files after the run.
The fixture checks these handoffs:
- a denied authentication returns an error;
- an expired password enters the password-change path;
- the selected TTY, session type, desktop, seat, and VT reach the session stack;
- session values returned by the module reach the child environment.
## Host capability probe
Use `make system-e2e` to inspect host capabilities.
The probe checks `/dev/tty0`, Xvfb, a Wayland socket, the PAM library, faillock, `/etc/pam.d/latchd`, and systemd-logind.
## Strict VM check
Prepare a disposable VM with:
- the latchd binary and `/etc/latchd/config.toml`;
- a test account and `/etc/pam.d/latchd` configured for it;
- a real virtual terminal and an available `seat0`;
- systemd-logind;
- the X11 or Wayland stack under test;
- the desktop files and executables named by the config.
Build the binary, then run:
```sh
make build
make system-e2e-strict
```
## Reading failures
- `configuration` errors point to a value or ownership check; run `--preview --show-config` to isolate it
- `session` errors point to a desktop entry, executable, home directory, X11 server, or child process; run `--preview --no-tui` and read the client log
- `PAM` errors point to account or service setup; check `/etc/pam.d/latchd` and the service journal
- `terminal` errors mean the process lacks a controlling terminal; use preview mode for non-interactive checks

208
docs/guide.md Normal file
View File

@@ -0,0 +1,208 @@
# Latchd guide
Latchd is a terminal login manager for Linux. It starts on a virtual terminal, gives you a keyboard-first screen, and starts the shell or desktop you select.
The service is small on purpose: systemd gives it the terminal, the login stack checks the account, and latchd handles the screen plus the process handoff. The [configuration reference](configuration.md) lists every field, while the [architecture notes](architecture.md) explain the Rust workspace.
## What starts when
The service unit starts `/usr/bin/latchd` as root on `/dev/tty2`. The default configuration file is `/etc/latchd/config.toml`.
The screen contains these parts:
- A list of desktop and shell sessions.
- An account field.
- A password field.
- A status line.
Use the screen in this order:
- Select an account.
- Select a desktop or shell.
- Enter the password.
- Press Enter.
- Enter a new password if the login stack requests one.
After login, latchd performs these actions:
- Build an environment for the selected account and session.
- Open the session.
- Start the selected child with the account credentials.
- Wait for the child to exit.
- Close the session.
Session-specific setup works as follows:
- X11 uses a temporary authority file.
- Wayland receives the session values that it needs.
- A TTY session starts the account shell.
```text
Start latchd
|
v
Show accounts and sessions
|
v
Select account, session, and password
|
v
Press Enter
|
v
Check the password and account state
|
+------------------------------+
| |
v v
Login accepted New password required
| |
| v
| Show new password field
| |
| v
| Submit the password change
| |
+--------------+---------------+
|
v
Build the session environment
|
v
Open the session
|
v
Start the child
|
v
Wait for exit
|
v
Close the session
```
## Service mode and preview mode
Service mode needs root, a controlling terminal, and a system configuration file with safe ownership. It can authenticate users and start sessions.
Preview mode is for local work; it draws the screen, discovers the configured entries, and reads the user configuration, but it does not authenticate or start a child process.
```sh
make build
./build/latchd --preview --no-log
```
Use the non-interactive checks when you only need a result:
```sh
./build/latchd --preview --show-config --no-log
./build/latchd --preview --no-tui --no-log
```
`--show-config` prints the build information plus the selected terminal, service name, and shell. `--no-tui` validates the configuration and runs session preflight without opening the screen.
## Build from source
Install Rust 1.88 and the PAM development files. On Debian or Ubuntu, use:
```sh
sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules
```
The toolchain file selects Rust 1.88 and the required `rustfmt` and `clippy` components. Build the optimized binary with:
```sh
make build --release
```
The output is at `build/latchd`, `make build` also embeds the version, commit, and build time.
Run `make check` for verification and `make package` to create a tarball with the binary, the system configuration, and the systemd unit. The `make install` target writes the files to the system.
The commands and the container workflow are in [Development and release](development.md).
## Install and start the service
Use a disposable machine first and keep another way to log in while you change the display manager:
```sh
sudo make install
sudo systemctl enable latchd.service
sudo systemctl start latchd.service
```
The install target writes the binary, the system TOML file, the login policy, the session-script directories, and the systemd unit. It does not disable another display manager, so stop the service that already owns the target VT before you start latchd there.
The supplied unit owns `tty2`. If you move it, change `tty` in the TOML file and `TTYPath` in the unit together; otherwise the values passed to the login stack and the terminal will disagree.
Use `sudo systemctl status latchd.service` and `sudo journalctl -u latchd.service -b --no-pager` when the unit fails before the screen appears.
## Configuration files
The service default is `/etc/latchd/config.toml`. Preview mode first checks `$XDG_CONFIG_HOME/latchd/config.toml`, then `~/.config/latchd/config.toml`, and finally the system file; `--config PATH` overrides all of them.
Create the user file with:
```sh
make install-user
```
The target creates the directory with mode `700` and the file with mode `600`, and it leaves an existing file unchanged. Read [Configuration reference](configuration.md) before you add a variable file or change a path used by the service.
The main sections are:
| Section | What it controls |
| --- | --- |
| `[accounts]` | NSS account list, shadow status, and faillock display |
| `[x11]` | X server, authority tool, desktop entries, and scripts |
| `[wayland]` | Wayland desktop entries and scripts |
| `[environment_switcher]` | TTY entry and last-session file |
| `[visual]` | Wallpaper, animation, notices, and motion setting |
| `[power_controls]` | Confirmed programs bound to F1 through F12 |
## Accounts and sessions
Account discovery starts with NSS and then removes entries below `minimum_uid`, entries in `excluded_users`, accounts with a no-login shell, and homes with unsafe ownership or write permission. Set `enumerate = false` when you want a blank list and manual username entry.
The screen can show password expiry, account expiry, lock state, and inactivity from the configured shadow file. It can also call `faillock` for the selected name; the query has a 750 millisecond limit and a failed query only removes the extra status text.
Session discovery reads `.desktop` files from the X11 and Wayland directories, then reads executable files from the matching script directories. Files must belong to root and must not be writable by group or other users. A desktop entry with `Hidden=true`, `NoDisplay=true`, an invalid `Exec`, or an unavailable `TryExec` is skipped.
Latchd stores desktop commands as argument lists, so shell operators and desktop field codes do not run. A script entry uses the file path as its command. The TTY shell appears when `include_tty_shell = true`, and it also appears when no other session exists.
## The session environment
The child starts with an empty environment, then receives terminal locale values, account paths, XDG directories, session type, seat, VT number, and the selected desktop values. The account UID, primary GID, and supplementary groups are set before the child runs.
X11 starts the configured server on the selected VT, waits for its readiness signal, creates a private authority file, and removes that file after the client exits. Wayland starts the selected program but does not start a compositor. TTY mode attaches the shell to the controlling terminal.
The session records a UTMPX user process when the platform supports it. A last-session file can remember the successful choice for each account; the file is bounded and replaced atomically.
## Logs and useful paths
The example file uses these paths:
| Path | Use |
| --- | --- |
| `/var/log/latchd.log` | Main service and audit output |
| `/var/log/latchd.client.log` | Graphical session output |
| `/var/log/latchd.xorg.log` | X server output |
| `/var/lib/latchd/last-session.toml` | Last session per account |
| `/etc/latchd/wms/` | X11 script entries |
| `/etc/latchd/wayland/` | Wayland script entries |
The service creates log files with mode `600` and rejects a main log larger than 64 MiB. Audit lines include the action, result, account, and a short reason; passwords and command arguments stay out of the log.
## When a session does not start
Check the config and the paths first:
```sh
./build/latchd --preview --show-config --no-log
./build/latchd --preview --no-tui --no-log
make system-e2e
```
Then check the desktop file, the executable named by `Exec` or `TryExec`, the account home permissions, and the service log. For X11, check the X server and `xauth` paths; for Wayland, check the compositor and its user service outside latchd.
Use [End-to-end testing](e2e-testing.md) when the host is a container or when you need a disposable VM for the terminal and display stack.

BIN
docs/images/latchd-help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

75
docs/ui.md Normal file
View File

@@ -0,0 +1,75 @@
# User interface
The screen is designed for a virtual terminal. It keeps the session list beside the account and password fields, and adapts down to 60 columns by 16 rows.
![Latchd login screen](images/latchd-preview.png)
Preview mode screenshot of the login layout.
## Fields and status
- session list — choose an X11, Wayland, administrator-script, or TTY entry
- username — type an account name or select one discovered through NSS
- password — enter the password; the screen clears it after an attempt
- status line — shows focus prompts, account state, faillock counts, notices, and authentication progress
- footer — shows the keys that apply to the current screen state
When the selected account needs a new password, the password field changes to the new-password prompt. The old password stays in the pending authentication request until the password-change call finishes, then both values are cleared.
## Keys
| Key | Action |
| --- | --- |
| `Tab` / `Shift-Tab` | Move between session, username, and password |
| `Up` / `Down` | Move focus, or move through the filtered list |
| `Left` / `Right` | Change the session when the session list has focus |
| `/` | Start a session or account filter |
| `Ctrl-R` | Refresh accounts and sessions in the background |
| `Ctrl-N` / `Ctrl-P` | Select the next or previous account |
| `Ctrl-B` | Toggle the wallpaper and ambient animation |
| `Ctrl-M` | Toggle reduced motion |
| `Enter` | Start login or submit a new password |
| `Esc` | Clear the password, close a popup, or cancel power confirmation |
| `?` | Open the key reference |
| `Ctrl-C` | Restore the terminal and exit |
| `F1`–`F12` | Confirm the matching configured power action |
Lowercase filters ignore case. A filter with an uppercase character uses case-sensitive matching. Press `Esc` while filtering to leave the filter field.
## Visual settings
The `[visual]` section controls the background:
```toml
[visual]
background_enabled = true
animations = true
notifications = true
reduced_motion = false
max_wallpaper_bytes = 8388608
# wallpaper_path = "/etc/latchd/wallpaper.png"
```
`wallpaper_path` accepts a root-owned PNG or JPEG file. The loader checks the file size and source dimensions, then scales the image before it draws it in the terminal. `animations = false` stops frame updates at startup, while `reduced_motion = true` does the same and can also be changed with `Ctrl-M`.
Set `LATCHD_ASCII=1` for terminals without reliable Unicode cells, `NO_COLOR=1` for plain output, and `LATCHD_HIGH_CONTRAST=1` for a stronger focus style.
## Help panel
Press `?` to open the key reference:
![Latchd help panel](images/latchd-help.png)
Press `Esc` or `?` again to close it. The help panel does not change the selected account or session.
## Capture the screen
Build the Docker test image, then start a PTY with a fixed size:
```sh
docker build -f Dockerfile.test -t latchd-test:local .
tmux new-session -d -x 120 -y 36 \
'docker run --rm -it latchd-test:local /workspace/target/release/latchd --preview --no-log'
```
The capture shows the preview binary and terminal renderer, preview mode is simply for testing and doesn't perform any real login.

View File

@@ -1,13 +1,45 @@
# latchd config — see src/config/config.go for all defaults.
# only include values you want to override.
tty = 2
main_log_path = "/var/log/latchd.log"
client_log_path = "/var/log/latchd.client.log"
pam_service = "latchd"
system_shell = "/bin/sh"
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
# tty = 2
# pam_service = "latchd"
# system_shell = "/bin/sh"
# focus_behaviour = "default"
[x11]
display = ":1"
server_path = "/usr/bin/X"
xauth_path = "/usr/bin/xauth"
server_timeout_secs = 60
server_log_path = "/var/log/latchd.xorg.log"
sessions_path = "/usr/share/xsessions"
scripts_path = "/etc/latchd/wms"
[wayland]
sessions_path = "/usr/share/wayland-sessions"
scripts_path = "/etc/latchd/wayland"
[environment_switcher]
include_tty_shell = false
max_display_length = 18
remember_last_session = true
last_session_path = "/var/lib/latchd/last-session.toml"
# Start power actions directly with an executable and its arguments.
[power_controls]
# [[power_controls.entries]]
# hint = "Hibernate"
# key = "F3"
# cmd = "systemctl hibernate"
entries = []
[accounts]
minimum_uid = 1000
excluded_users = []
enumerate = true
faillock_path = "/usr/sbin/faillock"
faillock_config = "/etc/security/faillock.conf"
shadow_path = "/etc/shadow"
[visual]
background_enabled = true
animations = true
notifications = true
reduced_motion = false
max_wallpaper_bytes = 8388608
# wallpaper_path = "/etc/latchd/wallpaper.png"

View File

@@ -2,3 +2,4 @@
auth include login
account include login
password include login
session include login

View File

@@ -9,6 +9,10 @@ StandardInput=tty
TTYPath=/dev/tty2
TTYReset=yes
TTYVHangup=yes
KillMode=control-group
Restart=on-failure
RestartSec=2s
UMask=0077
Type=idle
[Install]

View File

@@ -1,41 +0,0 @@
#!/bin/sh
# sourced by latchd before starting an X11 session.
# runs the user's xprofile, xinitrc fragments, and xsession.
_xexec="$1"
shift
# source shell profiles
if [ -f /etc/profile ]; then
. /etc/profile
fi
shell="${SHELL##*/}"
case "$shell" in
zsh) [ -f "$HOME/.zprofile" ] && . "$HOME/.zprofile" ;;
bash) [ -f "$HOME/.bash_profile" ] && . "$HOME/.bash_profile" ;;
*) [ -f "$HOME/.profile" ] && . "$HOME/.profile" ;;
esac
# source x profiles
[ -f /etc/xprofile ] && . /etc/xprofile
[ -f "$HOME/.xprofile" ] && . "$HOME/.xprofile"
# run xinitrc.d scripts
for _f in /etc/X11/xinit/xinitrc.d/*; do
[ -x "$_f" ] && . "$_f"
done
# run Xsession.d scripts
for _f in /etc/X11/Xsession.d/*; do
[ -x "$_f" ] && . "$_f"
done
# load xresources
if [ -f "$HOME/.Xresources" ]; then
xrdb -merge "$HOME/.Xresources"
fi
# run xsession
[ -f "$HOME/.xsession" ] && . "$HOME/.xsession"
exec $_xexec "$@"

9
legacy/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Legacy Go implementation
The original Go implementation remains in `legacy/go/` as a reference and
compatibility target while the production binary is built from the Rust
workspace. It is not part of the default build, install, service, release, or
Docker test paths. Run `make legacy-build` only when comparing behavior during
the migration; the target is intentionally labelled as quarantined.
New security fixes and features belong in the Rust crates under `crates/`.

View File

@@ -1,7 +1,3 @@
// auth — faillock integration.
//
// Reads /etc/security/faillock.conf for deny count, executes
// faillock --user to get tally, supports --reset via admin bypass.
package auth
import (

View File

@@ -1,9 +1,4 @@
// auth — PAM authentication with CGO bindings.
//
// Uses runtime.LockOSThread to prevent goroutine migration during
// PAM transactions, as PAM modules rely on thread-local storage.
//
// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs.
// Keep each transaction on one OS thread because modules can use thread-local state.
package auth
/*
@@ -12,11 +7,11 @@ package auth
#include <stdlib.h>
#include <stdint.h>
// Match the //export-generated signature.
// Match the callback signature that cgo generates for //export.
extern int latchd_pam_conv(int, struct pam_message **,
struct pam_response **, void *);
// Store a uintptr_t handle in C heap so the Go GC never sees a Go pointer.
// Store the numeric handle in C memory; never pass a Go pointer to the module.
static void *storeHandle(uintptr_t h) {
uintptr_t *p = malloc(sizeof(uintptr_t));
if (p) *p = h;
@@ -51,7 +46,7 @@ type AuthError struct {
}
const (
ErrPAMService = iota
ErrPAMService = iota
ErrAccountValidation
ErrHomeDirInvalid
ErrShellInvalid

View File

@@ -1,6 +1,3 @@
// auth — UTMPX user accounting (glibc only).
//
// Mirrors vendor/src/auth/utmpx.rs.
package auth
/*

View File

@@ -0,0 +1,7 @@
// Let the C compiler convert values for either native or compatibility ut_tv fields.
#include <stdint.h>
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {
ut->ut_tv.tv_sec = sec;
ut->ut_tv.tv_usec = usec;
}

View File

@@ -125,23 +125,23 @@ type WaylandConfig struct {
}
type Config struct {
TTY uint8 `toml:"tty"`
MainLogPath string `toml:"main_log_path"`
ClientLogPath string `toml:"client_log_path"`
CachePath string `toml:"cache_path"`
DoLog bool `toml:"do_log"`
PAMService string `toml:"pam_service"`
SystemShell string `toml:"system_shell"`
InitialPath string `toml:"initial_path"`
ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"`
FocusBehaviour FocusBehaviour `toml:"focus_behaviour"`
Background BackgroundConfig `toml:"background"`
PowerControls PowerControlConfig `toml:"power_controls"`
Switcher SwitcherConfig `toml:"environment_switcher"`
Username UsernameFieldConfig `toml:"username_field"`
Password PasswordFieldConfig `toml:"password_field"`
X11 X11Config `toml:"x11"`
Wayland WaylandConfig `toml:"wayland"`
TTY uint8 `toml:"tty"`
MainLogPath string `toml:"main_log_path"`
ClientLogPath string `toml:"client_log_path"`
CachePath string `toml:"cache_path"`
DoLog bool `toml:"do_log"`
PAMService string `toml:"pam_service"`
SystemShell string `toml:"system_shell"`
InitialPath string `toml:"initial_path"`
ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"`
FocusBehaviour FocusBehaviour `toml:"focus_behaviour"`
Background BackgroundConfig `toml:"background"`
PowerControls PowerControlConfig `toml:"power_controls"`
Switcher SwitcherConfig `toml:"environment_switcher"`
Username UsernameFieldConfig `toml:"username_field"`
Password PasswordFieldConfig `toml:"password_field"`
X11 X11Config `toml:"x11"`
Wayland WaylandConfig `toml:"wayland"`
}
func Default() Config {
@@ -168,7 +168,6 @@ func LoadVariables(filename string) (map[string]string, error) {
if err != nil {
return nil, fmt.Errorf("read %s: %w", filename, err)
}
// Parse a flat TOML table of string values.
vars := make(map[string]string)
var table map[string]any
if err := toml.Unmarshal(raw, &table); err != nil {
@@ -180,8 +179,6 @@ func LoadVariables(filename string) (map[string]string, error) {
return vars, nil
}
// ── variable substitution ───────────────────────────────────────────────
func resolveVars(in string, vars map[string]string) string {
for k, v := range vars {
in = strings.ReplaceAll(in, "$"+k, v)
@@ -189,8 +186,6 @@ func resolveVars(in string, vars map[string]string) string {
return in
}
// ── embedded default config ─────────────────────────────────────────────
const defaultTOML = `
tty = 2
main_log_path = "/var/log/latchd.log"

View File

@@ -4,10 +4,8 @@ go 1.22.0
require (
github.com/BurntSushi/toml v1.4.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.6
github.com/charmbracelet/lipgloss v0.11.0
github.com/mattn/go-runewidth v0.0.15
)
require (
@@ -15,9 +13,12 @@ require (
github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/charmbracelet/x/input v0.1.0 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
@@ -25,4 +26,5 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

View File

@@ -2,7 +2,6 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
@@ -13,12 +12,16 @@ github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -32,9 +35,13 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@@ -18,8 +18,6 @@ import (
"latchd/tui/bg"
)
// ── pixelated user avatar ───────────────────────────────────────────────
var avatarArt = []string{
` ▄▄▄▄▄ `,
` ▄███████▄ `,
@@ -31,8 +29,6 @@ var avatarArt = []string{
` ██ ██ `,
}
// ── messages ─────────────────────────────────────────────────────────────
type tickMsg time.Time
type authResult struct {
@@ -57,8 +53,6 @@ const (
modeAdminPass
)
// ── result (sent back to main on successful login) ──────────────────────
type Result struct {
Username string
Password string
@@ -67,8 +61,6 @@ type Result struct {
Preview bool
}
// ── model ───────────────────────────────────────────────────────────────
type Model struct {
cfg config.Config
preview bool
@@ -137,8 +129,6 @@ func (m *Model) Init() tea.Cmd {
return tea.Batch(tick(), tea.EnterAltScreen)
}
// ── update ──────────────────────────────────────────────────────────────
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -222,8 +212,6 @@ func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
return m, nil
}
// ── keys ────────────────────────────────────────────────────────────────
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg.String()
@@ -392,8 +380,6 @@ func (m *Model) selectedEnv() session.PostLoginEnv {
return m.envs[m.selIdx]
}
// ── commands ────────────────────────────────────────────────────────────
func (m *Model) loginCmd() tea.Cmd {
if m.preview {
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
@@ -422,14 +408,10 @@ func (m *Model) adminBypassCmd() tea.Cmd {
}
}
// ── queries ────────────────────────────────────────────────────────────
func (m *Model) Result() *Result { return m.result }
func (m *Model) Quitting() bool { return m.quitting }
func (m *Model) Result() *Result { return m.result }
func (m *Model) Quitting() bool { return m.quitting }
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
// ── view ───────────────────────────────────────────────────────────────
var (
accent = lipgloss.Color("208") // orange
subtle = lipgloss.Color("8") // grey
@@ -438,8 +420,8 @@ var (
infoCol = lipgloss.Color("3") // yellow
greenCol = lipgloss.Color("2") // green
cardBg = lipgloss.Color("0") // black card background
cardPad = 2
cardBg = lipgloss.Color("0") // black card background
cardPad = 2
avatarStyle = lipgloss.NewStyle().Foreground(accent)
@@ -459,11 +441,11 @@ var (
fieldText = lipgloss.NewStyle().Foreground(bright)
fieldFocus = lipgloss.NewStyle().Foreground(accent)
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
swNeighbour = lipgloss.NewStyle().Foreground(subtle)
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
swDot = lipgloss.NewStyle().Foreground(subtle)
swDotActive = lipgloss.NewStyle().Foreground(accent)
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
swNeighbour = lipgloss.NewStyle().Foreground(subtle)
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
swDot = lipgloss.NewStyle().Foreground(subtle)
swDotActive = lipgloss.NewStyle().Foreground(accent)
powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true)
@@ -527,7 +509,6 @@ func overlayCenter(w, h int, content string) string {
func (m *Model) renderLayout() string {
clock := m.renderClock()
// top bar: power hints on left, clock on right
var topParts []string
topParts = append(topParts, m.renderPowerHints())
if clock != "" {
@@ -535,24 +516,19 @@ func (m *Model) renderLayout() string {
}
top := strings.Join(topParts, " ")
// avatar + form side by side
avatar := m.renderAvatar()
form := m.renderForm()
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
// DE switcher with specifier label
sw := m.renderSwitcher()
// caps lock
caps := m.renderCapsLock()
// admin unlock
var admin string
if m.mode == modeAdminUser || m.mode == modeAdminPass {
admin = m.renderAdminForm()
}
// status
st := m.renderStatus()
var parts []string
@@ -655,7 +631,6 @@ func (m *Model) renderSwitcher() string {
var parts []string
// session specifier (like "x11", "wayland")
spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
if cfg.ShowMovers && m.selIdx > 0 {
@@ -679,7 +654,6 @@ func (m *Model) renderSwitcher() string {
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
// Dot indicators
var dots []string
for i := 0; i < len(m.envs); i++ {
if i == m.selIdx {
@@ -747,8 +721,6 @@ func (m *Model) renderStatus() string {
return st.Align(lipgloss.Center).Width(70).Render(m.status)
}
// ── caps lock detection ────────────────────────────────────────────────
const kdgkbled = 0x4B64
func capsLockOn() bool {
@@ -762,8 +734,6 @@ func capsLockOn() bool {
return leds&0x04 != 0
}
// ── field editing ──────────────────────────────────────────────────────
func handleField(k string, buf *string, cur, scroll *int) {
switch {
case k == "backspace" || k == "ctrl+h":
@@ -846,8 +816,6 @@ func abs(n int) int {
return n
}
// ── tick ────────────────────────────────────────────────────────────────
func tick() tea.Cmd {
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
return tickMsg(t)

View File

@@ -1,7 +1,3 @@
// latchd — TUI display/login manager for GNU/Linux and BSD.
//
// Supports TTY, X11, and Wayland sessions with PAM authentication.
// Go rewrite of Lemurs (Rust source under vendor/).
package main
import (
@@ -193,8 +189,6 @@ func setupLog(path string) {
log.SetFlags(log.LstdFlags)
}
// ── chvt ────────────────────────────────────────────────────────────────
const vtA = 0x5606
const vtW = 0x5607
const kbt = 0x4B33

View File

@@ -1,6 +1,3 @@
// session — environment variable setup for latchd sessions.
//
// Mirrors vendor/src/post_login/env_variables.rs.
package session
import (

View File

@@ -1,6 +1,3 @@
// session — process execution and session spawning.
//
// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs.
package session
import (
@@ -16,12 +13,12 @@ import (
)
type PostLoginEnv struct {
Kind string // "x11", "wayland", "tty"
Title string
XinitrcPath string
ScriptPath string
DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated)
SessionDesktop string // for XDG_SESSION_DESKTOP
Kind string // "x11", "wayland", "tty"
Title string
XinitrcPath string
ScriptPath string
DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated)
SessionDesktop string // for XDG_SESSION_DESKTOP
}
type SpawnedEnv struct {

View File

@@ -1,7 +1,3 @@
// session — environment scanning from xsessions, wayland-sessions,
// and custom script directories. Includes desktop entry parser.
//
// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry.
package session
import (

View File

@@ -0,0 +1 @@
package session

View File

@@ -1,6 +1,3 @@
// session — X11 server setup.
//
// Mirrors vendor/src/post_login/x.rs:setup_x.
package session
import (
@@ -47,7 +44,7 @@ func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, er
dd = "0" + dd
}
// Ignore SIGUSR1 so Xorg sends it when ready.
// Xorg sends SIGUSR1 only when the parent ignores the signal during startup.
signal.Ignore(syscall.SIGUSR1)
cmd := exec.Command(cfg.SystemShell, "-c",

View File

@@ -1,10 +1,5 @@
// bg — cellular automata background for the TUI.
//
// Conway's Game of Life rendered at 30 Hz using Unicode half-block
// characters (▀) to double the effective vertical resolution.
// The grid is double-buffered for zero-allocation ticking, and
// render output is built in a pre-allocated strings.Builder so the
// Pack two cell rows into each Unicode half-block row; two buffers keep frame
// updates allocation-free.
package bg
import (
@@ -12,7 +7,6 @@ import (
"strings"
)
// Bright orange (accent) for alive cells, dark grey for dead.
const (
fgAlive = "\x1b[38;2;255;127;0m"
fgDead = "\x1b[38;2;30;30;30m"
@@ -23,10 +17,10 @@ const (
)
type Automata struct {
w, h int
a, b []uint8
useA bool
buf strings.Builder
w, h int
a, b []uint8
useA bool
buf strings.Builder
}
func NewAutomata(w, h int) *Automata {
@@ -92,8 +86,6 @@ func (a *Automata) count(g []uint8, x, y, w, h int) uint8 {
return c
}
// Frame returns the current generation as a single string suitable for
// writing to the alternate screen.
func (a *Automata) Frame() string {
a.buf.Reset()
src := a.a

4
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "1.88.0"
components = ["rustfmt", "clippy"]
profile = "minimal"

286
rust/main.rs Normal file
View File

@@ -0,0 +1,286 @@
//! Terminal login manager entry point and service loop.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
use std::{
fs::{self, OpenOptions},
io,
os::unix::fs::{MetadataExt, OpenOptionsExt},
path::{Path, PathBuf},
};
use clap::Parser;
use latchd_auth::AuthService;
use latchd_core::{
audit::{AuditAction, AuditEvent},
config::{self, Config},
error::{Error, ResultExt},
session,
};
use latchd_tui as tui;
use tracing::{error, info};
const BUILD_VERSION: &str = env!("LATCHD_VERSION");
const BUILD_COMMIT: &str = env!("LATCHD_COMMIT");
const BUILD_TIME: &str = env!("LATCHD_BUILD_TIME");
/// Command-line options for the login service.
#[derive(Debug, Parser)]
#[command(
name = "latchd",
version = BUILD_VERSION,
about = "A secure terminal login manager"
)]
struct Cli {
/// Configuration TOML path.
#[arg(short, long)]
config: Option<PathBuf>,
/// TOML file with values for text substitution.
#[arg(short = 'v', long)]
variables: Option<PathBuf>,
/// Override the configured virtual terminal.
#[arg(long)]
tty: Option<u8>,
/// Override the X11 desktop-entry directory.
#[arg(long)]
xsessions: Option<PathBuf>,
/// Override the Wayland desktop-entry directory.
#[arg(long)]
wlsessions: Option<PathBuf>,
/// Override the session PATH.
#[arg(long)]
initial_path: Option<String>,
/// Run the screen without authentication or session startup.
#[arg(long)]
preview: bool,
/// Validate the configuration and sessions without opening the screen.
#[arg(long, alias = "preflight")]
no_tui: bool,
/// Skip the main log file.
#[arg(long)]
no_log: bool,
/// Print build information and selected configuration values.
#[arg(long)]
show_config: bool,
/// Print embedded build metadata.
#[arg(long)]
build_info: bool,
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
run().map_err(|error| color_eyre::eyre::eyre!(error.user_report()))
}
fn run() -> Result<(), Error> {
let cli = Cli::parse();
if cli.build_info {
println!("version={BUILD_VERSION}\ncommit={BUILD_COMMIT}\nepoch={BUILD_TIME}");
return Ok(());
}
let config_path = resolve_config_path(cli.config.as_deref(), cli.preview)?;
if let Some(path) = cli.variables.as_deref() {
if !path.exists() {
return Err(Error::Config(format!(
"variables file does not exist: {}",
path.display()
)));
}
}
let variables_path = cli.variables.as_deref();
if !cli.preview {
if let Some(path) = config_path.as_deref() {
config::validate_source(path, "configuration")?;
}
if let Some(path) = variables_path {
config::validate_source(path, "variables")?;
}
}
let mut config = Config::default();
if config_path.is_some() || variables_path.is_some() {
config = config::load(config_path.as_deref(), variables_path)?;
}
if let Some(tty) = cli.tty {
if tty == 0 {
return Err(Error::Config("--tty must be greater than zero".into()));
}
config.tty = tty;
}
if let Some(path) = cli.xsessions {
config.x11.sessions_path = path;
}
if let Some(path) = cli.wlsessions {
config.wayland.sessions_path = path;
}
if let Some(path) = cli.initial_path {
if path.is_empty() {
return Err(Error::Config("--initial-path must not be empty".into()));
}
config.initial_path = path;
}
config::validate(&config)?;
if cli.show_config {
println!(
"version = {BUILD_VERSION}\ncommit = {BUILD_COMMIT}\nbuilt = {BUILD_TIME}\ntty = {}\npam_service = {:?}\nsystem_shell = {:?}",
config.tty, config.pam_service, config.system_shell
);
return Ok(());
}
if cli.no_tui {
session::preflight(&config)?;
println!("configuration valid");
return Ok(());
}
if !cli.preview {
if std::env::var_os("XDG_SESSION_TYPE").is_some() {
return Err(Error::Config(
"already inside a session; use --preview".into(),
));
}
if !running_as_root() {
return Err(Error::Config("latchd must run as root".into()));
}
validate_terminal()?;
}
if !cli.no_log && !cli.preview {
setup_logging(&config.main_log_path)?;
} else {
tracing_subscriber::fmt()
.with_writer(io::sink)
.with_ansi(false)
.init();
}
let auth = AuthService::new()?;
while let Some(selection) = tui::run(config.clone(), auth.clone(), cli.preview)? {
let identity = selection.ticket.identity().clone();
let spec = match session::build_spec(&identity, &config, &selection.session) {
Ok(spec) => spec,
Err(error) => {
let _ = auth.cancel(selection.ticket);
error!(%error, "cannot build session specification");
continue;
}
};
info!(user = %identity.username, session = %selection.session.title, "starting session");
let audit = AuditEvent::started(AuditAction::Session, &identity.username);
tracing::info!(
target: "latchd.audit",
action = %audit.action,
outcome = %audit.outcome,
account = %audit.account,
pam_reason = ?audit.pam_reason,
session = %selection.session.title,
"security event"
);
if let Err(error) = auth.run_session(selection.ticket, spec) {
let audit = AuditEvent::failed(AuditAction::Session, &identity.username, &error);
tracing::info!(
target: "latchd.audit",
action = %audit.action,
outcome = %audit.outcome,
account = %audit.account,
pam_reason = ?audit.pam_reason,
session = %selection.session.title,
"security event"
);
error!("session ended with an error; see the structured audit event");
} else {
let audit = AuditEvent::succeeded(AuditAction::Session, &identity.username);
tracing::info!(
target: "latchd.audit",
action = %audit.action,
outcome = %audit.outcome,
account = %audit.account,
pam_reason = ?audit.pam_reason,
session = %selection.session.title,
"security event"
);
}
}
Ok(())
}
fn resolve_config_path(explicit: Option<&Path>, preview: bool) -> Result<Option<PathBuf>, Error> {
if let Some(path) = explicit {
if path.exists() {
return Ok(Some(path.to_owned()));
}
return Err(Error::Config(format!(
"configuration does not exist: {}",
path.display()
)));
}
let system_path = Path::new(config::SYSTEM_CONFIG_PATH);
if !preview {
return Ok(system_path.exists().then(|| system_path.to_owned()));
}
if let Some(path) = config::user_config_path().filter(|path| path.exists()) {
return Ok(Some(path));
}
Ok(system_path.exists().then(|| system_path.to_owned()))
}
fn running_as_root() -> bool {
// SAFETY: `geteuid` has no pointer arguments or memory preconditions.
unsafe { libc::geteuid() == 0 }
}
fn setup_logging(path: &std::path::Path) -> Result<(), Error> {
const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024;
let parent = path
.parent()
.ok_or_else(|| Error::Config(format!("log path has no parent: {}", path.display())))?;
fs::create_dir_all(parent).io_context(format!("create log directory {}", parent.display()))?;
let parent_metadata = fs::symlink_metadata(parent)
.io_context(format!("inspect log directory {}", parent.display()))?;
if !parent_metadata.is_dir()
|| parent_metadata.uid() != 0
|| parent_metadata.mode() & 0o022 != 0
{
return Err(Error::Config(format!(
"log directory must be root-owned and not group-writable: {}",
parent.display()
)));
}
let file = OpenOptions::new()
.create(true)
.append(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600)
.open(path)
.io_context(format!("open log {}", path.display()))?;
if file
.metadata()
.io_context(format!("inspect log {}", path.display()))?
.len()
> MAX_LOG_BYTES
{
return Err(Error::Config(format!(
"log exceeds {MAX_LOG_BYTES} bytes: {}",
path.display()
)));
}
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))
.io_context(format!("secure log {}", path.display()))?;
tracing_subscriber::fmt()
.with_writer(file)
.with_ansi(false)
.init();
Ok(())
}
fn validate_terminal() -> Result<(), Error> {
let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1;
let stdout_is_tty = unsafe { libc::isatty(libc::STDOUT_FILENO) } == 1;
if !stdin_is_tty || !stdout_is_tty {
return Err(Error::Terminal(
"latchd requires a controlling terminal (use --preview for non-interactive checks)"
.into(),
));
}
Ok(())
}

111
scripts/test-cli.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/bin/sh
# Use --preview; this test needs no credentials, VT, or 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"
user_config_home="$workdir/config-home"
mkdir -p "$user_config_home/latchd"
sed 's/^tty = 2$/tty = 7/' "$config" >"$user_config_home/latchd/config.toml"
user_shown="$workdir/user-show-config"
HOME="$workdir/home" XDG_CONFIG_HOME="$user_config_home" \
"$binary" --preview --show-config --no-log >"$user_shown"
assert_contains "tty = 7" "$user_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)"

5
scripts/test-docker.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
image="latchd-test:local"
docker build --pull -f Dockerfile.test -t "$image" .

93
scripts/test-pam-fixture.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "pam-fixture: SKIP (root is required to install a temporary PAM policy)"
exit 0
fi
service="latchd-fixture-$$"
session_service="latchd-session-fixture-$$"
policy="/etc/pam.d/$service"
session_policy="/etc/pam.d/$session_service"
checker="/tmp/latchd-pam-session-check-$$"
module_source="/tmp/latchd-pam-session-env-$$.c"
module="/tmp/latchd-pam-session-env-$$.so"
cleanup() {
rm -f "$policy" "$session_policy" "$checker" "$module_source" "$module"
}
trap cleanup EXIT HUP INT TERM
umask 077
cat >"$policy" <<'PAM'
auth required pam_deny.so
account required pam_permit.so
password required pam_deny.so
session required pam_permit.so
PAM
cat >"$checker" <<'SH'
#!/bin/sh
set -eu
[ "${PAM_TTY:-}" = "tty2" ]
[ "${XDG_SESSION_CLASS:-}" = "user" ]
[ "${XDG_SESSION_TYPE:-}" = "wayland" ]
[ "${XDG_SESSION_DESKTOP:-}" = "niri" ]
[ "${XDG_SEAT:-}" = "seat0" ]
[ "${XDG_VTNR:-}" = "2" ]
SH
chmod 700 "$checker"
cat >"$module_source" <<'C'
#include <security/pam_appl.h>
#include <security/pam_modules.h>
PAM_EXTERN int pam_sm_open_session(
pam_handle_t *pamh,
int flags,
int argc,
const char **argv
) {
int result;
(void)flags;
(void)argc;
(void)argv;
result = pam_putenv(pamh, "XDG_SESSION_ID=fixture-session");
if (result != PAM_SUCCESS) return result;
result = pam_putenv(pamh, "XDG_RUNTIME_DIR=/tmp/latchd-runtime-fixture");
if (result != PAM_SUCCESS) return result;
return pam_putenv(
pamh,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/latchd-runtime-fixture/bus"
);
}
PAM_EXTERN int pam_sm_close_session(
pam_handle_t *pamh,
int flags,
int argc,
const char **argv
) {
(void)pamh;
(void)flags;
(void)argc;
(void)argv;
return PAM_SUCCESS;
}
C
${CC:-cc} -shared -fPIC "$module_source" -o "$module" -lpam
chmod 700 "$module"
cat >"$session_policy" <<PAM
auth required pam_permit.so
account required pam_permit.so
password required pam_deny.so
session required $module
session required pam_exec.so $checker
PAM
LATCHD_PAM_FIXTURE_SERVICE="$service" \
LATCHD_PAM_SESSION_FIXTURE_SERVICE="$session_service" \
cargo test --locked -p latchd-auth --test pam_fixture -- --ignored

142
scripts/test-system-e2e.sh Executable file
View File

@@ -0,0 +1,142 @@
#!/bin/sh
# Report unavailable capabilities as SKIP; strict mode treats each skip as an error.
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
# Check the PAM runtime only; authentication still needs a test account and policy.
if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then
pass "PAM runtime library is available"
else
skip "PAM runtime library is unavailable"
fi
if [ -n "$binary" ] && need_command nm && nm -D "$binary" 2>/dev/null | grep -q 'pam_chauthtok'; then
pass "PAM password-change entry point is linked"
else
skip "PAM password-change entry point could not be inspected"
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 pseudo-terminal cannot prove that the host can allocate a VT.
if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then
pass "Linux virtual terminal device is available"
else
skip "Linux virtual terminal is unavailable (container or non-console host)"
fi
# Xvfb checks X11 without using the host display.
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 compositor must own the Wayland socket; installed client libraries are not enough.
wayland_socket=
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
case "$WAYLAND_DISPLAY" in
/*) 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
# Check loginctl only when systemd-logind is available.
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

View File

@@ -1,9 +0,0 @@
// Helper: assign seconds + microseconds to utmpx's ut_tv field.
// The C compiler handles the type conversion, so this works whether
// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct
// (__uint32_t tv_sec / __int32_t tv_usec).
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {
ut->ut_tv.tv_sec = sec;
ut->ut_tv.tv_usec = usec;
}

View File

@@ -1,4 +0,0 @@
package session
// Wayland session spawning is handled in exec.go/spawnWayland.
// This file exists for future Wayland-specific compositor logic.