diff --git a/Cargo.lock b/Cargo.lock index 6793c2e..a20f69f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,21 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahfail" +version = "0.10.0" +dependencies = [ + "ahfail-ui", + "cc", + "gdk", + "glib", + "gstreamer", + "gstreamer-player", + "gtk", + "libc", + "pkg-config", +] + [[package]] name = "ahfail-display" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 41c085e..f390073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,6 @@ members = [ "crates/ahfail-gtklock", "crates/ahfail-pam", "crates/ahfail-display", + "crates/ahfail", ] resolver = "2" diff --git a/crates/ahfail-ui/src/display.rs b/crates/ahfail-ui/src/display.rs index e6a146a..d459957 100644 --- a/crates/ahfail-ui/src/display.rs +++ b/crates/ahfail-ui/src/display.rs @@ -3,7 +3,9 @@ use rand::Rng; use crate::config::ModuleConfig; const SPRITE_MARGIN: i32 = 100; -const RETRY_ATTEMPTS: usize = 10; +// Cheap, and with only 10 tries a sprite could still land on the password +// field on a small screen. +const RETRY_ATTEMPTS: usize = 100; pub fn sprite_position( animation: &gdk_pixbuf::PixbufAnimation, diff --git a/crates/ahfail/Cargo.toml b/crates/ahfail/Cargo.toml new file mode 100644 index 0000000..0aa6bd9 --- /dev/null +++ b/crates/ahfail/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ahfail" +version = "0.10.0" +edition = "2021" +description = "Screen locker that says ah ah ah when you get the password wrong" +license = "AGPL-3.0-only" + +[[bin]] +name = "ahfail" + +[dependencies] +# The locker never checks for updates itself, so it does not pull in the HTTP client. +ahfail-ui = { path = "../ahfail-ui", default-features = false } +gtk = { version = "0.15", package = "gtk", features = ["v3_24"] } +gdk = { version = "0.15", package = "gdk", features = ["v3_24"] } +gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] } +gstreamer-player = "0.18" +glib = { version = "0.15", package = "glib" } +libc = "0.2" + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/crates/ahfail/build.rs b/crates/ahfail/build.rs new file mode 100644 index 0000000..27bf842 --- /dev/null +++ b/crates/ahfail/build.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; +use std::process::Command; + +// Same GResource embedding as ahfail-display: the sprites and the audio clip are +// compiled into the binary, and ahfail_ui::animation looks them up through the +// `ahfail_get_resource` symbol that glib-compile-resources generates. +fn main() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir.join("../..").canonicalize().unwrap(); + let assets_dir = workspace_root.join("assets"); + let gresource_xml = assets_dir.join("ahfail.gresource.xml"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let c_src = out_dir.join("ahfail-resources.c"); + + println!("cargo:rerun-if-changed={}", gresource_xml.display()); + for entry in std::fs::read_dir(&assets_dir).unwrap() { + println!("cargo:rerun-if-changed={}", entry.unwrap().path().display()); + } + + let status = Command::new("glib-compile-resources") + .args([ + "--generate-source", + "--target", + c_src.to_str().unwrap(), + "--sourcedir", + assets_dir.to_str().unwrap(), + gresource_xml.to_str().unwrap(), + ]) + .status() + .expect("glib-compile-resources not found"); + assert!(status.success(), "glib-compile-resources failed"); + + let gio = pkg_config::probe_library("gio-2.0").expect("gio-2.0 not found"); + let mut build = cc::Build::new(); + build.file(&c_src).flag_if_supported("-w"); + for inc in &gio.include_paths { + build.include(inc); + } + build.compile("ahfail_resources"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os == "linux" { + // The Wayland backend drives ext-session-lock-v1 through gtk-session-lock, + // the same library gtklock uses. The X11 backend and PAM need libpam. + pkg_config::probe_library("gtk-session-lock-0").expect( + "gtk-session-lock not found (Arch: pacman -S gtk-session-lock)", + ); + println!("cargo:rustc-link-lib=pam"); + } +} diff --git a/crates/ahfail/src/doctor.rs b/crates/ahfail/src/doctor.rs new file mode 100644 index 0000000..5834e58 --- /dev/null +++ b/crates/ahfail/src/doctor.rs @@ -0,0 +1,161 @@ +//! `ahfail doctor`: what works on this machine, what does not, and the exact +//! fix. Exit code 1 if something would stop `ahfail lock` from working. + +use std::path::Path; + +struct Report { + blocking: bool, +} + +impl Report { + fn ok(&self, what: &str) { + println!(" \x1b[32m✓\x1b[0m {what}"); + } + fn warn(&self, what: &str, fix: &str) { + println!(" \x1b[33m!\x1b[0m {what}\n → {fix}"); + } + fn fail(&mut self, what: &str, fix: &str) { + self.blocking = true; + println!(" \x1b[31m✗\x1b[0m {what}\n → {fix}"); + } + fn info(&self, what: &str) { + println!(" · {what}"); + } +} + +fn in_path(bin: &str) -> bool { + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).any(|d| d.join(bin).is_file())) + .unwrap_or(false) +} + +fn env(k: &str) -> String { + std::env::var(k).unwrap_or_default() +} + +/// PAM service files that still carry the old hook (libahfail_pam.so). +fn legacy_pam_hooks(dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { return vec![] }; + rd.flatten() + .filter(|e| { + std::fs::read_to_string(e.path()) + .map(|s| s.lines().any(|l| !l.trim_start().starts_with('#') && l.contains("libahfail_pam"))) + .unwrap_or(false) + }) + .map(|e| e.path().display().to_string()) + .collect() +} + +pub fn run() -> i32 { + let mut r = Report { blocking: false }; + println!("ahfail {} doctor\n", crate::VERSION); + #[cfg(target_os = "linux")] + linux(&mut r); + #[cfg(target_os = "macos")] + macos(&mut r); + println!(); + if r.blocking { + println!("Something above stops `ahfail lock` from working."); + 1 + } else { + println!("Ready. Try `ahfail test`, then `ahfail`."); + 0 + } +} + +#[cfg(target_os = "linux")] +fn linux(r: &mut Report) { + use crate::Backend; + + println!("Session"); + let desktop = env("XDG_CURRENT_DESKTOP"); + r.info(&format!( + "XDG_SESSION_TYPE={} XDG_CURRENT_DESKTOP={}", + if env("XDG_SESSION_TYPE").is_empty() { "?".into() } else { env("XDG_SESSION_TYPE") }, + if desktop.is_empty() { "?" } else { &desktop } + )); + let backend = crate::detect_backend(); + match backend { + None => r.fail("no graphical session (WAYLAND_DISPLAY and DISPLAY are both empty)", "run ahfail from inside your desktop session, not over SSH"), + Some(b) => r.ok(&format!("{b:?} session")), + } + if unsafe { libc::getuid() } == 0 { + r.fail("running as root", "run ahfail as the user whose screen should be locked"); + } + + if let Some(b) = backend { + std::env::set_var("GDK_BACKEND", match b { Backend::Wayland => "wayland", Backend::X11 => "x11" }); + if gtk::init().is_err() { + r.fail("GTK could not open the display", "check that the session variables are exported to this shell"); + } else if b == Backend::Wayland { + if crate::wayland::supported() { + r.ok("compositor supports ext-session-lock-v1"); + } else if desktop.to_uppercase().contains("GNOME") { + r.fail("GNOME does not let third-party programs lock the screen on Wayland", "use GNOME's own lock, or log in to an X11 session for ahfail"); + } else { + r.fail("compositor does not support ext-session-lock-v1", "sway, Hyprland, niri, river, labwc and KDE Plasma 6 do; update the compositor"); + } + } + } + + println!("\nAuthentication"); + if Path::new("/etc/pam.d/ahfail").is_file() { + r.ok("PAM service /etc/pam.d/ahfail"); + } else { + r.fail("/etc/pam.d/ahfail is missing, so no password can ever be accepted", "install the package, or: sudo install -Dm644 data/pam.d/ahfail /etc/pam.d/ahfail"); + } + + println!("\nSound"); + match gstreamer::init() { + Err(_) => r.warn("GStreamer could not start", "install gstreamer and gst-plugins-good"), + Ok(()) => { + let decoder = ["mpg123audiodec", "avdec_mp3", "mad"] + .iter() + .find(|f| gstreamer::ElementFactory::find(f).is_some()); + match decoder { + Some(d) => r.ok(&format!("mp3 decoder ({d})")), + None => r.warn("no mp3 decoder: the clip will not play", "install gst-plugins-good (mpg123) or gst-libav"), + } + } + } + if in_path("pactl") { + r.ok("volume control via pactl"); + } else { + r.warn("pactl not found: the volume cannot be raised to 100 %", "install pipewire-pulse (or libpulse); or run with --no-max-volume"); + } + + println!("\nIdle locking"); + let hints = match backend { + Some(Backend::Wayland) => ("swayidle", "swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'"), + _ => ("xss-lock", "xss-lock -- ahfail"), + }; + if in_path(hints.0) { + r.ok(&format!("{} found; use: {}", hints.0, hints.1)); + } else { + r.info(&format!("to lock automatically: {}", hints.1)); + } + + let legacy = legacy_pam_hooks("/etc/pam.d"); + if !legacy.is_empty() { + println!("\nOld integration"); + r.info(&format!( + "the PAM hook libahfail_pam.so is still in {}; not needed with `ahfail lock`, but harmless if you keep using that locker", + legacy.join(", ") + )); + } +} + +#[cfg(target_os = "macos")] +fn macos(r: &mut Report) { + println!("macOS"); + let files = legacy_pam_hooks("/etc/pam.d"); + if files.is_empty() { + r.fail( + "the ahfail PAM line is not in the screensaver PAM file", + "add `auth optional /usr/local/lib/ahfail/libahfail_pam.so` as the first auth line of /etc/pam.d/screensaverui", + ); + } else { + r.ok(&format!("PAM hook in {}", files.join(", "))); + } + r.info("`ahfail lock` uses the system lock (pmset displaysleepnow); enable \"Require password immediately\" in System Settings"); +} diff --git a/crates/ahfail/src/locker.rs b/crates/ahfail/src/locker.rs new file mode 100644 index 0000000..7dd7835 --- /dev/null +++ b/crates/ahfail/src/locker.rs @@ -0,0 +1,343 @@ +//! The lock screen itself, shared by the Wayland and X11 backends and by the +//! `ahfail test` preview: one full-screen window per monitor with a clock and a +//! password field, and a layer on top where Nedry appears on every failure. +//! +//! Backends only decide how the windows become a lock (ext-session-lock-v1 on +//! Wayland, an input grab on X11) and how it is released again. + +use std::cell::RefCell; +use std::rc::Rc; + +use ahfail_ui::config::ModuleConfig; +use gtk::gdk_pixbuf::PixbufAnimation; +use gtk::prelude::*; + +use crate::pam; + +pub const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3"; + +/// Space kept free of sprites around the password box, in pixels. +const DEADZONE_MARGIN: i32 = 48; + +const CSS: &str = r#" +window.ahfail { background: radial-gradient(ellipse at center, #182232 0%, #0b0f17 70%); } +.ahfail .clock { color: #e6e9ef; font-size: 88px; font-weight: 300; } +.ahfail .date { color: #97a3b6; font-size: 20px; } +.ahfail .user { color: #e6e9ef; font-size: 17px; font-weight: 600; margin-top: 28px; } +.ahfail entry { + min-width: 300px; min-height: 42px; font-size: 18px; + border-radius: 21px; padding: 0 18px; + background: rgba(255,255,255,0.08); color: #e6e9ef; + border: 1px solid rgba(255,255,255,0.18); box-shadow: none; +} +.ahfail entry:focus { border-color: #38bdf8; } +.ahfail .status { color: #97a3b6; font-size: 15px; min-height: 22px; } +.ahfail .status.error { color: #f87171; font-weight: 600; } +"#; + +pub struct Options { + /// Force the volume to 100 % on the first failure (restored on unlock). + pub max_volume: bool, +} + +struct LockWindow { + window: gtk::Window, + clock: gtk::Label, + date: gtk::Label, + fixed: gtk::Fixed, + center: gtk::Box, + entry: gtk::Entry, + status: gtk::Label, + /// Whether this window shows the password field (on X11 only the primary does). + has_entry: bool, +} + +enum AuthResult { + Ok, + Failed, +} + +pub struct Locker { + windows: Vec, + animation: Option, + players: Vec, + volume: Option, + failures: u32, + busy: bool, + user: String, + opts: Options, + sender: glib::Sender, + /// Set by the backend: releases the lock. Called once, after PAM said yes. + on_unlock: Option>, + unlocked: bool, +} + +pub type Shared = Rc>; + +/// Must be called after gtk::init() and gstreamer::init(). +pub fn new(user: String, opts: Options) -> Shared { + install_css(); + let (sender, receiver) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); + let animation = unsafe { ahfail_ui::animation::load_animation() }; + if animation.is_none() { + eprintln!("[ahfail] sprites could not be loaded; locking without them"); + } + let locker = Rc::new(RefCell::new(Locker { + windows: Vec::new(), + animation, + players: Vec::new(), + volume: None, + failures: 0, + busy: false, + user, + opts, + sender, + on_unlock: None, + unlocked: false, + })); + let weak = Rc::downgrade(&locker); + receiver.attach(None, move |res| { + if let Some(l) = weak.upgrade() { + handle_result(&l, res); + } + glib::Continue(true) + }); + start_clock(&locker); + locker +} + +pub fn set_on_unlock(locker: &Shared, f: impl Fn() + 'static) { + locker.borrow_mut().on_unlock = Some(Box::new(f)); +} + +fn install_css() { + let provider = gtk::CssProvider::new(); + if provider.load_from_data(CSS.as_bytes()).is_ok() { + if let Some(screen) = gdk::Screen::default() { + gtk::StyleContext::add_provider_for_screen( + &screen, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + } +} + +fn now(fmt: &str) -> String { + glib::DateTime::now_local() + .ok() + .and_then(|d| d.format(fmt).ok()) + .map(|s| s.to_string()) + .unwrap_or_default() +} + +fn start_clock(locker: &Shared) { + let weak = Rc::downgrade(locker); + glib::timeout_add_seconds_local(1, move || { + let Some(l) = weak.upgrade() else { return glib::Continue(false) }; + let (clock, date) = (now("%H:%M"), now("%A %-d. %B")); + for w in &l.borrow().windows { + w.clock.set_text(&clock); + w.date.set_text(&date); + } + glib::Continue(true) + }); +} + +/// Build the lock window for one monitor. The backend decides how it is shown +/// (a session-lock surface, or a grabbed override-redirect window). +pub fn build_window(locker: &Shared, kind: gtk::WindowType, with_entry: bool) -> gtk::Window { + let window = gtk::Window::new(kind); + window.style_context().add_class("ahfail"); + window.set_decorated(false); + + let overlay = gtk::Overlay::new(); + let background = gtk::EventBox::new(); + overlay.add(&background); + + // Sprites live on their own layer that never takes input, so a Nedry landing + // on top of the field cannot swallow a click. + let fixed = gtk::Fixed::new(); + overlay.add_overlay(&fixed); + overlay.set_overlay_pass_through(&fixed, true); + + let center = gtk::Box::new(gtk::Orientation::Vertical, 6); + center.set_halign(gtk::Align::Center); + center.set_valign(gtk::Align::Center); + let clock = gtk::Label::new(Some(&now("%H:%M"))); + clock.style_context().add_class("clock"); + let date = gtk::Label::new(Some(&now("%A %-d. %B"))); + date.style_context().add_class("date"); + let user = gtk::Label::new(Some(&locker.borrow().user)); + user.style_context().add_class("user"); + let entry = gtk::Entry::new(); + entry.set_visibility(false); + entry.set_input_purpose(gtk::InputPurpose::Password); + entry.set_placeholder_text(Some("Password")); + // No input method on a lock screen: keys must not be routed through an + // external IM, and on Wayland the text-input IM swallowed Return before the + // entry could turn it into "activate". + entry.set_im_module(Some("gtk-im-context-simple")); + entry.set_halign(gtk::Align::Center); + let status = gtk::Label::new(None); + status.style_context().add_class("status"); + for w in [clock.upcast_ref::(), date.upcast_ref(), user.upcast_ref(), entry.upcast_ref(), status.upcast_ref()] { + center.add(w); + } + if !with_entry { + user.set_no_show_all(true); + entry.set_no_show_all(true); + status.set_no_show_all(true); + } + overlay.add_overlay(¢er); + window.add(&overlay); + + // Enter is handled here rather than through "activate", so submitting does + // not depend on how the input stack translates the key. + let weak = Rc::downgrade(locker); + entry.connect_key_press_event(move |e, ev| { + use gdk::keys::constants as key; + let k = ev.keyval(); + if k == key::Return || k == key::KP_Enter || k == key::ISO_Enter { + if let Some(l) = weak.upgrade() { + submit(&l, e); + } + return gtk::Inhibit(true); + } + if k == key::Escape { + e.set_text(""); + } + gtk::Inhibit(false) + }); + + locker.borrow_mut().windows.push(LockWindow { window: window.clone(), clock, date, fixed, center, entry, status, has_entry: with_entry }); + window +} + +fn submit(locker: &Shared, entry: >k::Entry) { + let (user, sender) = { + let mut l = locker.borrow_mut(); + if l.busy || l.unlocked { + return; + } + l.busy = true; + (l.user.clone(), l.sender.clone()) + }; + let secret = pam::Secret::new(entry.text().to_string()); + entry.set_text(""); + for w in &locker.borrow().windows { + w.entry.set_sensitive(false); + w.status.style_context().remove_class("error"); + w.status.set_text("Checking…"); + } + // PAM may take seconds (pam_faillock delays, network auth): never on the GTK thread. + std::thread::spawn(move || { + let res = match pam::authenticate(&user, secret) { + Ok(()) => AuthResult::Ok, + Err(e) => { + eprintln!("[ahfail] authentication failed: {e}"); + AuthResult::Failed + } + }; + let _ = sender.send(res); + }); +} + +fn handle_result(locker: &Shared, res: AuthResult) { + match res { + AuthResult::Ok => unlock(locker), + AuthResult::Failed => { + fail(locker); + let l = locker.borrow(); + for w in &l.windows { + w.entry.set_sensitive(true); + } + if let Some(w) = l.windows.iter().find(|w| w.has_entry) { + w.entry.grab_focus(); + } + } + } + locker.borrow_mut().busy = false; +} + +/// One wrong password: a new Nedry on every monitor, and the clip. +pub fn fail(locker: &Shared) { + let mut l = locker.borrow_mut(); + l.failures += 1; + let n = l.failures; + if n == 1 && l.opts.max_volume { + l.volume = ahfail_ui::volume::save_and_set_max(); + } + let text = if n == 1 { + "Ah ah ah, you didn't say the magic word!".to_string() + } else { + format!("Ah ah ah, you didn't say the magic word! ({n})") + }; + for w in &l.windows { + w.status.set_text(&text); + w.status.style_context().add_class("error"); + } + if let Some(anim) = l.animation.clone() { + for w in &l.windows { + let alloc = w.window.allocation(); + // Overlay children get their own GdkWindow in GTK3, so the box's + // allocation is relative to itself (always 0,0). Translate it into the + // window's coordinates, which are also the sprite layer's. + let c = w.center.allocation(); + let (cx, cy) = w.center.translate_coordinates(&w.window, 0, 0).unwrap_or((c.x(), c.y())); + let deadzone = gdk::Rectangle::new( + cx - DEADZONE_MARGIN, + cy - DEADZONE_MARGIN, + c.width() + 2 * DEADZONE_MARGIN, + c.height() + 2 * DEADZONE_MARGIN, + ); + let cfg = ModuleConfig { deadzone: Some(deadzone) }; + ahfail_ui::display::place_sprite(&w.fixed, &anim, alloc.width(), alloc.height(), &cfg); + } + } + let player = ahfail_ui::audio::create_player(AUDIO_URI); + player.play(); + l.players.push(player); +} + +fn unlock(locker: &Shared) { + let cb = { + let mut l = locker.borrow_mut(); + if l.unlocked { + return; + } + l.unlocked = true; + for p in l.players.drain(..) { + p.stop(); + } + if let Some(v) = l.volume.take() { + ahfail_ui::volume::restore(v); + } + l.on_unlock.take() + }; + if let Some(cb) = cb { + cb(); + } +} + +/// Restore the volume if we are leaving without a successful unlock (e.g. the +/// compositor ended the lock, or the preview window was closed). +pub fn cleanup(locker: &Shared) { + let mut l = locker.borrow_mut(); + for p in l.players.drain(..) { + p.stop(); + } + if let Some(v) = l.volume.take() { + ahfail_ui::volume::restore(v); + } +} + +pub fn focus_entry(locker: &Shared) { + if let Some(w) = locker.borrow().windows.iter().find(|w| w.has_entry) { + w.entry.grab_focus(); + } +} + +pub fn windows(locker: &Shared) -> Vec { + locker.borrow().windows.iter().map(|w| w.window.clone()).collect() +} diff --git a/crates/ahfail/src/main.rs b/crates/ahfail/src/main.rs new file mode 100644 index 0000000..055a1ee --- /dev/null +++ b/crates/ahfail/src/main.rs @@ -0,0 +1,174 @@ +//! `ahfail`: one command in front of every platform. +//! +//! ahfail lock the screen (same as `ahfail lock`) +//! ahfail test show the effect once, without locking +//! ahfail doctor explain what works on this machine and what is missing +//! +//! Linux: ahfail is itself the locker (Wayland: ext-session-lock-v1, X11: input +//! grab) and authenticates through its own PAM service, /etc/pam.d/ahfail. +//! macOS: the lock screen belongs to the system, so `lock` asks the system to +//! lock and the effect comes from the PAM module (see `ahfail doctor`). + +mod doctor; +#[cfg(target_os = "linux")] +mod locker; +#[cfg(target_os = "linux")] +mod pam; +#[cfg(target_os = "linux")] +mod preview; +#[cfg(target_os = "linux")] +mod wayland; +#[cfg(target_os = "linux")] +mod x11; + +use std::process::exit; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +const HELP: &str = "\ +ahfail - ah ah ah, you didn't say the magic word + +Usage: + ahfail [lock] [--no-max-volume] lock the screen + ahfail test [--max-volume] play the effect once, without locking + ahfail doctor check this machine and explain what is missing + ahfail --version + +Linux picks the backend from the session: Wayland (ext-session-lock-v1, as used by +sway, Hyprland, niri, river and KDE) or X11. GNOME does not allow third-party +lockers on Wayland. + +Idle locking: + swayidle -w timeout 300 'ahfail' before-sleep 'ahfail' + xss-lock -- ahfail +"; + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Backend { + Wayland, + X11, +} + +/// Which display server this session runs on, from the environment alone. +pub fn detect_backend() -> Option { + let set = |k: &str| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false); + if set("WAYLAND_DISPLAY") { + Some(Backend::Wayland) + } else if set("DISPLAY") { + Some(Backend::X11) + } else { + None + } +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let flag = |f: &str| args.iter().any(|a| a == f); + let cmd = args.iter().find(|a| !a.starts_with('-')).map(String::as_str).unwrap_or("lock"); + + if flag("--version") || flag("-V") { + println!("ahfail {VERSION}"); + return; + } + if flag("--help") || flag("-h") || cmd == "help" { + print!("{HELP}"); + return; + } + let code = match cmd { + "lock" => lock(!flag("--no-max-volume")), + "test" => test(flag("--max-volume")), + "doctor" => doctor::run(), + other => { + eprintln!("ahfail: unknown command '{other}'\n\n{HELP}"); + 2 + } + }; + exit(code); +} + +#[cfg(target_os = "macos")] +fn lock(_max_volume: bool) -> i32 { + // The macOS lock screen is the system's; the ahfail PAM module hooks it. + match std::process::Command::new("pmset").arg("displaysleepnow").status() { + Ok(s) if s.success() => 0, + _ => { + eprintln!("ahfail: could not lock the screen (pmset displaysleepnow failed)"); + 1 + } + } +} + +#[cfg(target_os = "macos")] +fn test(_max_volume: bool) -> i32 { + eprintln!("ahfail: `test` is not available on macOS yet; lock the screen and type a wrong password"); + 1 +} + +#[cfg(target_os = "linux")] +fn prepare(backend: Backend) -> Result<(), String> { + if unsafe { libc::getuid() } == 0 { + return Err("refusing to run as root: lock the screen as the logged-in user".into()); + } + // Under XWayland both variables are set; pin GTK to the backend we chose so + // it does not quietly pick the other one. + std::env::set_var("GDK_BACKEND", match backend { Backend::Wayland => "wayland", Backend::X11 => "x11" }); + gtk::init().map_err(|e| format!("could not open the display: {e}"))?; + gstreamer::init().map_err(|e| format!("could not start GStreamer: {e}"))?; + Ok(()) +} + +/// Only one ahfail may lock at a time; a second call (e.g. swayidle firing again +/// while locked) exits quietly. The lock file dies with the process. +#[cfg(target_os = "linux")] +fn single_instance() -> Option { + use std::os::unix::io::AsRawFd; + let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + let path = std::path::Path::new(&dir).join(format!("ahfail-{}.lock", unsafe { libc::getuid() })); + let f = std::fs::OpenOptions::new().create(true).write(true).open(path).ok()?; + if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return None; + } + Some(f) +} + +#[cfg(target_os = "linux")] +fn lock(max_volume: bool) -> i32 { + let Some(backend) = detect_backend() else { + eprintln!("ahfail: no graphical session found (neither WAYLAND_DISPLAY nor DISPLAY is set)"); + return 1; + }; + let Some(_instance) = single_instance() else { + return 0; // already locked + }; + if let Err(e) = prepare(backend) { + eprintln!("ahfail: {e}"); + return 1; + } + if backend == Backend::Wayland && !wayland::supported() { + eprintln!("ahfail: this compositor does not support ext-session-lock-v1, so no third-party locker can lock it (GNOME is one of these). Run `ahfail doctor`."); + return 1; + } + let Some(user) = pam::current_user() else { + eprintln!("ahfail: could not determine the current user"); + return 1; + }; + let l = locker::new(user, locker::Options { max_volume }); + match backend { + Backend::Wayland => wayland::run(l), + Backend::X11 => x11::run(l), + } +} + +#[cfg(target_os = "linux")] +fn test(max_volume: bool) -> i32 { + let Some(backend) = detect_backend() else { + eprintln!("ahfail: no graphical session found"); + return 1; + }; + if let Err(e) = prepare(backend) { + eprintln!("ahfail: {e}"); + return 1; + } + let user = pam::current_user().unwrap_or_default(); + preview::run(locker::new(user, locker::Options { max_volume })) +} diff --git a/crates/ahfail/src/pam.rs b/crates/ahfail/src/pam.rs new file mode 100644 index 0000000..664cbb1 --- /dev/null +++ b/crates/ahfail/src/pam.rs @@ -0,0 +1,151 @@ +//! Minimal PAM client: authenticate the current user against the `ahfail` +//! service (/etc/pam.d/ahfail, shipped by the package). +//! +//! This is the application side of PAM, the opposite of crates/ahfail-pam, +//! which is a module that sits inside someone else's stack. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_void}; +use std::ptr; + +const PAM_SUCCESS: c_int = 0; +const PAM_PROMPT_ECHO_OFF: c_int = 1; +const PAM_PROMPT_ECHO_ON: c_int = 2; +const PAM_REFRESH_CRED: c_int = 0x0010; +const PAM_CONV_ERR: c_int = 19; +const PAM_BUF_ERR: c_int = 5; + +#[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: extern "C" fn(c_int, *mut *const PamMessage, *mut *mut PamResponse, *mut c_void) -> c_int, + appdata_ptr: *mut c_void, +} + +extern "C" { + fn pam_start( + service: *const c_char, + user: *const c_char, + conv: *const PamConv, + pamh: *mut *mut c_void, + ) -> c_int; + fn pam_authenticate(pamh: *mut c_void, flags: c_int) -> c_int; + fn pam_setcred(pamh: *mut c_void, flags: c_int) -> c_int; + fn pam_end(pamh: *mut c_void, status: c_int) -> c_int; + fn pam_strerror(pamh: *mut c_void, errnum: c_int) -> *const c_char; +} + +/// The password, overwritten with zeros when dropped so it does not linger in +/// the heap after the attempt. +pub struct Secret(Vec); + +impl Secret { + pub fn new(s: String) -> Self { + Secret(s.into_bytes()) + } +} + +impl Drop for Secret { + fn drop(&mut self) { + for b in self.0.iter_mut() { + unsafe { ptr::write_volatile(b, 0) }; + } + } +} + +extern "C" fn conversation( + num_msg: c_int, + msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata: *mut c_void, +) -> c_int { + if num_msg <= 0 || msg.is_null() || resp.is_null() || appdata.is_null() { + return PAM_CONV_ERR; + } + let secret = unsafe { &*(appdata as *const Secret) }; + // PAM frees the array and each resp string with free(), so both must come + // from the C allocator. + let replies = unsafe { + libc::calloc(num_msg as usize, std::mem::size_of::()) as *mut PamResponse + }; + if replies.is_null() { + return PAM_BUF_ERR; + } + for i in 0..num_msg as isize { + // Linux-PAM passes an array of pointers; index it that way. + let m = unsafe { *msg.offset(i) }; + if m.is_null() { + continue; + } + let style = unsafe { (*m).msg_style }; + if style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON { + let len = secret.0.len(); + let p = unsafe { libc::malloc(len + 1) as *mut u8 }; + if p.is_null() { + unsafe { libc::free(replies as *mut c_void) }; + return PAM_BUF_ERR; + } + unsafe { + ptr::copy_nonoverlapping(secret.0.as_ptr(), p, len); + *p.add(len) = 0; + (*replies.offset(i)).resp = p as *mut c_char; + } + } + // PAM_ERROR_MSG / PAM_TEXT_INFO need no answer: resp stays NULL. + } + unsafe { *resp = replies }; + PAM_SUCCESS +} + +/// Blocking: run it off the GTK thread. Ok(()) only on PAM_SUCCESS. +pub fn authenticate(user: &str, password: Secret) -> Result<(), String> { + let service = CString::new("ahfail").unwrap(); + let user_c = CString::new(user).map_err(|_| "invalid user name".to_string())?; + let conv = PamConv { + conv: conversation, + appdata_ptr: &password as *const Secret as *mut c_void, + }; + let mut handle: *mut c_void = ptr::null_mut(); + let rc = unsafe { pam_start(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle) }; + if rc != PAM_SUCCESS || handle.is_null() { + return Err(format!("pam_start failed ({rc})")); + } + let rc = unsafe { pam_authenticate(handle, 0) }; + let result = if rc == PAM_SUCCESS { + // Refresh credentials (e.g. Kerberos tickets), as i3lock does. A failure + // here is not a reason to keep the screen locked. + unsafe { pam_setcred(handle, PAM_REFRESH_CRED) }; + Ok(()) + } else { + let msg = unsafe { pam_strerror(handle, rc) }; + Err(if msg.is_null() { + format!("PAM error {rc}") + } else { + unsafe { CStr::from_ptr(msg) }.to_string_lossy().into_owned() + }) + }; + unsafe { pam_end(handle, rc) }; + drop(password); + result +} + +/// Login name of the user running ahfail. +pub fn current_user() -> Option { + let pw = unsafe { libc::getpwuid(libc::getuid()) }; + if pw.is_null() { + return None; + } + let name = unsafe { CStr::from_ptr((*pw).pw_name) }; + Some(name.to_string_lossy().into_owned()) +} diff --git a/crates/ahfail/src/preview.rs b/crates/ahfail/src/preview.rs new file mode 100644 index 0000000..94ae314 --- /dev/null +++ b/crates/ahfail/src/preview.rs @@ -0,0 +1,41 @@ +//! `ahfail test`: the lock screen in an ordinary window, with one failure +//! triggered straight away. Nothing is locked and no password is checked, so it +//! is safe to run to see whether sprites and sound work. + +use std::time::Duration; + +use gtk::prelude::*; + +use crate::locker::{self, Shared}; + +const SHOW_FOR: Duration = Duration::from_secs(8); + +pub fn run(locker: Shared) -> i32 { + let window = locker::build_window(&locker, gtk::WindowType::Toplevel, false); + window.set_title("ahfail test"); + window.set_decorated(true); + window.set_default_size(960, 600); + window.set_position(gtk::WindowPosition::Center); + window.connect_delete_event(|_, _| { + gtk::main_quit(); + gtk::Inhibit(false) + }); + window.connect_key_press_event(|_, ev| { + if ev.keyval() == gdk::keys::constants::Escape { + gtk::main_quit(); + } + gtk::Inhibit(false) + }); + window.show_all(); + + // Wait for the first layout so the sprite lands inside the window. + { + let locker = locker.clone(); + glib::timeout_add_local_once(Duration::from_millis(400), move || locker::fail(&locker)); + } + glib::timeout_add_local_once(SHOW_FOR, gtk::main_quit); + + gtk::main(); + locker::cleanup(&locker); + 0 +} diff --git a/crates/ahfail/src/wayland.rs b/crates/ahfail/src/wayland.rs new file mode 100644 index 0000000..84ac53c --- /dev/null +++ b/crates/ahfail/src/wayland.rs @@ -0,0 +1,101 @@ +//! Wayland backend: ext-session-lock-v1 through gtk-session-lock. +//! +//! The protocol is what makes this safe: once the compositor has confirmed the +//! lock, only our lock surfaces are shown, and if ahfail crashes or is killed the +//! session stays locked (the compositor shows a fallback) rather than opening up. + +use std::cell::Cell; +use std::os::raw::c_void; +use std::rc::Rc; + +use glib::translate::{FromGlibPtrNone, ToGlibPtr}; +use gtk::prelude::*; + +use crate::locker::{self, Shared}; + +extern "C" { + fn gtk_session_lock_is_supported() -> glib::ffi::gboolean; + fn gtk_session_lock_prepare_lock() -> *mut c_void; + fn gtk_session_lock_lock_lock(lock: *mut c_void); + fn gtk_session_lock_lock_new_surface(lock: *mut c_void, window: *mut gtk::ffi::GtkWindow, monitor: *mut gdk::ffi::GdkMonitor); + fn gtk_session_lock_lock_unlock_and_destroy(lock: *mut c_void); +} + +/// True when the compositor implements ext-session-lock-v1. Needs gtk::init(). +pub fn supported() -> bool { + unsafe { gtk_session_lock_is_supported() != 0 } +} + +pub fn run(locker: Shared) -> i32 { + let lock = unsafe { gtk_session_lock_prepare_lock() }; + if lock.is_null() { + eprintln!("ahfail: could not create a session lock"); + return 1; + } + // A borrowed wrapper, only for connecting signals. The library owns the + // object and frees it in unlock_and_destroy, so this wrapper is leaked on + // purpose rather than dropped after that. + let obj: glib::Object = unsafe { glib::Object::from_glib_none(lock as *mut glib::gobject_ffi::GObject) }; + + let locked = Rc::new(Cell::new(false)); + let exit_code = Rc::new(Cell::new(0)); + { + let locked = locked.clone(); + obj.connect_local("locked", false, move |_| { + locked.set(true); + None + }); + } + { + let locked = locked.clone(); + let exit_code = exit_code.clone(); + let locker = locker.clone(); + obj.connect_local("finished", false, move |_| { + // Either the compositor refused the lock (another locker is running), + // or it ended the lock on its own. Both mean we stop here. + if !locked.get() { + eprintln!("ahfail: the compositor refused the lock (is another locker running?)"); + exit_code.set(1); + } + locker::cleanup(&locker); + gtk::main_quit(); + None + }); + } + + unsafe { gtk_session_lock_lock_lock(lock) }; + + let display = gdk::Display::default().expect("no display"); + let add_monitor = { + let locker = locker.clone(); + move |monitor: &gdk::Monitor, primary: bool| { + let window = locker::build_window(&locker, gtk::WindowType::Toplevel, true); + let wptr: *mut gtk::ffi::GtkWindow = window.to_glib_none().0; + let mptr: *mut gdk::ffi::GdkMonitor = monitor.to_glib_none().0; + unsafe { gtk_session_lock_lock_new_surface(lock, wptr, mptr) }; + window.show_all(); + if primary { + locker::focus_entry(&locker); + } + } + }; + let primary = display.primary_monitor(); + for i in 0..display.n_monitors() { + if let Some(m) = display.monitor(i) { + let is_primary = primary.as_ref().map(|p| p == &m).unwrap_or(i == 0); + add_monitor(&m, is_primary); + } + } + // A monitor plugged in while locked must get a lock surface too, otherwise + // the compositor shows its own blank fallback on it. + display.connect_monitor_added(move |_, m| add_monitor(m, false)); + + locker::set_on_unlock(&locker, move || { + unsafe { gtk_session_lock_lock_unlock_and_destroy(lock) }; + gtk::main_quit(); + }); + + gtk::main(); + std::mem::forget(obj); + exit_code.get() +} diff --git a/crates/ahfail/src/x11.rs b/crates/ahfail/src/x11.rs new file mode 100644 index 0000000..a4c1952 --- /dev/null +++ b/crates/ahfail/src/x11.rs @@ -0,0 +1,106 @@ +//! X11 backend: override-redirect windows covering every monitor, plus a +//! keyboard and pointer grab. This is the i3lock model, including its limit: +//! on X11 nothing keeps the screen locked if the locker process dies. +//! +//! The grab is the whole lock. If it cannot be taken (a menu or another program +//! holds it), ahfail refuses to "lock" rather than show a lock screen that +//! keystrokes pass straight through. + +use std::time::Duration; + +use gtk::prelude::*; + +use crate::locker::{self, Shared}; + +const GRAB_ATTEMPTS: u32 = 100; // ~2 s, the same budget i3lock gives up after +const GRAB_RETRY: Duration = Duration::from_millis(20); + +fn pump() { + while gtk::events_pending() { + gtk::main_iteration(); + } +} + +fn grab(window: >k::Window) -> Result { + let gdk_window = window.window().ok_or("lock window is not realised")?; + let display = gdk_window.display(); + let seat = display.default_seat().ok_or("no input seat")?; + let mut last = gdk::GrabStatus::Success; + for _ in 0..GRAB_ATTEMPTS { + last = seat.grab(&gdk_window, gdk::SeatCapabilities::ALL, false, None, None, None); + if last == gdk::GrabStatus::Success { + return Ok(seat); + } + std::thread::sleep(GRAB_RETRY); + pump(); + } + Err(format!("could not grab keyboard and pointer ({last:?}); something else is holding them")) +} + +pub fn run(locker: Shared) -> i32 { + let display = gdk::Display::default().expect("no display"); + let primary = display.primary_monitor().or_else(|| display.monitor(0)); + let mut main_window = None; + + for i in 0..display.n_monitors() { + let Some(m) = display.monitor(i) else { continue }; + let is_primary = primary.as_ref().map(|p| p == &m).unwrap_or(i == 0); + // Only the primary monitor gets a password field: with the grab, every + // key goes to the grabbing window anyway. + let window = locker::build_window(&locker, gtk::WindowType::Popup, is_primary); + let g = m.geometry(); + window.move_(g.x(), g.y()); + window.set_default_size(g.width(), g.height()); + window.resize(g.width(), g.height()); + window.set_keep_above(true); + window.show_all(); + if is_primary { + main_window = Some(window); + } + } + let Some(main_window) = main_window else { + eprintln!("ahfail: no monitor found"); + return 1; + }; + pump(); + + let seat = match grab(&main_window) { + Ok(s) => s, + Err(e) => { + eprintln!("ahfail: refusing to lock: {e}"); + for w in locker::windows(&locker) { + w.close(); + } + return 1; + } + }; + locker::focus_entry(&locker); + + // Notifications and other override-redirect windows may map above us later; + // keep our windows on top. + { + let locker = locker.clone(); + glib::timeout_add_local(Duration::from_millis(500), move || { + for w in locker::windows(&locker) { + if let Some(gw) = w.window() { + gw.raise(); + } + } + glib::Continue(true) + }); + } + + locker::set_on_unlock(&locker, { + let locker = locker.clone(); + move || { + seat.ungrab(); + for w in locker::windows(&locker) { + w.close(); + } + gtk::main_quit(); + } + }); + + gtk::main(); + 0 +} diff --git a/data/pam.d/ahfail b/data/pam.d/ahfail new file mode 100644 index 0000000..41aefef --- /dev/null +++ b/data/pam.d/ahfail @@ -0,0 +1,5 @@ +#%PAM-1.0 +# PAM service for the ahfail screen locker. Installed by the package as its own +# file, the same way swaylock and gtklock ship theirs, so no other package's +# PAM configuration is ever edited. +auth include login