Document ahfail as a locker

The README leads with ahfail, ahfail test and ahfail doctor, the
platform table, idle locking and what the lock does and does not
guarantee, including pam_faillock. The old gtklock module and PAM hook
move to a short legacy section. CLAUDE.md describes the five crates.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
Asger Geel Weirsøe
2026-09-30 19:18:21 +02:00
parent 79bb0e41d5
commit cb41117932
2 changed files with 105 additions and 222 deletions
+34 -47
View File
@@ -4,66 +4,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What This Is
`ahfail` is a [`gtklock`](https://github.com/jovanlanik/gtklock) module. It compiles to `ahfail-module.so`, which gtklock loads at runtime. On each failed unlock attempt (`PW_FAILURE`), it spawns an animated "Nedry" sprite at a random screen location and plays an audio clip ("ah ah ah, you didn't say the magic word").
`ahfail` is a screen locker. On every failed unlock it spawns an animated "Nedry" sprite at a random screen position and plays "ah ah ah, you didn't say the magic word". On Linux the `ahfail` binary is itself the locker (Wayland via ext-session-lock-v1, X11 via an input grab) and authenticates through its own PAM service, `/etc/pam.d/ahfail` (`data/pam.d/ahfail`). On macOS the system lock screen is used and the effect comes from the PAM hook.
## Build System
The older integrations, a gtklock module and a PAM hook for other X11 lockers, are still built as "legacy integrations".
The build is a two-stage hybrid:
## Workspace
1. **Meson** compiles GResources from `assets/ahfail.gresource.xml` into C, invokes Cargo to produce `libahfail_module.a`, then links everything into `ahfail-module.so`.
2. **Cargo** handles only the Rust crate — it produces a `staticlib` that Meson links.
Five crates under `crates/`:
Always use Meson for the final shared object; `cargo build` alone does not produce the loadable module.
| Crate | Output | Responsibility |
|---|---|---|
| `ahfail` | `ahfail` binary | The CLI and the locker: `lock` (default), `test` (preview, no lock), `doctor` (environment check). `wayland.rs` (gtk-session-lock FFI), `x11.rs` (seat grab), `locker.rs` (shared lock UI), `pam.rs` (PAM client, service `ahfail`). |
| `ahfail-ui` | rlib | Shared by all: sprite animation from GResources, sprite placement with deadzone, audio player, volume save/restore, optional update check (cargo feature `update-check`, default on). |
| `ahfail-gtklock` | `libahfail_module.a` → `ahfail-module.so` | Legacy gtklock module. Meson links the staticlib with the GResources into the loadable `.so`. |
| `ahfail-pam` | `libahfail_pam.so` | Legacy PAM hook for X11 lockers and macOS. Spawns `ahfail-display` on a failed attempt, never from a root process. Always returns `PAM_IGNORE`. |
| `ahfail-display` | `ahfail-display` binary | Legacy standalone sprite window spawned by the PAM hook. Refuses to run as root; single-instance lock in `$XDG_RUNTIME_DIR`. |
Assets (`assets/*.png`, `assets/magic-word.mp3`) are embedded as GResources, via Meson for the gtklock module and via `build.rs` for the binaries, and read at runtime as `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
## Build
```bash
# First-time setup
meson setup builddir
# Build
meson setup builddir --prefix=/usr # --prefix=/usr for Arch paths
meson compile -C builddir
sudo meson install -C builddir
# Run Rust tests only (no GTK display required)
cargo test
# Run Meson tests (loads the .so, requires GTK)
meson test -C builddir
# Manual integration test
gtklock -d -m builddir/ahfail-module.so -- --deadzone=X,Y,W,H
cargo build --release -p ahfail # just the locker
cargo build --release -p ahfail-display --no-default-features # without the update check
```
## Architecture
Always use Meson for `ahfail-module.so`: `cargo build` alone produces only the staticlib.
All FFI entry points live in `src/lib.rs` — these are the `extern "C"` functions that gtklock calls (`on_activation`, `on_window_create`, `on_window_destroy`, `on_idle_hide`, etc.).
**Module lifecycle:**
- `on_activation` — called once at startup; initialises GTK/GStreamer, loads all sprite frames into a `PixbufSimpleAnim`, stores them in `MODULE_STATE`.
- `on_window_create` — called per monitor; calls `WindowHandler::create`, which creates a `gtk::Fixed` overlay, a pool of pre-warmed GStreamer players, and wires up a signal on `error_label` that fires on each failed attempt.
- `on_window_destroy` / `on_idle_hide` — clean up sprites and stop players.
**Key types:**
| Type | File | Purpose |
|------|------|---------|
| `MODULE_STATE` | `src/state.rs` | Thread-local `RefCell<ModuleState>` holding the shared animation, audio URI, and deadzone config |
| `WindowData` | `src/state.rs` | Heap-allocated per-window state (sprites, player pool, GTK signal handle) |
| `WindowContext` | `src/context.rs` | Safe wrapper around the raw `*mut Window` pointer passed from C |
| `Window` / `GtkLock` | `src/context.rs` | `#[repr(C)]` mirrors of gtklock's internal structs — must match `include/gtklock-module.h` |
| `WindowHandler` | `src/handler.rs` | Sprite placement logic (random position, deadzone avoidance with retries) and GStreamer player management |
| `ModuleConfig` | `src/config.rs` | Parses the `--deadzone=x,y,w,h` CLI argument via glib's `GOptionEntry` |
**Assets** are embedded as GResources at build time. At runtime they are accessed via `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
## Safety Conventions
- All `unsafe` pointer work on `*mut Window` goes through `WindowContext` — never dereference the raw pointer outside that wrapper.
- `WindowData` is heap-allocated via `Box::into_raw` and reclaimed via `Box::from_raw` in `WindowContext::take_data`. Do not free it any other way.
- The `module_data` flexible array field on `Window` is a C ABI contract with gtklock — index 0 is this module's slot.
Linux build dependencies: gtk3, gtk-session-lock, gstreamer (+ base/good plugins), pam, meson, ninja, rust.
## Tests
`tests/ahfail_tests.rs` — integration tests that construct mock `Window` / `GtkLock` structs directly to exercise handler logic without a running gtklock process. Run with `cargo test`.
```bash
cargo test # unit + integration (some need a display: use xvfb-run)
xvfb-run meson test -C builddir --verbose # smoke tests: module symbols, PAM module
```
`tests/benchmarks.rs` — criterion benchmarks for sprite placement.
- `crates/ahfail-gtklock/tests/`: integration tests that build mock `Window`/`GtkLock` structs.
- `tests/module_test.c`, `tests/pam_smoke_test.c`: C smoke tests built by Meson.
- The locker itself is tested headless: X11 under Xvfb with `xdotool`, Wayland under `sway` with `WLR_BACKENDS=headless` and `wtype`.
`tests/module_test.c` — C smoke test built by Meson that dlopen-style verifies the exported symbols exist.
## Safety Conventions
- `ahfail` and `ahfail-display` must never run as root; the PAM hook must never spawn anything from a root process.
- The X11 lock is only the grab: if it cannot be taken, refuse to lock. Never show a lock screen without it.
- The password lives in `pam::Secret`, which zeroes itself on drop, and PAM runs off the GTK thread.
- gtklock module: all `unsafe` work on `*mut Window` goes through `WindowContext`; `WindowData` is created with `Box::into_raw` and reclaimed only in `WindowContext::take_data`. The `#[repr(C)]` structs must match `include/gtklock-module.h`.
+71 -175
View File
@@ -1,142 +1,78 @@
# Ah ah ah, you didn't say the magic word
On a failed lock-screen unlock attempt, this spawns a looping animation of **the author's face photoshopped onto Dennis Nedry's body** and plays the "ah ah ah, you didn't say the magic word" clip from Jurassic Park. Each wrong guess adds another sprite at a random screen position. Volume is forced to 100% on the first failure and restored when the screen unlocks.
`ahfail` is a screen locker. Get the password wrong and a looping animation of **the author's face photoshopped onto Dennis Nedry's body** appears on screen while the "ah ah ah, you didn't say the magic word" clip from Jurassic Park plays. Every wrong guess adds another Nedry. The volume is forced to 100% on the first failure and restored when you unlock.
```bash
ahfail # lock the screen
ahfail test # show the effect once, without locking anything
ahfail doctor # check this machine and explain what is missing
```
One command on every platform: `ahfail` works out whether you run Wayland or X11 (or macOS) and does the right thing.
---
> **Security disclaimer**
>
> This project hooks into PAM and/or your screen locker's plugin API. PAM sits directly in the authentication critical path — a bug in this module could lock you out of your system or, in the worst case, weaken authentication.
> This is a screen locker, so it sits directly in the authentication path. A bug in it could lock you out of your session or, in the worst case, weaken the lock.
>
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are modifying a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are running a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
---
## Platform support
## Install
The integration method differs by display server:
| Platform | Display server | Module loaded | How it works |
|---|---|---|---|
| Linux | **Wayland** | `ahfail-module.so` | Loaded directly by `gtklock` via its module API |
| Linux | **X11** | `libahfail_pam.so` + `ahfail-display` | PAM cleanup hook spawns the display binary after each failed auth |
| macOS | — | `libahfail_pam.so` + `ahfail-display` | Same PAM approach, hooks into the screensaver PAM stack |
The **gtklock module** is Wayland-only — it uses gtklock's internal window API to overlay sprites directly on the lock screen. The **PAM module** works on X11 and macOS by registering a cleanup callback that fires on each failed authentication attempt; it double-forks a standalone display binary (`ahfail-display`) that runs independently of the PAM stack.
---
## Linux (x86\_64) — binary install
Pre-built binaries for both Wayland and X11 are available on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases). The tarball contains all three files:
| File | Used by |
|---|---|
| `ahfail-module.so` | Wayland — loaded by `gtklock` |
| `libahfail_pam.so` | X11 — loaded by PAM |
| `ahfail-display` | X11 — spawned by the PAM module |
### Arch Linux
```bash
# Download and extract (replace v0.1.0 with the latest release)
curl -fsSL https://gitea.weircon.dk/agw/gtk-ahfail/releases/download/v0.1.0/ahfail-linux-x86_64.tar.gz \
| sudo tar -xz -C /tmp/ahfail-install
# Wayland (gtklock)
sudo install -Dm755 /tmp/ahfail-install/ahfail-module.so /usr/lib/gtklock/ahfail-module.so
# X11 (PAM module + display binary)
sudo install -Dm755 /tmp/ahfail-install/libahfail_pam.so /usr/lib/ahfail/libahfail_pam.so
sudo install -Dm755 /tmp/ahfail-install/ahfail-display /usr/lib/ahfail/ahfail-display
yay -S ahfail
```
Then follow the [Wayland](#usage-linux---wayland-only-gtklock) or [X11](#usage-linux---x11-i3lock-xscreensaver-etc) configuration below.
(Once it is published on the AUR.) The package installs the `ahfail` binary and its own PAM service file, `/etc/pam.d/ahfail`. Nothing in any other package's PAM configuration is touched, the same way swaylock and gtklock ship theirs.
---
## Linux compile
### Install dependencies and build from source
### From source
```bash
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gtklock
sudo pacman -S meson ninja rust gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good pam
meson setup builddir --prefix=/usr
meson compile -C builddir
sudo meson install -C builddir
```
`--prefix=/usr` matches Arch conventions (same as pacman) and ensures the compiled-in default path for `ahfail-display` matches where it is installed.
`--prefix=/usr` matches Arch conventions and puts the PAM file where PAM looks for it.
Installs:
- `/usr/lib/gtklock/ahfail-module.so` — gtklock module
- `/usr/lib/ahfail/libahfail_pam.so` — PAM module (for X11)
- `/usr/lib/ahfail/ahfail-display` — standalone display binary
Then run `ahfail doctor`. It tells you whether the session type is supported, whether `/etc/pam.d/ahfail` is in place, whether GStreamer can play the clip and whether the volume can be controlled, with the exact fix for anything that is missing.
---
## Usage Linux - wayland (only gtklock)
## Platform support
| Platform | How it locks | Notes |
|---|---|---|
| Linux, **Wayland** | ext-session-lock-v1 (through gtk-session-lock) | sway, Hyprland, niri, river, labwc and KDE Plasma 6. **Not GNOME**: GNOME does not let third-party programs lock the screen. |
| Linux, **X11** | full-screen windows on every monitor plus a keyboard and pointer grab | The i3lock model. Works with any X11 window manager. |
| **macOS** | the system lock screen | `ahfail` asks macOS to lock; the effect comes from the PAM hook installed via Homebrew (see below). |
## Locking automatically
Point your idle daemon at `ahfail`. Running it while already locked is harmless: a second instance exits quietly.
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so
# Wayland
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
# X11
xss-lock -- ahfail
```
### Arguments
Pass `--no-max-volume` to leave the volume alone.
Pass module arguments after `--`:
## Security
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
```
- `--deadzone=X,Y,W,H` — rectangle where sprites will not spawn (keep your password field clear)
- `--audio-uri=URI` — override the default audio clip, e.g. `file:///home/user/custom.mp3`
---
## Usage Linux - X11 (i3lock, xscreensaver, etc.)
The PAM module works with any X11 locker that authenticates via PAM. Supported lockers and their service file names:
| Locker | PAM service file |
|---|---|
| i3lock | `/etc/pam.d/i3lock` |
| i3lock-color | `/etc/pam.d/i3lock-color` |
| betterlockscreen | `/etc/pam.d/betterlockscreen` |
| xscreensaver | `/etc/pam.d/xscreensaver` |
| lightdm | `/etc/pam.d/lightdm` |
Add a line to the relevant file.
**Important:** The `ahfail` line must appear **before** `auth include system-auth` (or any equivalent include). If it comes after, PAM's internal flow control inside `system-auth` (`pam_faillock` with `[default=die]`) means our module is never reached on failed attempts — it only gets called on success.
Your PAM service file should look like this:
**Arch / standard (`--prefix=/usr`):**
```
#%PAM-1.0
auth optional /usr/lib/ahfail/libahfail_pam.so
auth include system-auth
```
**Fedora / RHEL (multilib):**
```
#%PAM-1.0
auth optional /usr/lib64/ahfail/libahfail_pam.so
auth include system-auth
```
**Debian / Ubuntu (multiarch):**
```
#%PAM-1.0
auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so
auth include system-auth
```
The full path is required — `$(libdir)/ahfail` is not in PAM's default search path.
If the display binary is not at the default location, add `display_path=`:
```
auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfail-display
```
- **Wayland:** once the compositor confirms the lock, only the lock surfaces are shown. If `ahfail` crashes or is killed, the session **stays locked** (the compositor shows its own fallback), it never opens up.
- **X11:** the lock is the input grab. If the grab cannot be taken (a menu or another program holds it), `ahfail` refuses to lock rather than show a lock screen your keystrokes pass through. Like i3lock, nothing on X11 keeps the screen locked if the locker process dies.
- `ahfail` refuses to run as root, and the password is checked in a separate thread through PAM service `ahfail` and wiped from memory afterwards.
- **Too many wrong passwords can lock your account.** `/etc/pam.d/ahfail` includes `login`, so the system's `pam_faillock` policy applies. On Arch that means 3 wrong passwords within 15 minutes lock the account for 10 minutes (see `/etc/security/faillock.conf`). That is the system's rule, not ahfail's, and every PAM-based locker behaves the same, but it is worth knowing before you invite colleagues to try their luck.
---
@@ -149,72 +85,44 @@ brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
brew install ahfail
```
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step.
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step. Enable "Require password immediately" in System Settings so `ahfail` actually locks.
To upgrade: `brew upgrade ahfail`.
To upgrade: `brew upgrade ahfail`. To uninstall: `sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui` (or `screensaver`), then `brew uninstall ahfail`.
### Script install
```bash
git clone https://gitea.weircon.dk/agw/gtk-ahfail.git
cd gtk-ahfail
bash scripts/install-macos.sh
```
Installs Homebrew dependencies, builds from source, copies binaries to `/usr/local/lib/ahfail/`, and patches the screensaver PAM configuration automatically.
### Manual install
```bash
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
# install Rust via https://rustup.rs if not present
meson setup builddir && meson compile -C builddir
sudo mkdir -p /usr/local/lib/ahfail
sudo cp builddir/libahfail_pam.so builddir/ahfail-display /usr/local/lib/ahfail/
```
Add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` as the **first** `auth` line (before any `auth include` or `auth required` entries):
```
auth optional /usr/local/lib/ahfail/libahfail_pam.so
```
`bash scripts/install-macos.sh` and `bash scripts/uninstall-macos.sh` do the same from a source checkout.
---
## Uninstall
## Legacy integrations
### Linux — X11
Before `ahfail` became its own locker it hooked into other lockers. These still work and are still built, for anyone who wants to keep their current locker.
| File | Used by |
|---|---|
| `ahfail-module.so` | a module for **gtklock** (Wayland) |
| `libahfail_pam.so` + `ahfail-display` | a PAM hook for **X11 lockers** (i3lock, i3lock-color, betterlockscreen, xscreensaver, lightdm) |
**gtklock:**
```bash
bash scripts/uninstall-linux-x11.sh
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
```
Removes the `ahfail` line from common PAM service files (`/etc/pam.d/i3lock`, `xscreensaver`, `lightdm`, etc.) and removes the installed binaries. If `builddir` is present it uses `ninja uninstall`; otherwise it removes the known paths manually.
`--deadzone=X,Y,W,H` keeps sprites away from your password field, `--audio-uri=URI` swaps the clip.
### Linux — Wayland
**X11 PAM hook:** add this line to the locker's PAM file (for example `/etc/pam.d/i3lock`) **before** `auth include system-auth`. After it, `pam_faillock`'s `[default=die]` inside `system-auth` means the module is never reached on a failed attempt.
No PAM config was modified. Just stop passing the module to gtklock and optionally remove the file:
```bash
sudo rm -f /usr/lib/gtklock/ahfail-module.so # --prefix=/usr install
# or
sudo rm -f /usr/local/lib/gtklock/ahfail-module.so
```
#%PAM-1.0
auth optional /usr/lib/ahfail/libahfail_pam.so
auth include system-auth
```
### macOS — Homebrew
Fedora and RHEL use `/usr/lib64/ahfail/`, Debian and Ubuntu `/usr/lib/x86_64-linux-gnu/ahfail/`; the full path is required. Add `display_path=/path/to/ahfail-display` if the display binary lives elsewhere. The hook never starts the display from a root process (a display manager, for example), so it cannot affect a login screen. `bash scripts/uninstall-linux-x11.sh` removes the line and the files again.
```bash
sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui # or screensaver on macOS 12
brew uninstall ahfail
```
Pre-built binaries of the legacy files are on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases).
### macOS — manual / script install
```bash
bash scripts/uninstall-macos.sh
```
Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/lib/ahfail/`.
On swaylock and hyprlock the PAM hook fires, but the compositor hides everything except the locker itself, so only the audio plays. Use `ahfail` instead.
---
@@ -222,34 +130,22 @@ Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/
The sprite is the author's face on Nedry's body. To use your own:
1. Replace the sprite frames in `assets/sprites/` with your own PNG sequence.
1. Replace the sprite frames in `assets/` with your own PNG sequence.
2. Update `assets/ahfail.gresource.xml` if you add or remove files.
3. Rebuild with Meson.
To replace the audio clip, swap `assets/audio/magic-word.mp3` and rebuild.
To replace the audio clip, swap `assets/magic-word.mp3` and rebuild.
---
## Development
```bash
cargo test # unit + integration tests (needs display)
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
cargo clippy # lint
meson setup builddir && meson compile -C builddir # full build including .so
xvfb-run meson test -C builddir --verbose # Meson tests headless
cargo test # unit + integration tests (some need a display)
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
cargo clippy # lint
meson setup builddir && meson compile -C builddir # full build including the .so files
xvfb-run meson test -C builddir --verbose # Meson tests headless
```
---
## Roadmap
### swaylock (Wayland)
The PAM module can be added to `/etc/pam.d/swaylock` and will fire on each failed attempt — but on Wayland the `ahfail-display` binary cannot draw on top of the lock screen. The Wayland session-lock protocol (`ext-session-lock-v1`) restricts rendering to the locker process itself, so the animation is suppressed by the compositor and only the audio plays.
Full support requires swaylock to expose a plugin API similar to gtklock's. There is an open request for this upstream. Once available, a dedicated swaylock module can be added alongside the existing gtklock one.
### hyprlock (Wayland)
Same situation as swaylock — PAM fires correctly but the display is blocked by the compositor. hyprlock does not currently have a plugin/module API. Full animation support is pending upstream plugin support.
The update check (a daily look at the releases page with a desktop notification) is a cargo feature, `update-check`, on by default. Distro packages build without it, since the package manager handles updates there.