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.
This commit is contained in:
2026-09-25 21:33:46 +05:30
parent 1723ab53b8
commit faf5b27328
49 changed files with 6795 additions and 747 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

9
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/target /target
/dist/
go_project_build_documentation.md go_project_build_documentation.md
documentation.md documentation.md
@@ -6,4 +7,10 @@ session.md
build/* build/*
examples/* examples/*
things_todo.md things_todo.md
ly/* ly/*
tui-design-skill/
FEATURE_CHECKLIST.md
IMPLEMENTATION_CHECKLIST.md
audit_code.md
audit_rust_code.md

127
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,127 @@
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 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:
- 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

View File

@@ -1,5 +1,21 @@
# Changelog # Changelog
## 0.3.0 — Rust workspace release preparation
- Rust is the supported login manager; the Go/cgo prototype is quarantined at
`legacy/go/` and excluded from normal builds and release archives.
- Added workspace crates for PAM authentication, session/config/account
management, and the TUI, including account switching and advisory faillock.
- Added optional terminal-cell wallpapers, cellular automata effects, and
accessible motion/background controls.
- Added GitLab CI verification, version-checked tag releases, optimized
single-version Cargo builds, deterministic installation archives, SHA-256
checksums, and embedded build metadata.
- Live VT, logind, PAM-policy, X11, and Wayland validation remains a deployment
requirement on an isolated VM or target host.
## Legacy Go history (not the Rust release)
## 0.1.0 — Unreleased ## 0.1.0 — Unreleased
first public release first public release

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.3.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"

20
Dockerfile.test Normal file
View File

@@ -0,0 +1,20 @@
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
RUN cargo fmt --all -- --check \
&& cargo test --locked --all-targets \
&& cargo clippy --locked --all-targets --all-features -- -D warnings \
&& RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps

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.

272
Makefile
View File

@@ -1,146 +1,168 @@
BINARY := latchd SHELL := /usr/bin/env bash
SRC_DIR := src .SHELLFLAGS := -eu -o pipefail -c
CMD_DIR := $(SRC_DIR) .DEFAULT_GOAL := help
BUILD_DIR := build .DELETE_ON_ERROR:
PREFIX ?= /usr PROJECT := latchd
BINDIR := $(DESTDIR)$(PREFIX)/bin BINARY := latchd
CONFDIR := $(DESTDIR)/etc/$(BINARY) PREFIX ?= /usr
SYSTEMD := $(DESTDIR)/usr/lib/systemd/system DESTDIR ?=
BINDIR := $(DESTDIR)$(PREFIX)/bin
CONFDIR := $(DESTDIR)/etc/$(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 VERSION ?= $(shell sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf unknown)
BUILDDATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S') 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 \ TARGET_ARGS := $(if $(TARGET),--target $(TARGET),)
-X main.Version=$(VERSION) \ RELEASE_DIR := $(CARGO_TARGET_DIR)/$(if $(TARGET),$(TARGET)/)release
-X main.Commit=$(COMMIT) \ PACKAGE_NAME := $(PROJECT)-$(VERSION)-$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))
-X main.BuildTime=$(BUILDDATE) 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 COLOR ?= 1
GOFLAGS := -trimpath -ldflags="$(LDFLAGS)" 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 .PHONY: all deps build release-build test check fmt lint doc audit docker-test \
BOLD := \033[1m package release release-check version install uninstall clean run-preview \
DIM := \033[2m legacy-build legacy-check enable update help
RED := \033[31m
GREEN := \033[32m
YELLOW := \033[33m
BLUE := \033[34m
CYAN := \033[36m
WHITE := \033[37m
RST := \033[0m
## ─── targets ──────────────────────────────────────────────── all: release-build ## Build the optimized release binary
.PHONY: all deps build install uninstall clean test lint fmt vet \ version: ## Print the package, commit, and reproducibility metadata
run-preview release enable update help @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 deps: ## Check native dependencies needed for builds and tests
@printf " $(BLUE)%-10s$(RST) checking dependencies...\n" "DEPS" @command -v $(CARGO) >/dev/null || { echo 'cargo (Rust 1.88+) is required'; exit 1; }
@if ! dpkg -s libpam0g-dev >/dev/null 2>&1; then \ @command -v rustfmt >/dev/null || { echo 'rustfmt is required'; exit 1; }
printf " $(YELLOW)%-10s$(RST) sudo apt install -y libpam0g-dev\n" ""; \ @command -v pkg-config >/dev/null || { echo 'pkg-config is required'; exit 1; }
sudo apt install -y libpam0g-dev; \ @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
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 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; \
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 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)'
build: $(BUILD_DIR)/$(BINARY) ## compile latchd release: check audit docker-test package ## Run all gates and produce release artifacts
$(BUILD_DIR)/$(BINARY): $(shell find $(SRC_DIR) -name '*.go') install: build ## Install the binary, config, PAM policy, and systemd unit
@printf " $(CYAN)%-10s$(RST) %s\n" "GO" "$@" @install -Dm755 '$(BUILD_DIR)/$(BINARY)' '$(BINDIR)/$(BINARY)'
@mkdir -p $(BUILD_DIR) @install -Dm644 extra/config.toml '$(CONFDIR)/config.toml'
@cd $(SRC_DIR) && $(GO) build $(GOFLAGS) -o ../$@ . @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: build ## install to $(PREFIX) uninstall: ## Remove files installed by make install
@printf " $(GREEN)%-10s$(RST) %s\n" "INSTALL" "$(BINDIR)/$(BINARY)" @rm -f '$(BINDIR)/$(BINARY)' '$(DESTDIR)/etc/pam.d/$(PROJECT)' '$(SYSTEMD_DIR)/$(PROJECT).service'
@install -Dm755 $(BUILD_DIR)/$(BINARY) $(BINDIR)/$(BINARY) @printf '$(YELLOW)configuration at %s was preserved; remove it manually if no longer needed$(RESET)\n' '$(CONFDIR)'
@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"
uninstall: ## remove installed files clean: ## Remove generated build, release, and Cargo artifacts
@printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BINDIR)/$(BINARY)" @rm -rf '$(BUILD_DIR)' '$(DIST_DIR)'
@rm -f $(BINDIR)/$(BINARY) @$(CARGO) clean
@rm -rf $(CONFDIR)
@rm -f $(DESTDIR)/etc/pam.d/$(BINARY)
@rm -f $(SYSTEMD)/$(BINARY).service
clean: ## remove build artifacts run-preview: build ## Run the safe non-authenticating preview UI
@printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BUILD_DIR)" @'$(BUILD_DIR)/$(BINARY)' --preview --no-log
@rm -rf $(BUILD_DIR)
test: ## run tests legacy-build: ## Build the quarantined Go reference only when explicitly requested
@printf " $(YELLOW)%-10s$(RST) running tests...\n" "TEST" @printf '$(YELLOW)legacy Go is reference-only and excluded from release builds$(RESET)\n'
@cd $(SRC_DIR) && $(GO) test ./... @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' .
lint: ## run linter legacy-check: ## Format-check and test the quarantined Go reference
@printf " $(YELLOW)%-10s$(RST) linting...\n" "LINT" @command -v $(GO) >/dev/null || { echo 'go is required for legacy-check'; exit 1; }
@golangci-lint run $(SRC_DIR)/... 2>/dev/null || printf " $(DIM)%-10s$(RST) golangci-lint not found; skipping\n" "" @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 ./...
fmt: ## format source enable: ## Enable the systemd service after host validation
@printf " $(BLUE)%-10s$(RST) formatting...\n" "FMT" @sudo systemctl enable $(PROJECT).service
@cd $(SRC_DIR) && $(GO) fmt ./...
vet: ## run go vet update: ## Rebuild and reinstall the current checkout without changing git state
@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"
@sudo $(MAKE) install @sudo $(MAKE) install
@printf " $(GREEN)%-10s$(RST) updated — run 'sudo make enable' to switch DMs\n" "UPDATE"
help: ## print this help help: ## Show available targets
@printf " $(BOLD)latchd$(RST) — tui display manager\n" @printf '$(CYAN)$(PROJECT)$(RESET) $(VERSION) — production build targets\n\n'
@printf " $(DIM)version $(VERSION) commit $(COMMIT)$(RST)\n" @awk -F ':|##' '/^[a-zA-Z0-9_-]+:.*##/ { printf " %-18s %s\n", $$1, $$NF }' '$(MAKEFILE_LIST)' | sort
@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"

286
README.md
View File

@@ -1,190 +1,148 @@
# latchd # latchd
a display manager that runs in your terminal. lets you pick a desktop environment and log in — supports tty, x11, and wayland. `latchd` is a Linux/PAM terminal login manager written in Rust. It presents a keyboard-first Ratatui login screen on a virtual terminal, authenticates through PAM, and starts an isolated TTY, X11, or Wayland session with an explicit per-user environment.
## what problem does this solve The Rust binary is the supported implementation. The original Go/cgo tree under `legacy/go/` is retained as a quarantined comparison artifact and is not built by the default targets.
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. ## Security model
## how it works - The service requires root for real sessions and refuses to start from an existing desktop session.
- PAM work is serialized on one dedicated worker thread. PAM handles never cross that thread boundary; authentication tickets are opaque RAII-owned transactions.
- Password buffers are zeroized and are never placed in argv, logs, or the rendered UI.
- Session environments are rebuilt from an allowlist for every login. Shell commands are argv vectors; no configured command is passed through a shell.
- Desktop files and session scripts must be root-owned, regular, non-group-writable files. Account shells such as `false` and `nologin` are rejected.
- Xauthority cookies are generated in a `0600` file owned by the account, supplied to `xauth` through stdin, and removed by an RAII cleanup guard.
- Main, graphical-session, and Xorg logs are private (`0600`) and opened without following symlinks.
- Power actions are an explicit absolute-path allowlist and require confirmation. They are disabled in `--preview` mode.
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. The implementation intentionally does not provide an administrator unlock shortcut: account lockout and authorization remain the responsibility of the configured PAM policy.
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. ## Build and test
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. Requirements: Rust 1.88 or newer, a Linux PAM development package, and a C toolchain. Debian/Ubuntu packages are `libpam0g-dev`, `libpam-modules`, and `build-essential`.
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.
## what you get
- 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 ```bash
make deps # install system dependencies make deps
sudo make install # build, install binary + configs + systemd unit make build
sudo systemctl enable latchd make test # unit tests and rustdoc warnings
make lint # Clippy with warnings denied
make audit # RustSec dependency advisory scan
make docker-test # the same checks in a pinned Debian container
make package # deterministic install archive plus SHA-256
make release # all local gates, Docker, and package output
``` ```
to test without rebooting: `make build` installs the release binary at `build/latchd`. The reproducible test image is defined by [Dockerfile.test](Dockerfile.test) and does not require `--privileged`.
## Releases
The supported release pipeline is GitLab CI in [`.gitlab-ci.yml`](.gitlab-ci.yml).
Every branch and merge request runs formatting, tests, Clippy, rustdoc, a
RustSec dependency audit, and the Docker suite. Docker-in-Docker requires a trusted privileged GitLab Runner with
the runner client-certificate volume configured; untrusted fork pipelines
should not be assigned to that runner. A SemVer tag
such as `v0.3.0` additionally builds a deterministic
Linux archive containing the binary, systemd unit, PAM policy, configuration,
license, changelog, build metadata, and a SHA-256 checksum, then publishes it
as a GitLab Release. The tag version must match the workspace version. CI
creates the GitLab Release only after both Rust and Docker verification pass.
Release builds use `SOURCE_DATE_EPOCH`, commit metadata, stripped symbols,
`opt-level = 3`, thin LTO, one codegen unit, no incremental state, and
`panic = "abort"`. Set `TARGET` or `DESTDIR` when invoking `make package` for a
controlled package build; `VERSION` must match the workspace version. The
legacy Go tree is physically isolated under `legacy/go/` and is never included
in release artifacts.
## Installation
```bash ```bash
make run-preview sudo make install
sudo systemctl enable latchd.service
``` ```
### arch Installation places the binary at `/usr/bin/latchd`, the configuration at `/etc/latchd/config.toml`, the PAM policy at `/etc/pam.d/latchd`, and the systemd unit at `/usr/lib/systemd/system/latchd.service`. The unit owns `/dev/tty2`; adjust `TTYPath`, `After`, and the `tty` configuration together if another VT is required.
```bash Keep the previous package/binary available while validating a deployment. `--preview` is a safe UI exercise that does not authenticate, create a user session, or execute power actions. `--no-tui` (also `--preflight`) validates configuration and exits without entering the login screen.
pacman -S latchd
systemctl enable latchd ## Command line
```text
latchd [OPTIONS]
-c, --config <PATH> Configuration TOML (default /etc/latchd/config.toml)
-v, --variables <PATH> Optional TOML variable file
--tty <N> Override the virtual terminal number
--xsessions <DIR> Override the X11 desktop-entry directory
--wlsessions <DIR> Override the Wayland desktop-entry directory
--initial-path <PATH> Override the session PATH
--preview Draw the UI without authentication or session startup
--no-tui, --preflight Validate configuration and exit
--no-log Disable the main log
--show-config Print the key validated settings and exit
--build-info Print version, commit, and build epoch and exit
-V, --version Print the version
``` ```
## Command Line An explicit configuration or variables file used by a real (non-preview) service must be root-owned, regular, and not group/other writable. The default configuration is optional: when `/etc/latchd/config.toml` is absent, built-in defaults are used.
```bash ## Configuration
latchd [options]
-c, --config <file> config path (default: /etc/latchd/config.toml) See [extra/config.toml](extra/config.toml) for the complete schema. Values may be substituted from a TOML variables file with `$NAME` syntax. Power entries contain an executable path and an argv array, never a shell command string.
-v, --variables <file> variables path
--tty <n> override the tty number to run on Session discovery reads root-owned `.desktop` files from the configured X11 and Wayland directories and executable root-owned scripts from `/etc/latchd/wms` and `/etc/latchd/wayland`. Unsupported desktop-entry field codes are rejected rather than interpreted by a shell. A TTY shell is offered when no graphical sessions are available, or when `include_tty_shell = true`.
--preview run inside an existing session for testing
--no-log disable all logging When enabled, the account chooser discovers eligible NSS users (by default,
--show-config print the parsed config and exit UID 1000 and above with a real home directory and login shell). `Ctrl-N` and
-V, --version print version and exit `Ctrl-P` cycle accounts, clear the password, and start a bounded advisory
--xsessions <dir> override xsessions path `faillock` query. The displayed lockout state never overrides PAM: a PAM
--wlsessions <dir> override wayland-sessions path conversation remains the only authorization decision. Set
--initial-path <path> override initial PATH value `accounts.enumerate = false` when listing local accounts is not appropriate.
The `[visual]` section is TUI-only. It can load a root-owned PNG/JPEG as a
downsampled terminal-cell wallpaper, run a low-rate cellular-automata ambient
layer, and show short-lived notifications. `reduced_motion = true` disables
motion while preserving the static layout; `NO_COLOR=1` disables the image
layer and all color effects.
## TUI controls
The screen is designed for 80×24 and remains usable down to 60 columns. Below the supported minimum it reports the required size without drawing a partial frame.
| Key | Action |
| --- | --- |
| Tab / Shift-Tab | Move focus |
| Up / Down | Move focus |
| Left / Right | Change session when the chooser is focused |
| Ctrl-N / Ctrl-P | Select the next / previous discovered account |
| Ctrl-B | Toggle the TUI-only wallpaper/automata background |
| Ctrl-M | Toggle reduced motion |
| Enter | Authenticate from the password field |
| Esc | Clear the password / cancel power confirmation |
| `?` | Show keyboard help |
| Ctrl-C | Restore the terminal and quit |
| F1–F12 | Confirm and run a configured power action |
`NO_COLOR=1` requests a monochrome semantic style, and `LATCHD_ASCII=1` selects ASCII session markers for terminals without reliable Unicode support.
## Project layout
```text
crates/
├── latchd-core/ validated config, accounts, sessions, and UTMPX accounting
├── latchd-auth/ PAM FFI, conversation callback, and transaction worker
└── latchd-tui/ Ratatui state machine, wallpaper, automata, and effects
rust/main.rs CLI, privilege checks, logging, and orchestration
extra/ example config, PAM policy, systemd unit, session directories
scripts/ Docker test entry point
legacy/
└── go/ non-production Go reference (explicit `make legacy-build` only)
``` ```
## Adding Sessions ## Platform support
Drop executable scripts into the right folder and they show up in the switcher. The first release targets Linux with PAM and systemd. BSD and non-PAM platforms are not claimed or tested. Real PAM/VT/X11 integration should be exercised on an isolated test host; the ordinary Docker suite covers compilation, unit tests, rendering tests, formatting, Clippy, and rustdoc without privileged container access.
**x11** — put your xinitrc scripts in `/etc/latchd/wms/`: ## License
```bash Apache-2.0; see [LICENSE](LICENSE).
# /etc/latchd/wms/bspwm
#!/bin/sh
sxhkd &
exec bspwm
```
```bash
chmod +x /etc/latchd/wms/bspwm
```
**wayland** — same thing in `/etc/latchd/wayland/`:
```bash
# /etc/latchd/wayland/sway
#!/bin/sh
exec sway
```
latchd also reads freedesktop `.desktop` files from `/usr/share/xsessions` and `/usr/share/wayland-sessions` automatically.
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.

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,548 @@
//! PAM authentication with one dedicated worker thread.
//!
//! PAM handles are intentionally never moved out of the worker. The public
//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM
//! transaction from the wrong thread or forget to close it.
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_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;
#[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_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_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;
}
/// An opaque authenticated PAM transaction.
pub struct AuthTicket {
id: u64,
identity: Identity,
owner: Option<AuthService>,
}
impl AuthTicket {
/// Returns the account identity associated with this ticket.
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>>,
},
Run {
id: u64,
spec: SessionSpec,
response: std::sync::mpsc::SyncSender<Result<(), Error>>,
},
Cancel {
id: u64,
},
}
/// Serializes PAM operations on a dedicated worker thread.
#[derive(Clone)]
pub struct AuthService {
commands: SyncSender<Command>,
}
impl AuthService {
/// Starts the PAM worker.
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 })
}
/// Starts one authentication transaction without blocking the UI thread.
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)
}
/// Runs the authenticated session and closes its PAM 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()))?
}
/// Cancels a ticket that will not be used for a session.
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>>,
}
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::Run { id, spec, response } => {
let result = match sessions.remove(&id) {
Some(mut session) => {
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);
Err(error)
} else {
session.opened = true;
let run = match session.identity.as_ref() {
Some(identity) => session::run_user_session(identity, &spec),
None => Err(Error::Worker("ticket has no account identity".into())),
};
let close = session.close(if run.is_ok() {
PAM_SUCCESS
} else {
PAM_SYSTEM_ERR
});
match (run, close) {
(Err(error), _) => Err(error),
(Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
}
None => Err(Error::Worker("unknown authentication ticket".into())),
};
let _ = response.send(result);
}
Command::Cancel { id } => {
sessions.remove(&id);
}
}
}
}
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,
});
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,
)
};
// Reconstitute the allocation on every path after pam_start. PAM copies the
// conversation structure, but does not own 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;
// Authentication is complete. Do not retain the password for the session.
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 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 = &*(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 => 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()),
});
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));
}
}
}

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,97 @@
//! Linux UTMPX accounting with idempotent RAII cleanup.
use std::{io, mem, os::raw::c_char, ptr};
use crate::error::Error;
/// A live user-process record in UTMPX.
pub struct UtmpxGuard {
record: libc::utmpx,
closed: bool,
}
impl UtmpxGuard {
/// Adds a user-process record for a child and returns its 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,
})
}
/// Writes the corresponding dead-process record. Calling it twice is safe.
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,237 @@
//! NSS account discovery and advisory faillock status.
use std::{
ffi::CStr,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use crate::{
config::AccountsConfig,
error::{Error, ResultExt},
};
/// An account that can be offered by the login screen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
/// Canonical username.
pub username: String,
/// Numeric user ID.
pub uid: u32,
/// Primary group ID.
pub gid: u32,
/// Home directory.
pub home: PathBuf,
/// Login shell.
pub shell: PathBuf,
}
/// A non-authoritative account lockout summary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus {
/// Whether the faillock helper returned a usable result.
pub available: bool,
/// Number of observed failed attempts.
pub failures: u32,
/// Configured failure threshold, when discoverable.
pub max_tries: Option<u32>,
/// Remaining attempts before the advisory threshold.
pub remaining: Option<u32>,
/// Whether the advisory threshold has been reached.
pub locked_out: bool,
}
/// Enumerates interactive accounts using NSS and deterministic ordering.
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
}
/// Runs the configured helper and parses its stable 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 failures = parse_failure_count(&output.stdout);
let max_tries = read_deny(&config.faillock_config);
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
Ok(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 {
String::from_utf8_lossy(bytes)
.lines()
.filter_map(|line| line.split_whitespace().next())
.find_map(|value| value.parse::<u32>().ok())
.unwrap_or_default()
}
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_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_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,380 @@
use std::{
collections::HashMap,
fs,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::error::{Error, ResultExt};
/// Validated application configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Virtual terminal number used by the service.
pub tty: u8,
/// Main log file.
pub main_log_path: PathBuf,
/// Per-session log file.
pub client_log_path: PathBuf,
/// PAM service name.
pub pam_service: String,
/// Default shell used only when the account has no shell value.
pub system_shell: PathBuf,
/// Session PATH.
pub initial_path: String,
/// X11 settings.
pub x11: X11Config,
/// Wayland settings.
pub wayland: WaylandConfig,
/// Environment switcher settings.
pub environment_switcher: SwitcherConfig,
/// Power actions shown by the login screen.
pub power_controls: PowerControls,
/// Account enumeration and advisory lockout settings.
pub accounts: AccountsConfig,
/// TUI-only background, animation, and notification settings.
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(),
}
}
}
/// Account discovery and faillock settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AccountsConfig {
/// Minimum UID shown by automatic account discovery.
pub minimum_uid: u32,
/// Usernames excluded from the chooser.
pub excluded_users: Vec<String>,
/// Whether the chooser lists NSS accounts.
pub enumerate: bool,
/// Absolute faillock helper path.
pub faillock_path: PathBuf,
/// faillock configuration used to read the deny threshold.
pub faillock_config: 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"),
}
}
}
/// Visual effects used only by the terminal login screen.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VisualConfig {
/// Optional root-owned image used as a low-contrast terminal wallpaper.
pub wallpaper_path: Option<PathBuf>,
/// Enable the wallpaper and procedural background layer.
pub background_enabled: bool,
/// Advance animated background and spinner frames.
pub animations: bool,
/// Show transient in-TUI notifications for user-visible events.
pub notifications: bool,
/// Disable motion while retaining static visual styling.
pub reduced_motion: bool,
/// Maximum wallpaper file size accepted before decoding.
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,
}
}
}
/// X11 session configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct X11Config {
/// X display name.
pub display: String,
/// X server executable.
pub server_path: PathBuf,
/// Xauthority executable.
pub xauth_path: PathBuf,
/// X server startup timeout.
pub server_timeout_secs: u16,
/// X server log.
pub server_log_path: PathBuf,
/// Desktop-entry directory.
pub sessions_path: PathBuf,
/// Custom script directory.
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(),
}
}
}
/// Wayland session configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WaylandConfig {
/// Desktop-entry directory.
pub sessions_path: PathBuf,
/// Custom script directory.
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(),
}
}
}
/// Session switcher settings.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SwitcherConfig {
/// Include the fallback TTY shell in the chooser.
pub include_tty_shell: bool,
/// Maximum visible title width.
pub max_display_length: usize,
}
impl Default for SwitcherConfig {
fn default() -> Self {
Self {
include_tty_shell: false,
max_display_length: 18,
}
}
}
/// Power actions available to the login screen.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PowerControls {
/// Built-in or administrator-defined actions.
pub entries: Vec<PowerAction>,
}
/// A power action represented as an executable argv vector.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PowerAction {
/// Displayed key, such as `F1`.
pub key: String,
/// Displayed label.
pub hint: String,
/// Executable path.
pub program: PathBuf,
/// Arguments passed without shell interpretation.
#[serde(default)]
pub args: Vec<String>,
}
/// Loads a configuration file over the built-in defaults.
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])
})
}
/// Validates values that must be safe before starting the login screen.
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(),
));
}
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),
] {
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(())
}
/// Rejects a privileged configuration source that can be replaced by another user.
pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
let metadata =
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"{label} must be a root-owned, non-group-writable regular file: {}",
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,68 @@
use std::io;
use thiserror::Error;
/// The application-wide error type.
#[derive(Debug, Error)]
pub enum Error {
/// A command-line or configuration value was invalid.
#[error("configuration: {0}")]
Config(String),
/// A filesystem operation failed.
#[error("{context}: {source}")]
Io {
/// The operation being performed.
context: String,
/// The underlying error.
#[source]
source: io::Error,
},
/// TOML could not be decoded.
#[error("parse {path}: {source}")]
Toml {
/// The file being parsed.
path: String,
/// The parser error.
#[source]
source: toml::de::Error,
},
/// PAM rejected or could not complete an operation.
#[error("PAM {operation} failed with status {status}: {message}")]
Pam {
/// The PAM operation.
operation: &'static str,
/// The numeric PAM status.
status: i32,
/// The human-readable PAM message.
message: String,
},
/// A session process failed.
#[error("session: {0}")]
Session(String),
/// The terminal could not be initialized or restored.
#[error("terminal: {0}")]
Terminal(String),
/// An authentication worker request was cancelled or disconnected.
#[error("authentication worker: {0}")]
Worker(String),
}
/// Adds a stable operation label to an I/O error.
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
Error::Io {
context: context.into(),
source,
}
}
/// Converts a failed result into an application error with context.
pub trait ResultExt<T> {
/// Adds context to an I/O operation.
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))
}
}

View File

@@ -0,0 +1,15 @@
//! Core configuration, account, session, and accounting contracts for latchd.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
/// Linux UTMPX accounting primitives.
pub mod accounting;
/// NSS account discovery and faillock status.
pub mod accounts;
/// Validated application configuration.
pub mod config;
/// Structured application errors.
pub mod error;
/// Session discovery and process lifecycle.
pub mod session;

View File

@@ -0,0 +1,861 @@
//! Session discovery, environment construction, and child lifecycle.
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 signal_hook::{consts::SIGUSR1, flag};
/// A session family supported by latchd.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionKind {
/// A login shell on the current virtual terminal.
Tty,
/// An X11 desktop session.
X11,
/// A Wayland desktop session.
Wayland,
}
/// A discoverable desktop or script session.
#[derive(Debug, Clone)]
pub struct SessionDescriptor {
/// Session family.
pub kind: SessionKind,
/// User-facing title.
pub title: String,
/// Program and arguments, without shell interpretation.
pub argv: Vec<String>,
/// XDG current-desktop value.
pub current_desktop: Option<String>,
/// XDG session-desktop value.
pub session_desktop: Option<String>,
}
/// A fully materialized session request.
#[derive(Debug, Clone)]
pub struct SessionSpec {
/// Session family.
pub kind: SessionKind,
/// Session title for logs.
pub title: String,
/// Program and arguments.
pub argv: Vec<String>,
/// Explicit child environment.
pub environment: Vec<(String, String)>,
/// Working directory.
pub working_directory: PathBuf,
/// Private stdout/stderr log for graphical sessions.
pub client_log_path: PathBuf,
/// Login shell for TTY fallback.
pub shell: PathBuf,
/// Virtual terminal number.
pub tty: u8,
/// X11 configuration when this is an X11 session.
pub x11: Option<X11Runtime>,
}
/// Runtime X11 parameters passed to a session.
#[derive(Debug, Clone)]
pub struct X11Runtime {
/// X display name.
pub display: String,
/// X server command.
pub server_path: PathBuf,
/// Xauthority command.
pub xauth_path: PathBuf,
/// X server timeout.
pub timeout: Duration,
/// X server log path.
pub log_path: PathBuf,
}
/// Discovers configured sessions in stable 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
}
/// Checks configured binaries and discovered 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)
}
/// Builds an explicit environment and process specification for one identity.
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_SESSION_ID", "latchd".into());
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,
})
}
/// Runs a user session and owns all child process cleanup.
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")
);
}
}

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,267 @@
//! Low-cost terminal wallpaper and procedural 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 FRAME_INTERVAL: Duration = Duration::from_millis(120);
/// Background state owned by the TUI thread.
pub(crate) struct Background {
wallpaper: Option<RgbImage>,
automata: Automata,
enabled: bool,
animations: bool,
reduced_motion: bool,
last_tick: Instant,
}
impl Background {
/// Loads a bounded wallpaper image and prepares the procedural layer.
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) {
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] {
if dx == 0 && dy == 0 {
continue;
}
let nx = (x + dx) % self.width;
let ny = (y + dy) % self.height;
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);
}
}

View File

@@ -0,0 +1,901 @@
//! Ratatui login screen and deterministic state machine.
use std::{
sync::mpsc::{self, Receiver},
thread,
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
DefaultTerminal,
prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use zeroize::Zeroizing;
use latchd_auth::{AuthService, AuthTicket};
use latchd_core::{
accounts::{self, FaillockStatus, Identity},
config::Config,
error::{Error, ResultExt},
session::{self, SessionDescriptor},
};
mod background;
use background::Background;
const MIN_WIDTH: u16 = 60;
const MIN_HEIGHT: u16 = 16;
const MAX_INPUT: usize = 256;
/// The result of one completed login-screen interaction.
pub struct LoginSelection {
/// Authenticated PAM ticket.
pub ticket: AuthTicket,
/// Selected session descriptor.
pub session: SessionDescriptor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Sessions,
Username,
Password,
}
#[derive(Debug)]
enum AuthState {
Idle,
Pending(Receiver<Result<AuthTicket, Error>>),
Failed,
}
#[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_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>,
selected: usize,
focus: Focus,
username: String,
password: Zeroizing<String>,
status: String,
auth_state: AuthState,
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,
}
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,
accounts,
account_index,
faillock_receiver: None,
faillock_status: None,
selected: 0,
focus: Focus::Username,
username,
password: Zeroizing::new(String::new()),
status: "Enter your username".into(),
auth_state: AuthState::Idle,
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,
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
loop {
self.poll_faillock();
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(result) = self.poll_auth() {
match result {
Ok(ticket) => {
let selected = self
.sessions
.get(self.selected)
.cloned()
.ok_or_else(|| Error::Session("no session selected".into()))?;
return Ok(Some(LoginSelection {
ticket,
session: selected,
}));
}
Err(error) => {
tracing::warn!(%error, "authentication attempt failed");
self.auth_state = AuthState::Failed;
self.password.clear();
self.status = "Authentication failed".into();
self.notify("Authentication failed", NoticeLevel::Warning);
self.start_faillock_query();
}
}
}
if self.should_quit {
return Ok(None);
}
if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? {
if let Event::Key(key) = event::read().io_context("read terminal event")? {
if key.kind == KeyEventKind::Press {
self.handle_key(key);
}
}
}
}
}
fn poll_auth(&mut self) -> Option<Result<AuthTicket, Error>> {
let AuthState::Pending(receiver) = &self.auth_state else {
return None;
};
match receiver.try_recv() {
Ok(result) => Some(result),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => Some(Err(Error::Worker(
"authentication task disconnected".into(),
))),
}
}
fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else {
return;
};
match receiver.try_recv() {
Ok(Ok(status)) => {
self.faillock_status = Some(status);
self.faillock_receiver = None;
if status.available {
if status.locked_out {
self.notify(
"Account has reached the advisory faillock threshold",
NoticeLevel::Warning,
);
} else if status.failures > 0 {
self.notify(
&format!("{} failed attempt(s) recorded", status.failures),
NoticeLevel::Info,
);
}
}
}
Ok(Err(error)) => {
tracing::debug!(%error, "faillock query failed");
self.faillock_receiver = None;
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None,
}
}
fn start_faillock_query(&mut self) {
let username = self.username.clone();
if username.is_empty() {
self.faillock_receiver = None;
self.faillock_status = None;
return;
}
let config = self.config.accounts.clone();
let (sender, receiver) = mpsc::sync_channel(1);
self.faillock_receiver = Some(receiver);
let task = thread::Builder::new()
.name("latchd-faillock".into())
.spawn(move || {
let result = accounts::faillock_status(&username, &config);
let _ = sender.send(result);
});
if let Err(error) = task {
tracing::debug!(%error, "could not start faillock query");
self.faillock_receiver = None;
}
}
fn notify(&mut self, message: &str, level: NoticeLevel) {
if self.config.visual.notifications {
self.notice = Some(Notice {
message: message.to_owned(),
level,
expires: Instant::now() + Duration::from_secs(4),
});
}
}
fn 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(_)) {
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.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;
}
_ => {}
}
}
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::Esc => {
self.password.clear();
self.status = "Login cancelled".into();
}
KeyCode::Tab | KeyCode::Down => self.next_focus(),
KeyCode::BackTab | KeyCode::Up => self.previous_focus(),
KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(),
KeyCode::Right if self.focus == Focus::Sessions => self.next_session(),
KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(),
KeyCode::F(number) => self.confirm_power_action(number),
KeyCode::Backspace => self.backspace(),
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.insert(character)
}
_ => {}
}
}
fn confirm_power_action(&mut self, number: u8) {
if self.preview {
self.status = "Preview mode — power actions are disabled".into();
return;
}
let key = format!("F{number}");
if let Some((index, action)) = self
.config
.power_controls
.entries
.iter()
.enumerate()
.find(|(_, action)| action.key.eq_ignore_ascii_case(&key))
{
self.power_confirmation = Some(index);
self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint);
}
}
fn run_power_action(&mut self, index: usize) {
if self.preview {
self.power_confirmation = None;
self.status = "Preview mode — power actions are disabled".into();
return;
}
let Some(action) = self.config.power_controls.entries.get(index).cloned() else {
self.power_confirmation = None;
self.status = "Power action is no longer available".into();
return;
};
if !action.program.is_absolute() {
self.power_confirmation = None;
self.status = "Power action must use an absolute program path".into();
return;
}
match std::process::Command::new(&action.program)
.args(&action.args)
.spawn()
{
Ok(_) => {
self.power_confirmation = None;
self.status = format!("Started {}", action.hint);
}
Err(error) => {
self.power_confirmation = None;
self.status = format!("Could not start {}: {error}", action.hint);
}
}
}
fn begin_authentication(&mut self) {
if self.preview {
self.status = "Preview mode — authentication and session startup are disabled".into();
return;
}
if self.username.is_empty() || self.password.is_empty() {
self.status = "Username and password are required".into();
return;
}
let username = self.username.clone();
let password = self.password.as_bytes().to_vec();
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.auth_state = AuthState::Pending(receiver);
self.status = "Authenticating…".into();
self.password.clear();
}
Err(error) => self.status = format!("Could not start authentication: {error}"),
}
}
fn insert(&mut self, character: char) {
if self.focus == Focus::Username {
self.account_index = None;
}
let target = match self.focus {
Focus::Username => &mut self.username,
Focus::Password => &mut self.password,
Focus::Sessions => return,
};
if target.chars().count() < MAX_INPUT {
target.push(character);
}
}
fn backspace(&mut self) {
if self.focus == Focus::Username {
self.account_index = None;
}
let target = match self.focus {
Focus::Username => &mut self.username,
Focus::Password => &mut self.password,
Focus::Sessions => return,
};
target.pop();
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Username,
Focus::Username => Focus::Password,
Focus::Password => Focus::Sessions,
};
self.update_hint();
}
fn previous_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Password,
Focus::Username => Focus::Sessions,
Focus::Password => Focus::Username,
};
self.update_hint();
}
fn update_hint(&mut self) {
self.status = match self.focus {
Focus::Sessions => "Choose a session with ←/→".into(),
Focus::Username => "Enter your username".into(),
Focus::Password => "Enter your password and press Enter".into(),
};
}
fn next_session(&mut self) {
if !self.sessions.is_empty() {
self.selected = (self.selected + 1) % self.sessions.len();
}
}
fn previous_session(&mut self) {
if !self.sessions.is_empty() {
self.selected = self
.selected
.checked_sub(1)
.unwrap_or(self.sessions.len() - 1);
}
}
fn next_account(&mut self) {
self.select_account(1);
}
fn previous_account(&mut self) {
self.select_account(-1);
}
fn select_account(&mut self, delta: isize) {
if self.accounts.is_empty() {
self.notify(
"No eligible accounts discovered; enter a username manually",
NoticeLevel::Info,
);
return;
}
let current = self.account_index.unwrap_or(0) as isize;
let length = self.accounts.len() as isize;
let index = (current + delta).rem_euclid(length) as usize;
self.account_index = Some(index);
self.username = self.accounts[index].username.clone();
self.password.clear();
self.auth_state = AuthState::Idle;
self.faillock_status = None;
self.status = format!("Selected account {}", self.username);
self.start_faillock_query();
self.notify(
&format!("Selected account {}", self.username),
NoticeLevel::Info,
);
}
fn draw(&mut self, frame: &mut ratatui::Frame) {
let area = frame.area();
self.background.draw(frame, area, self.ascii, self.no_color);
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(format!(
"terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}"
))
.alignment(ratatui::prelude::Alignment::Center),
area,
);
return;
}
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(8),
Constraint::Length(2),
Constraint::Length(1),
])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled("latchd", self.style_accent()),
Span::raw(" secure terminal login"),
]))
.alignment(ratatui::prelude::Alignment::Center);
frame.render_widget(title, vertical[0]);
let body = if area.width < 80 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Length(8)])
.split(vertical[1])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(vertical[1])
};
let session_area = body[0];
let form_area = if body.len() == 2 {
body[1]
} else {
vertical[1]
};
let session_items: Vec<ListItem> = self
.sessions
.iter()
.enumerate()
.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 sessions =
List::new(session_items).block(self.block("Session", 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("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(
"Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help",
),
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);
}
}
fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
let popup = centered_rect(70, 50, area);
frame.render_widget(Clear, popup);
frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help")
.block(self.block("Help", false)).wrap(Wrap { trim: true }), popup);
}
fn block(&self, title: &str, focused: bool) -> Block<'static> {
let mut block = Block::default()
.borders(Borders::ALL)
.title(title.to_owned())
.border_style(if focused {
self.style_accent()
} else {
self.style_muted()
});
if !self.no_color {
block = block.style(Style::default().bg(ratatui::prelude::Color::Black));
}
block
}
fn account_title(&self) -> String {
self.account_index
.map(|index| format!("Username {}/{}", index + 1, self.accounts.len()))
.unwrap_or_else(|| "Username".into())
}
fn status_line(&self) -> String {
let mut status = self.status.clone();
if matches!(self.auth_state, AuthState::Pending(_)) {
let spinner = if self.config.visual.animations && !self.reduced_motion {
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
.get(self.animation_frame % 10)
.copied()
.unwrap_or("⠋")
} else if self.ascii {
"|"
} else {
"…"
};
status = format!("{spinner} Authenticating…");
}
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(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));
}
status
}
fn style_accent(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Cyan)
.add_modifier(ratatui::prelude::Modifier::BOLD)
}
}
fn style_selected(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
} else {
Style::default()
.fg(ratatui::prelude::Color::Yellow)
.add_modifier(ratatui::prelude::Modifier::BOLD)
}
}
fn style_muted(&self) -> Style {
Style::default().add_modifier(ratatui::prelude::Modifier::DIM)
}
}
/// Runs the login UI and restores the terminal on every return path.
pub fn run(
config: Config,
auth: AuthService,
preview: bool,
) -> Result<Option<LoginSelection>, Error> {
let sessions = session::discover(&config);
let accounts = if config.accounts.enumerate {
accounts::discover(&config.accounts)
} else {
Vec::new()
};
let background = Background::load(&config.visual)?;
let mut app = App::new(config, auth, preview, sessions, accounts, background);
app.account_index = (!app.accounts.is_empty()).then_some(0);
app.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
}
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
fn truncate_cells(value: &str, width: usize) -> String {
if UnicodeWidthStr::width(value) <= width {
return value.into();
}
let mut output = String::new();
let mut used = 0;
for character in value.chars() {
let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1);
if used + character_width + 1 > width {
break;
}
output.push(character);
used += character_width;
}
output.push('…');
output
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
#[test]
fn truncation_respects_terminal_cells() {
assert_eq!(truncate_cells("界界界", 5), "界界…");
}
#[test]
fn renders_the_login_screen_at_the_floor() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let buffer = terminal.backend().buffer();
let rendered = buffer
.content()
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("latchd"));
assert!(rendered.contains("Username"));
assert!(rendered.contains("Password"));
}
#[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));
}
}

View File

@@ -1,13 +1,46 @@
# latchd config — see src/config/config.go for all defaults. # latchd configuration. All paths are validated before the login screen starts.
# only include values you want to override.
# tty = 2 tty = 2
# pam_service = "latchd" main_log_path = "/var/log/latchd.log"
# system_shell = "/bin/sh" client_log_path = "/var/log/latchd.client.log"
# focus_behaviour = "default" pam_service = "latchd"
system_shell = "/bin/sh"
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
[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
# Power actions use an executable path and argv, never a shell command string.
# Keep this section empty unless the installation explicitly wants console
# power controls.
[power_controls] [power_controls]
# [[power_controls.entries]] entries = []
# hint = "Hibernate"
# key = "F3" [accounts]
# cmd = "systemctl hibernate" minimum_uid = 1000
excluded_users = []
enumerate = true
faillock_path = "/usr/sbin/faillock"
faillock_config = "/etc/security/faillock.conf"
[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 auth include login
account include login account include login
password include login password include login
session include login

View File

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

@@ -51,7 +51,7 @@ type AuthError struct {
} }
const ( const (
ErrPAMService = iota ErrPAMService = iota
ErrAccountValidation ErrAccountValidation
ErrHomeDirInvalid ErrHomeDirInvalid
ErrShellInvalid ErrShellInvalid

View File

@@ -3,6 +3,7 @@
// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct // ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct
// (__uint32_t tv_sec / __int32_t tv_usec). // (__uint32_t tv_sec / __int32_t tv_usec).
#include <stdint.h>
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t 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_sec = sec;
ut->ut_tv.tv_usec = usec; ut->ut_tv.tv_usec = usec;

View File

@@ -125,23 +125,23 @@ type WaylandConfig struct {
} }
type Config struct { type Config struct {
TTY uint8 `toml:"tty"` TTY uint8 `toml:"tty"`
MainLogPath string `toml:"main_log_path"` MainLogPath string `toml:"main_log_path"`
ClientLogPath string `toml:"client_log_path"` ClientLogPath string `toml:"client_log_path"`
CachePath string `toml:"cache_path"` CachePath string `toml:"cache_path"`
DoLog bool `toml:"do_log"` DoLog bool `toml:"do_log"`
PAMService string `toml:"pam_service"` PAMService string `toml:"pam_service"`
SystemShell string `toml:"system_shell"` SystemShell string `toml:"system_shell"`
InitialPath string `toml:"initial_path"` InitialPath string `toml:"initial_path"`
ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"` ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"`
FocusBehaviour FocusBehaviour `toml:"focus_behaviour"` FocusBehaviour FocusBehaviour `toml:"focus_behaviour"`
Background BackgroundConfig `toml:"background"` Background BackgroundConfig `toml:"background"`
PowerControls PowerControlConfig `toml:"power_controls"` PowerControls PowerControlConfig `toml:"power_controls"`
Switcher SwitcherConfig `toml:"environment_switcher"` Switcher SwitcherConfig `toml:"environment_switcher"`
Username UsernameFieldConfig `toml:"username_field"` Username UsernameFieldConfig `toml:"username_field"`
Password PasswordFieldConfig `toml:"password_field"` Password PasswordFieldConfig `toml:"password_field"`
X11 X11Config `toml:"x11"` X11 X11Config `toml:"x11"`
Wayland WaylandConfig `toml:"wayland"` Wayland WaylandConfig `toml:"wayland"`
} }
func Default() Config { func Default() Config {

View File

@@ -4,10 +4,8 @@ go 1.22.0
require ( require (
github.com/BurntSushi/toml v1.4.0 github.com/BurntSushi/toml v1.4.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.6 github.com/charmbracelet/bubbletea v0.26.6
github.com/charmbracelet/lipgloss v0.11.0 github.com/charmbracelet/lipgloss v0.11.0
github.com/mattn/go-runewidth v0.0.15
) )
require ( require (
@@ -15,9 +13,12 @@ require (
github.com/charmbracelet/x/ansi v0.1.2 // indirect github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/charmbracelet/x/input v0.1.0 // indirect github.com/charmbracelet/x/input v0.1.0 // indirect
github.com/charmbracelet/x/term v0.1.1 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.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 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.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/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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= 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/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 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 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/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

@@ -424,8 +424,8 @@ func (m *Model) adminBypassCmd() tea.Cmd {
// ── queries ──────────────────────────────────────────────────────────── // ── queries ────────────────────────────────────────────────────────────
func (m *Model) Result() *Result { return m.result } func (m *Model) Result() *Result { return m.result }
func (m *Model) Quitting() bool { return m.quitting } func (m *Model) Quitting() bool { return m.quitting }
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a } func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
// ── view ─────────────────────────────────────────────────────────────── // ── view ───────────────────────────────────────────────────────────────
@@ -438,8 +438,8 @@ var (
infoCol = lipgloss.Color("3") // yellow infoCol = lipgloss.Color("3") // yellow
greenCol = lipgloss.Color("2") // green greenCol = lipgloss.Color("2") // green
cardBg = lipgloss.Color("0") // black card background cardBg = lipgloss.Color("0") // black card background
cardPad = 2 cardPad = 2
avatarStyle = lipgloss.NewStyle().Foreground(accent) avatarStyle = lipgloss.NewStyle().Foreground(accent)
@@ -459,11 +459,11 @@ var (
fieldText = lipgloss.NewStyle().Foreground(bright) fieldText = lipgloss.NewStyle().Foreground(bright)
fieldFocus = lipgloss.NewStyle().Foreground(accent) fieldFocus = lipgloss.NewStyle().Foreground(accent)
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true) swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
swNeighbour = lipgloss.NewStyle().Foreground(subtle) swNeighbour = lipgloss.NewStyle().Foreground(subtle)
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true) swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
swDot = lipgloss.NewStyle().Foreground(subtle) swDot = lipgloss.NewStyle().Foreground(subtle)
swDotActive = lipgloss.NewStyle().Foreground(accent) swDotActive = lipgloss.NewStyle().Foreground(accent)
powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true) powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true)

View File

@@ -16,12 +16,12 @@ import (
) )
type PostLoginEnv struct { type PostLoginEnv struct {
Kind string // "x11", "wayland", "tty" Kind string // "x11", "wayland", "tty"
Title string Title string
XinitrcPath string XinitrcPath string
ScriptPath string ScriptPath string
DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated) DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated)
SessionDesktop string // for XDG_SESSION_DESKTOP SessionDesktop string // for XDG_SESSION_DESKTOP
} }
type SpawnedEnv struct { type SpawnedEnv struct {

View File

@@ -23,10 +23,10 @@ const (
) )
type Automata struct { type Automata struct {
w, h int w, h int
a, b []uint8 a, b []uint8
useA bool useA bool
buf strings.Builder buf strings.Builder
} }
func NewAutomata(w, h int) *Automata { func NewAutomata(w, h int) *Automata {

4
rust-toolchain.toml Normal file
View File

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

237
rust/main.rs Normal file
View File

@@ -0,0 +1,237 @@
//! latchd is a root-owned terminal login manager.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
use std::{
fs::{self, OpenOptions},
io,
os::unix::fs::{MetadataExt, OpenOptionsExt},
path::PathBuf,
};
use clap::Parser;
use latchd_auth::AuthService;
use latchd_core::{
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 latchd.
#[derive(Debug, Parser)]
#[command(
name = "latchd",
version = BUILD_VERSION,
about = "A secure terminal login manager"
)]
struct Cli {
/// Configuration TOML path.
#[arg(short, long, default_value = "/etc/latchd/config.toml")]
config: PathBuf,
/// Optional variable TOML path.
#[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 without authenticating or starting a real session.
#[arg(long)]
preview: bool,
/// Validate configuration and exit without starting the TUI.
#[arg(long, alias = "preflight")]
no_tui: bool,
/// Do not create a main log.
#[arg(long)]
no_log: bool,
/// Print the validated configuration and exit.
#[arg(long)]
show_config: bool,
/// Print embedded release metadata without accessing configuration or PAM.
#[arg(long)]
build_info: bool,
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
run().map_err(|error| color_eyre::eyre::eyre!(error.to_string()))
}
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 = if cli.config.exists() {
Some(cli.config.as_path())
} else {
if cli.config.as_os_str() != "/etc/latchd/config.toml" {
return Err(Error::Config(format!(
"configuration does not exist: {}",
cli.config.display()
)));
}
None
};
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 {
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, 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 unsafe { libc::geteuid() } != 0 {
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");
if let Err(error) = auth.run_session(selection.ticket, spec) {
error!(%error, "session ended with an error");
}
}
Ok(())
}
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(())
}

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

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
image="latchd-test:local"
docker build --pull -f Dockerfile.test -t "$image" .