sessiond: session authority daemon — lock-before-shell, lock-past-shell-death
ext-session-lock client that locks at session start, hands the lock to the shell over a runtime socket without an unlocked instant (abandon + restore), and retakes it the moment the shell heartbeat drops. Spartan PIN fallback surface, PAM via hand-rolled libpam FFI, machined-style sync server. Shell side: SessiondBridge singleton + LockScreen initIfReady routes the startup lock decision through the handshake.
This commit is contained in:
parent
b18fa331e0
commit
856c6e5576
13 changed files with 1817 additions and 6 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -9137,6 +9137,8 @@ dependencies = [
|
|||
"unicode-width 0.1.14",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
"zvariant",
|
||||
]
|
||||
|
|
|
|||
13
Cargo.toml
13
Cargo.toml
|
|
@ -156,6 +156,12 @@ hkdf = { version = "0.12", optional = true }
|
|||
num-bigint = { version = "0.4", optional = true }
|
||||
num-traits = { version = "0.2", optional = true }
|
||||
|
||||
# Session authority daemon (souveraine-sessiond) — ext-session-lock client.
|
||||
# Pure-Rust wayland stack; PAM is hand-rolled FFI against libpam (no new dep,
|
||||
# same reasoning as machined: fewer deps = fewer on-device build failures).
|
||||
wayland-client = { version = "0.31", optional = true }
|
||||
wayland-protocols = { version = "0.32", optional = true, features = ["client", "staging"] }
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -187,6 +193,11 @@ required-features = ["secrets"]
|
|||
name = "souveraine-machined"
|
||||
path = "src/bin/souveraine-machined.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "souveraine-sessiond"
|
||||
path = "src/bin/souveraine-sessiond.rs"
|
||||
required-features = ["sessiond"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
|
|
@ -207,3 +218,5 @@ matrix = ["dep:matrix-sdk"]
|
|||
gui = ["tuie/gui"]
|
||||
# Secret Service daemon (org.freedesktop.secrets), backed by the existing SeedId.
|
||||
secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits"]
|
||||
# Session authority daemon — holds ext-session-lock before/under the shell.
|
||||
sessiond = ["dep:wayland-client", "dep:wayland-protocols"]
|
||||
|
|
|
|||
65
src/bin/souveraine-sessiond.rs
Normal file
65
src/bin/souveraine-sessiond.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! souveraine-sessiond — session authority: lock-before-shell, lock-past-
|
||||
//! shell-death.
|
||||
//!
|
||||
//! Runs on the user tier as the seat owner. Requires nothing from the seed
|
||||
//! (it signs nothing yet — capability-token verification arrives with the
|
||||
//! services review); it requires only a Wayland display that speaks
|
||||
//! ext-session-lock-v1 and a PAM stack for its service name.
|
||||
//!
|
||||
//! Startup default is fail-closed: take the session lock immediately. The
|
||||
//! shell later takes over via the handoff protocol (see
|
||||
//! `sessiond/protocol.rs`). `--no-initial-lock` exists for bring-up and
|
||||
//! laptop experiments only.
|
||||
|
||||
// The sessiond module tree lives in the main crate source but is compiled
|
||||
// standalone into this bin, machined-style. Mounting it as `sessiond` keeps
|
||||
// the modules' own `crate::sessiond::...` paths valid.
|
||||
#[path = "../sessiond/mod.rs"]
|
||||
pub(crate) mod sessiond;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let mut pam_service = sessiond::protocol::DEFAULT_PAM_SERVICE.to_string();
|
||||
let mut initial_lock = true;
|
||||
let mut socket: Option<PathBuf> = None;
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--pam-service" => {
|
||||
pam_service = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("--pam-service requires a name"))?;
|
||||
}
|
||||
"--socket" => {
|
||||
socket = Some(PathBuf::from(args.next().ok_or_else(|| {
|
||||
anyhow::anyhow!("--socket requires a path")
|
||||
})?));
|
||||
}
|
||||
"--no-initial-lock" => initial_lock = false,
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"souveraine-sessiond [--pam-service {}] [--socket $XDG_RUNTIME_DIR/{}] [--no-initial-lock]",
|
||||
sessiond::protocol::DEFAULT_PAM_SERVICE,
|
||||
sessiond::protocol::SOCKET_RELPATH,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => anyhow::bail!("unknown argument: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
let socket = match socket {
|
||||
Some(p) => p,
|
||||
None => sessiond::server::socket_path()?,
|
||||
};
|
||||
sessiond::server::run(pam_service, initial_lock, &socket)
|
||||
}
|
||||
169
src/sessiond/auth.rs
Normal file
169
src/sessiond/auth.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Minimal PAM client for the fallback unlock surface.
|
||||
//!
|
||||
//! Hand-rolled FFI against libpam rather than a wrapper crate: the daemon
|
||||
//! asks exactly one question ("does this PIN authenticate this user") and a
|
||||
//! dependency is a bigger liability than 100 lines of well-understood
|
||||
//! unsafe. pam_unix answers via the setuid unix_chkpwd helper, so this works
|
||||
//! from an unprivileged session process — the same mechanism hyprlock and
|
||||
//! the shell's PamContext rely on.
|
||||
//!
|
||||
//! Doctrine: auth goes through PAM, always (SESSION-AUTHORITY-DOCTRINE §4).
|
||||
//! This module never inspects credentials itself; it carries the PIN to the
|
||||
//! stack configured for the service and reports the verdict.
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
||||
|
||||
const PAM_SUCCESS: c_int = 0;
|
||||
const PAM_CONV_ERR: c_int = 19;
|
||||
|
||||
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;
|
||||
|
||||
#[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: unsafe extern "C" fn(
|
||||
num_msg: c_int,
|
||||
msg: *mut *const PamMessage,
|
||||
resp: *mut *mut PamResponse,
|
||||
appdata_ptr: *mut c_void,
|
||||
) -> c_int,
|
||||
appdata_ptr: *mut c_void,
|
||||
}
|
||||
|
||||
#[link(name = "pam")]
|
||||
extern "C" {
|
||||
fn pam_start(
|
||||
service: *const c_char,
|
||||
user: *const c_char,
|
||||
conv: *const PamConv,
|
||||
handle: *mut *mut c_void,
|
||||
) -> c_int;
|
||||
fn pam_authenticate(handle: *mut c_void, flags: c_int) -> c_int;
|
||||
fn pam_end(handle: *mut c_void, status: c_int) -> c_int;
|
||||
fn pam_strerror(handle: *mut c_void, errnum: c_int) -> *const c_char;
|
||||
}
|
||||
|
||||
/// The conversation only ever answers prompts with the PIN. Info/error
|
||||
/// messages are acknowledged and dropped — the fallback surface has nowhere
|
||||
/// to render a module's prose and must not block on it.
|
||||
unsafe extern "C" fn conv_fn(
|
||||
num_msg: c_int,
|
||||
msg: *mut *const PamMessage,
|
||||
resp: *mut *mut PamResponse,
|
||||
appdata_ptr: *mut c_void,
|
||||
) -> c_int {
|
||||
if num_msg <= 0 || msg.is_null() || resp.is_null() || appdata_ptr.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let pin = &*(appdata_ptr as *const CString);
|
||||
|
||||
// Linux-PAM passes an array of message pointers. Responses are freed by
|
||||
// the module with free(), so they must come from malloc/strdup.
|
||||
let responses =
|
||||
libc::calloc(num_msg as usize, std::mem::size_of::<PamResponse>()) as *mut PamResponse;
|
||||
if responses.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
|
||||
// Linux-PAM convention: *msg points at a contiguous array of messages
|
||||
// (modules there prompt one message at a time anyway).
|
||||
for i in 0..num_msg as usize {
|
||||
let m = (*msg).add(i);
|
||||
if m.is_null() {
|
||||
libc::free(responses as *mut c_void);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let slot = &mut *responses.add(i);
|
||||
match (*m).msg_style {
|
||||
PAM_PROMPT_ECHO_OFF | PAM_PROMPT_ECHO_ON => {
|
||||
slot.resp = libc::strdup(pin.as_ptr());
|
||||
if slot.resp.is_null() {
|
||||
libc::free(responses as *mut c_void);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
}
|
||||
PAM_ERROR_MSG | PAM_TEXT_INFO => {
|
||||
slot.resp = std::ptr::null_mut();
|
||||
}
|
||||
_ => {
|
||||
libc::free(responses as *mut c_void);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
}
|
||||
slot.resp_retcode = 0;
|
||||
}
|
||||
|
||||
*resp = responses;
|
||||
PAM_SUCCESS
|
||||
}
|
||||
|
||||
/// Run one authenticate conversation. Blocking (pam_unix sleeps ~2s on a
|
||||
/// wrong answer); callers own threading. Returns Ok(()) on success and the
|
||||
/// stack's complaint otherwise.
|
||||
pub fn authenticate(service: &str, user: &str, pin: &str) -> Result<(), String> {
|
||||
let service = CString::new(service).map_err(|_| "service contains NUL".to_string())?;
|
||||
let user_c = CString::new(user).map_err(|_| "user contains NUL".to_string())?;
|
||||
let pin = CString::new(pin).map_err(|_| "pin contains NUL".to_string())?;
|
||||
|
||||
// Boxed so the conversation callback has a stable pointer for the whole
|
||||
// transaction; dropped after pam_end.
|
||||
let pin = Box::new(pin);
|
||||
let conv = PamConv {
|
||||
conv: conv_fn,
|
||||
appdata_ptr: &*pin as *const CString as *mut c_void,
|
||||
};
|
||||
|
||||
let mut handle: *mut c_void = std::ptr::null_mut();
|
||||
// SAFETY: all pointers live across the calls; handle is only used
|
||||
// between a successful pam_start and pam_end.
|
||||
unsafe {
|
||||
let rc = pam_start(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle);
|
||||
if rc != PAM_SUCCESS {
|
||||
return Err(format!("pam_start failed (code {rc})"));
|
||||
}
|
||||
let rc = pam_authenticate(handle, 0);
|
||||
let verdict = if rc == PAM_SUCCESS {
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = pam_strerror(handle, rc);
|
||||
Err(if msg.is_null() {
|
||||
format!("authentication failed (code {rc})")
|
||||
} else {
|
||||
CStr::from_ptr(msg).to_string_lossy().into_owned()
|
||||
})
|
||||
};
|
||||
pam_end(handle, rc);
|
||||
verdict
|
||||
}
|
||||
}
|
||||
|
||||
/// The user this session belongs to, from the process's own identity —
|
||||
/// sessiond authenticates the seat owner, never a name a caller supplies.
|
||||
pub fn session_user() -> Result<String, String> {
|
||||
// SAFETY: getpwuid_r with a stack buffer; result pointer checked.
|
||||
unsafe {
|
||||
let uid = libc::getuid();
|
||||
let mut pwd: libc::passwd = std::mem::zeroed();
|
||||
let mut buf = [0i8; 1024];
|
||||
let mut result: *mut libc::passwd = std::ptr::null_mut();
|
||||
let rc = libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result);
|
||||
if rc != 0 || result.is_null() {
|
||||
return Err(format!("getpwuid_r failed for uid {uid}"));
|
||||
}
|
||||
Ok(CStr::from_ptr(pwd.pw_name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
253
src/sessiond/draw.rs
Normal file
253
src/sessiond/draw.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
//! Software renderer for the fallback lock surface.
|
||||
//!
|
||||
//! Deliberately spartan: a dark field, a row of PIN dots, a 3x4 keypad,
|
||||
//! digits from a 5x7 bitmap font. This surface exists for the seconds
|
||||
//! before the shell takes over at boot and for the case where the shell is
|
||||
//! a corpse — it competes on availability, not looks. The rich lockscreen
|
||||
//! (swipe, widgets) stays in the shell.
|
||||
//!
|
||||
//! All drawing targets a raw ARGB8888 buffer (little-endian: B,G,R,A).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub const BG: u32 = 0xFF0E0E12;
|
||||
pub const KEY_FILL: u32 = 0xFF1D1D26;
|
||||
pub const KEY_PRESSED: u32 = 0xFF3C3C4C;
|
||||
pub const FG: u32 = 0xFFE6E6F0;
|
||||
pub const DOT_EMPTY: u32 = 0xFF44444F;
|
||||
pub const FAIL: u32 = 0xFFC63A4A;
|
||||
pub const BUSY: u32 = 0xFF8A8A99;
|
||||
|
||||
/// Keypad cells in layout order: 1-9, backspace, 0, enter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
Digit(u8),
|
||||
Backspace,
|
||||
Enter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn contains(&self, px: f64, py: f64) -> bool {
|
||||
px >= self.x as f64
|
||||
&& py >= self.y as f64
|
||||
&& px < (self.x + self.w) as f64
|
||||
&& py < (self.y + self.h) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// 5x7 glyphs, one bit per pixel, top row first.
|
||||
fn glyph(key: Key) -> [u8; 7] {
|
||||
match key {
|
||||
Key::Digit(0) => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
|
||||
Key::Digit(1) => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
|
||||
Key::Digit(2) => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
|
||||
Key::Digit(3) => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
|
||||
Key::Digit(4) => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
|
||||
Key::Digit(5) => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
|
||||
Key::Digit(6) => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
|
||||
Key::Digit(7) => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
|
||||
Key::Digit(8) => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
|
||||
Key::Digit(9) => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
|
||||
Key::Digit(_) => [0; 7],
|
||||
// "<" — take one off
|
||||
Key::Backspace => [0b00010, 0b00100, 0b01000, 0b10000, 0b01000, 0b00100, 0b00010],
|
||||
// check mark — submit
|
||||
Key::Enter => [0b00000, 0b00001, 0b00010, 0b00100, 0b10100, 0b01000, 0b00000],
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual feedback state, decided by the caller's state machine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mood {
|
||||
Entering,
|
||||
Checking,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct Scene {
|
||||
pub pin_len: usize,
|
||||
pub mood: Mood,
|
||||
pub pressed: Option<Key>,
|
||||
}
|
||||
|
||||
/// The keypad geometry for a surface of w x h. Pure function of size so
|
||||
/// input hit-testing and drawing can never disagree.
|
||||
pub fn keypad_layout(w: i32, h: i32) -> Vec<(Rect, Key)> {
|
||||
let keys = [
|
||||
Key::Digit(1),
|
||||
Key::Digit(2),
|
||||
Key::Digit(3),
|
||||
Key::Digit(4),
|
||||
Key::Digit(5),
|
||||
Key::Digit(6),
|
||||
Key::Digit(7),
|
||||
Key::Digit(8),
|
||||
Key::Digit(9),
|
||||
Key::Backspace,
|
||||
Key::Digit(0),
|
||||
Key::Enter,
|
||||
];
|
||||
// Grid occupies the lower ~55% of the surface, centered, capped width.
|
||||
let grid_w = (w * 8 / 10).min(520);
|
||||
let grid_h = h * 55 / 100;
|
||||
let x0 = (w - grid_w) / 2;
|
||||
let y0 = h - grid_h - h / 20;
|
||||
let cell_w = grid_w / 3;
|
||||
let cell_h = grid_h / 4;
|
||||
let pad = (cell_w / 10).max(4);
|
||||
|
||||
keys.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| {
|
||||
let col = (i % 3) as i32;
|
||||
let row = (i / 3) as i32;
|
||||
(
|
||||
Rect {
|
||||
x: x0 + col * cell_w + pad,
|
||||
y: y0 + row * cell_h + pad,
|
||||
w: cell_w - 2 * pad,
|
||||
h: cell_h - 2 * pad,
|
||||
},
|
||||
k,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn hit_test(w: i32, h: i32, px: f64, py: f64) -> Option<Key> {
|
||||
keypad_layout(w, h)
|
||||
.into_iter()
|
||||
.find(|(r, _)| r.contains(px, py))
|
||||
.map(|(_, k)| k)
|
||||
}
|
||||
|
||||
fn fill_rect(buf: &mut [u32], w: i32, h: i32, r: Rect, color: u32) {
|
||||
let x0 = r.x.max(0);
|
||||
let y0 = r.y.max(0);
|
||||
let x1 = (r.x + r.w).min(w);
|
||||
let y1 = (r.y + r.h).min(h);
|
||||
for y in y0..y1 {
|
||||
let row = (y * w) as usize;
|
||||
for x in x0..x1 {
|
||||
buf[row + x as usize] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_circle(buf: &mut [u32], w: i32, h: i32, cx: i32, cy: i32, radius: i32, color: u32) {
|
||||
let r2 = radius * radius;
|
||||
for y in (cy - radius).max(0)..(cy + radius + 1).min(h) {
|
||||
for x in (cx - radius).max(0)..(cx + radius + 1).min(w) {
|
||||
let dx = x - cx;
|
||||
let dy = y - cy;
|
||||
if dx * dx + dy * dy <= r2 {
|
||||
buf[(y * w + x) as usize] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_glyph(buf: &mut [u32], w: i32, h: i32, key: Key, cell: Rect, color: u32) {
|
||||
let g = glyph(key);
|
||||
let scale = (cell.h / 12).max(2);
|
||||
let gw = 5 * scale;
|
||||
let gh = 7 * scale;
|
||||
let ox = cell.x + (cell.w - gw) / 2;
|
||||
let oy = cell.y + (cell.h - gh) / 2;
|
||||
for (row, bits) in g.iter().enumerate() {
|
||||
for col in 0..5 {
|
||||
if bits & (1 << (4 - col)) != 0 {
|
||||
fill_rect(
|
||||
buf,
|
||||
w,
|
||||
h,
|
||||
Rect {
|
||||
x: ox + col as i32 * scale,
|
||||
y: oy + row as i32 * scale,
|
||||
w: scale,
|
||||
h: scale,
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the whole scene into an ARGB8888 buffer of exactly w*h pixels.
|
||||
pub fn render(buf: &mut [u32], w: i32, h: i32, scene: &Scene) {
|
||||
debug_assert_eq!(buf.len(), (w * h) as usize);
|
||||
buf.fill(BG);
|
||||
|
||||
// PIN dots, centered in the upper region above the keypad.
|
||||
let dot_color = match scene.mood {
|
||||
Mood::Entering => FG,
|
||||
Mood::Checking => BUSY,
|
||||
Mood::Failed => FAIL,
|
||||
};
|
||||
let shown = scene.pin_len.min(12).max(0);
|
||||
let slots = shown.max(4); // always draw at least 4 positions
|
||||
let radius = (w / 60).clamp(5, 12);
|
||||
let gap = radius * 4;
|
||||
let total = (slots as i32 - 1) * gap;
|
||||
let cy = h / 5;
|
||||
let cx0 = w / 2 - total / 2;
|
||||
for i in 0..slots {
|
||||
let cx = cx0 + i as i32 * gap;
|
||||
if i < shown {
|
||||
fill_circle(buf, w, h, cx, cy, radius, dot_color);
|
||||
} else {
|
||||
fill_circle(buf, w, h, cx, cy, radius, DOT_EMPTY);
|
||||
fill_circle(buf, w, h, cx, cy, radius - 2, BG);
|
||||
}
|
||||
}
|
||||
|
||||
for (rect, key) in keypad_layout(w, h) {
|
||||
let fill = if scene.pressed == Some(key) {
|
||||
KEY_PRESSED
|
||||
} else {
|
||||
KEY_FILL
|
||||
};
|
||||
fill_rect(buf, w, h, rect, fill);
|
||||
draw_glyph(buf, w, h, key, rect, FG);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_and_hit_test_agree() {
|
||||
let (w, h) = (1080, 2160);
|
||||
for (rect, key) in keypad_layout(w, h) {
|
||||
let cx = rect.x as f64 + rect.w as f64 / 2.0;
|
||||
let cy = rect.y as f64 + rect.h as f64 / 2.0;
|
||||
assert_eq!(hit_test(w, h, cx, cy), Some(key));
|
||||
}
|
||||
assert_eq!(hit_test(w, h, 5.0, 5.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fills_buffer() {
|
||||
let (w, h) = (400, 800);
|
||||
let mut buf = vec![0u32; (w * h) as usize];
|
||||
render(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
&Scene { pin_len: 3, mood: Mood::Entering, pressed: Some(Key::Digit(5)) },
|
||||
);
|
||||
assert!(buf.iter().any(|&p| p == BG));
|
||||
assert!(buf.iter().any(|&p| p == KEY_FILL));
|
||||
assert!(buf.iter().any(|&p| p == KEY_PRESSED));
|
||||
}
|
||||
}
|
||||
715
src/sessiond/lock.rs
Normal file
715
src/sessiond/lock.rs
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
//! ext-session-lock-v1 client: acquire the compositor lock, render the
|
||||
//! fallback surface, run PIN → PAM, and support the abandoned-lock handoff.
|
||||
//!
|
||||
//! One lock session = one Wayland connection. Releasing to the shell is
|
||||
//! `SessionOutcome::Released`: the connection is dropped WITHOUT unlocking,
|
||||
//! which leaves the compositor holding the session locked until the shell's
|
||||
//! own lock takes over (`misc:allow_session_lock_restore`). The unlocked
|
||||
//! path only exists through a successful PAM conversation here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use tracing::{info, warn};
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::{
|
||||
wl_buffer::{self, WlBuffer},
|
||||
wl_compositor::WlCompositor,
|
||||
wl_keyboard::{self, WlKeyboard},
|
||||
wl_output::WlOutput,
|
||||
wl_pointer::{self, WlPointer},
|
||||
wl_registry::{self, WlRegistry},
|
||||
wl_seat::{self, WlSeat},
|
||||
wl_shm::{self, WlShm},
|
||||
wl_shm_pool::WlShmPool,
|
||||
wl_surface::WlSurface,
|
||||
wl_touch::{self, WlTouch},
|
||||
},
|
||||
Connection, Dispatch, Proxy, QueueHandle, WEnum,
|
||||
};
|
||||
use wayland_protocols::ext::session_lock::v1::client::{
|
||||
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
|
||||
ext_session_lock_surface_v1::{self, ExtSessionLockSurfaceV1},
|
||||
ext_session_lock_v1::{self, ExtSessionLockV1},
|
||||
};
|
||||
|
||||
use crate::sessiond::auth;
|
||||
use crate::sessiond::draw::{self, hit_test, Key, Mood, Scene};
|
||||
|
||||
/// Where the boot bloom listens; poked once our surface has real content so
|
||||
/// the splash dissolves onto the lock, not onto the desktop.
|
||||
const SPLASH_SOCK: &str = "/run/souveraine-splash.sock";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionOutcome {
|
||||
/// The user authenticated at the fallback surface; session is unlocked.
|
||||
Unlocked,
|
||||
/// Told to hand off: connection dropped, session still locked.
|
||||
Released,
|
||||
/// The compositor refused or revoked the lock (another locker active).
|
||||
Denied,
|
||||
}
|
||||
|
||||
/// Messages into a running lock session. External commands and the auth
|
||||
/// worker's verdict share one channel; the pipe write wakes the poll loop.
|
||||
pub enum Msg {
|
||||
Release,
|
||||
AuthDone(Result<(), String>),
|
||||
}
|
||||
|
||||
pub struct LockController {
|
||||
tx: Sender<Msg>,
|
||||
wake: OwnedFd,
|
||||
}
|
||||
|
||||
impl LockController {
|
||||
pub fn send(&self, msg: Msg) {
|
||||
let _ = self.tx.send(msg);
|
||||
// A single byte; the loop drains the pipe. Failure means the loop
|
||||
// is gone, which the caller observes via the join handle anyway.
|
||||
let _ = nix_write(&self.wake, b"x");
|
||||
}
|
||||
|
||||
pub fn try_clone(&self) -> Result<LockController> {
|
||||
Ok(LockController { tx: self.tx.clone(), wake: self.wake.try_clone()? })
|
||||
}
|
||||
}
|
||||
|
||||
fn nix_write(fd: &OwnedFd, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let n = unsafe { libc::write(fd.as_raw_fd(), buf.as_ptr() as *const _, buf.len()) };
|
||||
if n < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channel() -> Result<(LockController, Receiver<Msg>, OwnedFd)> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut fds = [0i32; 2];
|
||||
// SAFETY: pipe2 fills two fds on success.
|
||||
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
|
||||
if rc != 0 {
|
||||
bail!("pipe2 failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fresh fds owned exactly once each.
|
||||
let (read, write) = unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
|
||||
Ok((LockController { tx, wake: write }, rx, read))
|
||||
}
|
||||
|
||||
struct SurfaceCtx {
|
||||
surface: WlSurface,
|
||||
lock_surface: ExtSessionLockSurfaceV1,
|
||||
output_name: u32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SeatDevices {
|
||||
touch: Option<WlTouch>,
|
||||
pointer: Option<WlPointer>,
|
||||
keyboard: Option<WlKeyboard>,
|
||||
}
|
||||
|
||||
struct LockState {
|
||||
compositor: Option<WlCompositor>,
|
||||
shm: Option<WlShm>,
|
||||
lock_mgr: Option<ExtSessionLockManagerV1>,
|
||||
lock: Option<ExtSessionLockV1>,
|
||||
locked: bool,
|
||||
finished: bool,
|
||||
outputs: Vec<(u32, WlOutput)>,
|
||||
surfaces: Vec<SurfaceCtx>,
|
||||
seats: HashMap<u32, (WlSeat, SeatDevices)>,
|
||||
pin: String,
|
||||
mood: Mood,
|
||||
pressed: Option<Key>,
|
||||
dirty: bool,
|
||||
authing: bool,
|
||||
submit: Option<String>,
|
||||
pointer_pos: (f64, f64),
|
||||
pointer_surface: Option<u32>, // protocol id of the entered wl_surface
|
||||
splash_poked: bool,
|
||||
}
|
||||
|
||||
impl LockState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
compositor: None,
|
||||
shm: None,
|
||||
lock_mgr: None,
|
||||
lock: None,
|
||||
locked: false,
|
||||
finished: false,
|
||||
outputs: Vec::new(),
|
||||
surfaces: Vec::new(),
|
||||
seats: HashMap::new(),
|
||||
pin: String::new(),
|
||||
mood: Mood::Entering,
|
||||
pressed: None,
|
||||
dirty: false,
|
||||
authing: false,
|
||||
submit: None,
|
||||
pointer_pos: (0.0, 0.0),
|
||||
pointer_surface: None,
|
||||
splash_poked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn press(&mut self, key: Key) {
|
||||
if self.authing {
|
||||
return;
|
||||
}
|
||||
if self.mood == Mood::Failed {
|
||||
self.mood = Mood::Entering;
|
||||
}
|
||||
self.pressed = Some(key);
|
||||
match key {
|
||||
Key::Digit(d) => {
|
||||
if self.pin.len() < 32 {
|
||||
self.pin.push((b'0' + d) as char);
|
||||
}
|
||||
}
|
||||
Key::Backspace => {
|
||||
self.pin.pop();
|
||||
}
|
||||
Key::Enter => {
|
||||
if !self.pin.is_empty() {
|
||||
self.submit = Some(std::mem::take(&mut self.pin));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
fn key_from_evdev(code: u32) -> Option<Key> {
|
||||
match code {
|
||||
2..=10 => Some(Key::Digit(code as u8 - 1)), // KEY_1..KEY_9
|
||||
11 => Some(Key::Digit(0)), // KEY_0
|
||||
79..=81 => Some(Key::Digit(code as u8 - 72)), // KP7..KP9
|
||||
75..=77 => Some(Key::Digit(code as u8 - 71)), // KP4..KP6
|
||||
71..=73 => Some(Key::Digit(code as u8 - 70)), // KP1..KP3
|
||||
82 => Some(Key::Digit(0)), // KP0
|
||||
14 => Some(Key::Backspace),
|
||||
28 | 96 => Some(Key::Enter),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> {
|
||||
self.surfaces
|
||||
.iter()
|
||||
.find(|s| s.surface.id().protocol_id() == id)
|
||||
.map(|s| (s.width, s.height))
|
||||
}
|
||||
}
|
||||
|
||||
fn poke_splash() {
|
||||
std::thread::spawn(|| {
|
||||
if let Ok(mut s) = UnixStream::connect(SPLASH_SOCK) {
|
||||
let _ = s.write_all(b"1");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run one full lock session on the current thread. Blocks until unlock,
|
||||
/// release, or denial.
|
||||
pub fn run(
|
||||
rx: Receiver<Msg>,
|
||||
wake_read: OwnedFd,
|
||||
controller_for_auth: LockController,
|
||||
pam_service: String,
|
||||
) -> Result<SessionOutcome> {
|
||||
let conn = Connection::connect_to_env().context("connecting to Wayland display")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let display = conn.display();
|
||||
display.get_registry(&qh, ());
|
||||
|
||||
let mut state = LockState::new();
|
||||
queue.roundtrip(&mut state).context("initial roundtrip")?;
|
||||
|
||||
let (Some(_), Some(_)) = (&state.compositor, &state.shm) else {
|
||||
bail!("compositor is missing wl_compositor/wl_shm");
|
||||
};
|
||||
let Some(mgr) = &state.lock_mgr else {
|
||||
bail!("compositor does not advertise ext-session-lock-v1");
|
||||
};
|
||||
|
||||
let lock = mgr.lock(&qh, ());
|
||||
state.lock = Some(lock);
|
||||
queue.roundtrip(&mut state).context("lock roundtrip")?;
|
||||
|
||||
if state.finished {
|
||||
return Ok(SessionOutcome::Denied);
|
||||
}
|
||||
if !state.locked {
|
||||
// Compositor may answer on a later dispatch; give it one blocking
|
||||
// turn before declaring failure.
|
||||
queue.blocking_dispatch(&mut state).context("waiting for locked")?;
|
||||
if state.finished {
|
||||
return Ok(SessionOutcome::Denied);
|
||||
}
|
||||
if !state.locked {
|
||||
bail!("compositor never acknowledged the session lock");
|
||||
}
|
||||
}
|
||||
info!("session lock acquired");
|
||||
|
||||
let user = auth::session_user().map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
loop {
|
||||
if state.finished {
|
||||
info!("lock revoked by compositor (another locker?)");
|
||||
return Ok(SessionOutcome::Denied);
|
||||
}
|
||||
|
||||
ensure_surfaces(&mut state, &qh);
|
||||
|
||||
if state.dirty {
|
||||
redraw_all(&mut state, &qh)?;
|
||||
state.dirty = false;
|
||||
}
|
||||
|
||||
if let Some(pin) = state.submit.take() {
|
||||
if !state.authing {
|
||||
state.authing = true;
|
||||
state.mood = Mood::Checking;
|
||||
state.pressed = None;
|
||||
state.dirty = true;
|
||||
let service = pam_service.clone();
|
||||
let user = user.clone();
|
||||
let ctl = LockController {
|
||||
tx: controller_for_auth.tx.clone(),
|
||||
wake: controller_for_auth.wake.try_clone()?,
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
let verdict = auth::authenticate(&service, &user, &pin);
|
||||
ctl.send(Msg::AuthDone(verdict));
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain control/auth messages before sleeping.
|
||||
let mut acted = false;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
acted = true;
|
||||
match msg {
|
||||
Msg::Release => {
|
||||
info!("releasing session lock to shell (connection drop, stays locked)");
|
||||
return Ok(SessionOutcome::Released);
|
||||
}
|
||||
Msg::AuthDone(Ok(())) => {
|
||||
info!("PAM success — unlocking session");
|
||||
if let Some(lock) = &state.lock {
|
||||
lock.unlock_and_destroy();
|
||||
}
|
||||
// The protocol requires a roundtrip after unlock so the
|
||||
// compositor sees it before we disconnect; otherwise the
|
||||
// exit is indistinguishable from an abandon.
|
||||
queue.roundtrip(&mut state).context("unlock roundtrip")?;
|
||||
return Ok(SessionOutcome::Unlocked);
|
||||
}
|
||||
Msg::AuthDone(Err(reason)) => {
|
||||
warn!("PAM refused: {reason}");
|
||||
state.authing = false;
|
||||
state.mood = Mood::Failed;
|
||||
state.pin.clear();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if acted {
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.flush().context("flush")?;
|
||||
if queue.dispatch_pending(&mut state).context("dispatch")? > 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(guard) = queue.prepare_read() else {
|
||||
continue;
|
||||
};
|
||||
let wl_fd = guard.connection_fd().as_raw_fd();
|
||||
let mut fds = [
|
||||
libc::pollfd { fd: wl_fd, events: libc::POLLIN, revents: 0 },
|
||||
libc::pollfd { fd: wake_read.as_raw_fd(), events: libc::POLLIN, revents: 0 },
|
||||
];
|
||||
// SAFETY: fds array outlives the call.
|
||||
let rc = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
|
||||
if rc < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(err).context("poll");
|
||||
}
|
||||
if fds[1].revents & libc::POLLIN != 0 {
|
||||
let mut buf = [0u8; 64];
|
||||
// Drain the wake pipe; messages are picked up next iteration.
|
||||
loop {
|
||||
let n = unsafe {
|
||||
libc::read(wake_read.as_raw_fd(), buf.as_mut_ptr() as *mut _, buf.len())
|
||||
};
|
||||
if n <= 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if fds[0].revents & libc::POLLIN != 0 {
|
||||
match guard.read() {
|
||||
Ok(_) => {
|
||||
queue.dispatch_pending(&mut state).context("dispatch after read")?;
|
||||
}
|
||||
Err(wayland_client::backend::WaylandError::Io(e))
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(e).context("reading wayland events"),
|
||||
}
|
||||
}
|
||||
// Guard dropped here if we didn't read — that cancels cleanly.
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_surfaces(state: &mut LockState, qh: &QueueHandle<LockState>) {
|
||||
if !state.locked {
|
||||
return;
|
||||
}
|
||||
let (Some(compositor), Some(lock)) = (&state.compositor, &state.lock) else {
|
||||
return;
|
||||
};
|
||||
let existing: Vec<u32> = state.surfaces.iter().map(|s| s.output_name).collect();
|
||||
let mut created = Vec::new();
|
||||
for (name, output) in &state.outputs {
|
||||
if existing.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let surface = compositor.create_surface(qh, ());
|
||||
let lock_surface = lock.get_lock_surface(&surface, output, qh, ());
|
||||
created.push(SurfaceCtx {
|
||||
surface,
|
||||
lock_surface,
|
||||
output_name: *name,
|
||||
width: 0,
|
||||
height: 0,
|
||||
configured: false,
|
||||
});
|
||||
}
|
||||
state.surfaces.extend(created);
|
||||
}
|
||||
|
||||
fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> {
|
||||
let scene = Scene { pin_len: state.pin.len(), mood: state.mood, pressed: state.pressed };
|
||||
let Some(shm) = state.shm.clone() else { return Ok(()) };
|
||||
let mut drew = false;
|
||||
for ctx in &mut state.surfaces {
|
||||
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
|
||||
continue;
|
||||
}
|
||||
draw_frame(&shm, ctx, &scene, qh)?;
|
||||
drew = true;
|
||||
}
|
||||
if drew && !state.splash_poked {
|
||||
state.splash_poked = true;
|
||||
poke_splash();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_frame(
|
||||
shm: &WlShm,
|
||||
ctx: &SurfaceCtx,
|
||||
scene: &Scene,
|
||||
qh: &QueueHandle<LockState>,
|
||||
) -> Result<()> {
|
||||
let (w, h) = (ctx.width, ctx.height);
|
||||
let stride = w * 4;
|
||||
let size = (stride * h) as usize;
|
||||
|
||||
// SAFETY: fresh memfd, sized before map, unmapped after the pixel write.
|
||||
let fd = unsafe {
|
||||
let raw = libc::memfd_create(b"sessiond-frame\0".as_ptr() as *const _, libc::MFD_CLOEXEC);
|
||||
if raw < 0 {
|
||||
bail!("memfd_create: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
let fd = OwnedFd::from_raw_fd(raw);
|
||||
if libc::ftruncate(fd.as_raw_fd(), size as libc::off_t) != 0 {
|
||||
bail!("ftruncate: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
fd
|
||||
};
|
||||
// SAFETY: mapping the region we just sized; checked for MAP_FAILED.
|
||||
unsafe {
|
||||
let ptr = libc::mmap(
|
||||
std::ptr::null_mut(),
|
||||
size,
|
||||
libc::PROT_READ | libc::PROT_WRITE,
|
||||
libc::MAP_SHARED,
|
||||
fd.as_raw_fd(),
|
||||
0,
|
||||
);
|
||||
if ptr == libc::MAP_FAILED {
|
||||
bail!("mmap: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
let pixels = std::slice::from_raw_parts_mut(ptr as *mut u32, (w * h) as usize);
|
||||
draw::render(pixels, w, h, scene);
|
||||
libc::munmap(ptr, size);
|
||||
}
|
||||
|
||||
let pool = shm.create_pool(fd.as_fd(), size as i32, qh, ());
|
||||
let buffer = pool.create_buffer(0, w, h, stride, wl_shm::Format::Argb8888, qh, ());
|
||||
// The buffer keeps the pool's storage alive; the pool object itself can go.
|
||||
pool.destroy();
|
||||
|
||||
ctx.surface.attach(Some(&buffer), 0, 0);
|
||||
ctx.surface.damage_buffer(0, 0, w, h);
|
||||
ctx.surface.commit();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Dispatch impls ----
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global { name, interface, version } => match interface.as_str() {
|
||||
"wl_compositor" => {
|
||||
state.compositor =
|
||||
Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ()));
|
||||
}
|
||||
"wl_shm" => {
|
||||
state.shm = Some(registry.bind::<WlShm, _, _>(name, 1, qh, ()));
|
||||
}
|
||||
"wl_seat" => {
|
||||
let seat = registry.bind::<WlSeat, _, _>(name, version.min(5), qh, ());
|
||||
state.seats.insert(name, (seat, SeatDevices::default()));
|
||||
}
|
||||
"wl_output" => {
|
||||
let output = registry.bind::<WlOutput, _, _>(name, version.min(2), qh, ());
|
||||
state.outputs.push((name, output));
|
||||
}
|
||||
"ext_session_lock_manager_v1" => {
|
||||
state.lock_mgr =
|
||||
Some(registry.bind::<ExtSessionLockManagerV1, _, _>(name, 1, qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|(n, _)| *n != name);
|
||||
state.surfaces.retain(|s| s.output_name != name);
|
||||
state.seats.remove(&name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtSessionLockV1, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ExtSessionLockV1,
|
||||
event: ext_session_lock_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
ext_session_lock_v1::Event::Locked => state.locked = true,
|
||||
ext_session_lock_v1::Event::Finished => state.finished = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtSessionLockSurfaceV1, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
surface: &ExtSessionLockSurfaceV1,
|
||||
event: ext_session_lock_surface_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event {
|
||||
surface.ack_configure(serial);
|
||||
if let Some(ctx) = state
|
||||
.surfaces
|
||||
.iter_mut()
|
||||
.find(|s| s.lock_surface.id() == surface.id())
|
||||
{
|
||||
ctx.width = width as i32;
|
||||
ctx.height = height as i32;
|
||||
ctx.configured = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlSeat, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
seat: &WlSeat,
|
||||
event: wl_seat::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event {
|
||||
let entry = state
|
||||
.seats
|
||||
.values_mut()
|
||||
.find(|(s, _)| s.id() == seat.id());
|
||||
let Some((seat, devices)) = entry else { return };
|
||||
if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() {
|
||||
devices.touch = Some(seat.get_touch(qh, ()));
|
||||
}
|
||||
if caps.contains(wl_seat::Capability::Pointer) && devices.pointer.is_none() {
|
||||
devices.pointer = Some(seat.get_pointer(qh, ()));
|
||||
}
|
||||
if caps.contains(wl_seat::Capability::Keyboard) && devices.keyboard.is_none() {
|
||||
devices.keyboard = Some(seat.get_keyboard(qh, ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlTouch, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlTouch,
|
||||
event: wl_touch::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_touch::Event::Down { surface, x, y, .. } => {
|
||||
let pid = surface.id().protocol_id();
|
||||
if let Some((w, h)) = state.surface_size_by_proto_id(pid) {
|
||||
if let Some(key) = hit_test(w, h, x, y) {
|
||||
state.press(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
wl_touch::Event::Up { .. } => {
|
||||
if state.pressed.take().is_some() {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlPointer, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlPointer,
|
||||
event: wl_pointer::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_pointer::Event::Enter { surface, surface_x, surface_y, .. } => {
|
||||
state.pointer_surface = Some(surface.id().protocol_id());
|
||||
state.pointer_pos = (surface_x, surface_y);
|
||||
}
|
||||
wl_pointer::Event::Leave { .. } => {
|
||||
state.pointer_surface = None;
|
||||
}
|
||||
wl_pointer::Event::Motion { surface_x, surface_y, .. } => {
|
||||
state.pointer_pos = (surface_x, surface_y);
|
||||
}
|
||||
wl_pointer::Event::Button { state: WEnum::Value(st), .. } => match st {
|
||||
wl_pointer::ButtonState::Pressed => {
|
||||
if let Some(pid) = state.pointer_surface {
|
||||
if let Some((w, h)) = state.surface_size_by_proto_id(pid) {
|
||||
let (px, py) = state.pointer_pos;
|
||||
if let Some(key) = hit_test(w, h, px, py) {
|
||||
state.press(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wl_pointer::ButtonState::Released => {
|
||||
if state.pressed.take().is_some() {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlKeyboard, ()> for LockState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlKeyboard,
|
||||
event: wl_keyboard::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_keyboard::Event::Key { key, state: WEnum::Value(st), .. } => {
|
||||
match st {
|
||||
wl_keyboard::KeyState::Pressed => {
|
||||
if let Some(k) = LockState::key_from_evdev(key) {
|
||||
state.press(k);
|
||||
}
|
||||
}
|
||||
wl_keyboard::KeyState::Released => {
|
||||
if state.pressed.take().is_some() {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Keymap carries an fd we must not leak; OwnedFd drops it here.
|
||||
wl_keyboard::Event::Keymap { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlBuffer, ()> for LockState {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
buffer: &WlBuffer,
|
||||
event: wl_buffer::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_buffer::Event::Release = event {
|
||||
buffer.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(LockState: ignore WlCompositor);
|
||||
delegate_noop!(LockState: ignore WlSurface);
|
||||
delegate_noop!(LockState: ignore WlShm);
|
||||
delegate_noop!(LockState: ignore WlShmPool);
|
||||
delegate_noop!(LockState: ignore WlOutput);
|
||||
delegate_noop!(LockState: ignore ExtSessionLockManagerV1);
|
||||
17
src/sessiond/mod.rs
Normal file
17
src/sessiond/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! souveraine-sessiond — the session authority daemon.
|
||||
//!
|
||||
//! The lock authority below the shell: takes ext-session-lock before the
|
||||
//! shell exists at boot, hands off to the shell's rich lock surface without
|
||||
//! ever passing through an unlocked instant, and takes the lock back the
|
||||
//! moment the shell heartbeat drops. See
|
||||
//! `SouveraineOS/docs/session-authority-boot-order.md` (Phase C) and
|
||||
//! `SESSION-AUTHORITY-DOCTRINE.md` §10–§11.
|
||||
//!
|
||||
//! Compiled into the `souveraine-sessiond` bin via `#[path]` includes, the
|
||||
//! same pattern as machined and secrets.
|
||||
|
||||
pub mod auth;
|
||||
pub mod draw;
|
||||
pub mod lock;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
93
src/sessiond/protocol.rs
Normal file
93
src/sessiond/protocol.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! Wire protocol for souveraine-sessiond — the session authority daemon.
|
||||
//!
|
||||
//! One JSON object per line over a Unix socket in the user's runtime dir,
|
||||
//! guarded `ok`/`reason` responses, same shape as machined's protocol. The
|
||||
//! socket lives on the user tier (unlike machined's system-tier socket): the
|
||||
//! session authority serves exactly one seat and dies with it.
|
||||
//!
|
||||
//! The handoff contract with the shell:
|
||||
//!
|
||||
//! 1. sessiond starts before the shell and takes ext-session-lock — the
|
||||
//! session is locked before any shell surface can exist.
|
||||
//! 2. The shell connects, sends `shell_ready`, and KEEPS the connection
|
||||
//! open. That connection is the heartbeat; EOF means the shell died.
|
||||
//! 3. sessiond releases by dropping its Wayland connection WITHOUT
|
||||
//! unlocking. The compositor keeps the session locked (abandoned-client
|
||||
//! state) and `misc:allow_session_lock_restore` lets the shell's own
|
||||
//! WlSessionLock take over. There is never an unlocked instant.
|
||||
//! 4. The shell sends `locked_ack` once its lock surface is secure. If the
|
||||
//! ack does not arrive in time, sessiond takes the lock back.
|
||||
//! 5. Heartbeat EOF at any point → sessiond retakes the lock immediately,
|
||||
//! whether or not the session was locked at the time. Fail closed.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Socket path relative to `$XDG_RUNTIME_DIR`.
|
||||
pub const SOCKET_RELPATH: &str = "souveraine/sessiond.sock";
|
||||
|
||||
/// Upper bound on one request line.
|
||||
pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;
|
||||
|
||||
/// PAM service for the fallback unlock surface. The shell's lock uses
|
||||
/// quickshell's default (`login`); the dedicated file lets the PIN stack be
|
||||
/// audited separately and is shipped as root-owned system config by the OS
|
||||
/// overlay, never by this crate (same stance as `souveraine-stepup`).
|
||||
pub const DEFAULT_PAM_SERVICE: &str = "souveraine-sessiond";
|
||||
|
||||
/// How long after releasing the lock we wait for the shell's `locked_ack`
|
||||
/// before deciding the shell is broken and taking the lock back.
|
||||
pub const LOCKED_ACK_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
/// Liveness + who currently owns the session lock.
|
||||
Status,
|
||||
/// The shell announces itself. The connection carrying this request
|
||||
/// becomes the heartbeat. If sessiond is holding the lock, it releases
|
||||
/// (see module docs) and the shell must lock and then `locked_ack`.
|
||||
ShellReady,
|
||||
/// The shell's lock surface reached compositor-acknowledged `secure`.
|
||||
LockedAck,
|
||||
/// Ask sessiond to take the session lock itself. Refused while a live
|
||||
/// shell owns steady state — the shell's lock IPC is the front door.
|
||||
Lock,
|
||||
}
|
||||
|
||||
/// What sessiond currently is, as reported by `status`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Phase {
|
||||
/// sessiond holds ext-session-lock and renders the fallback surface.
|
||||
Holding,
|
||||
/// Lock released to the shell; heartbeat live; ack not yet seen.
|
||||
AwaitingShellLock,
|
||||
/// Shell owns steady state (heartbeat live, ack seen).
|
||||
Released,
|
||||
/// No shell heartbeat and not holding (initial-lock disabled or the
|
||||
/// user unlocked at the fallback surface with no shell to hand off to).
|
||||
Idle,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_round_trips() {
|
||||
let req: Request = serde_json::from_str(r#"{"op":"shell_ready"}"#).unwrap();
|
||||
assert!(matches!(req, Request::ShellReady));
|
||||
let req: Request = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
|
||||
assert!(matches!(req, Request::Status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_serializes_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Phase::AwaitingShellLock).unwrap(),
|
||||
r#""awaiting_shell_lock""#
|
||||
);
|
||||
}
|
||||
}
|
||||
317
src/sessiond/server.rs
Normal file
317
src/sessiond/server.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
//! Orchestration for souveraine-sessiond: the control socket, the shell
|
||||
//! heartbeat, and the retake state machine.
|
||||
//!
|
||||
//! Sync, std-only, thread-per-connection — the same auditable shape as
|
||||
//! machined's server. The daemon's whole job is to make sure that at every
|
||||
//! moment either (a) it holds the session lock, (b) a live shell does, or
|
||||
//! (c) the user deliberately unlocked at the fallback surface. Transitions
|
||||
//! between those states are the only logic here.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
|
||||
use crate::sessiond::protocol::{Phase, Request, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES};
|
||||
|
||||
struct Daemon {
|
||||
phase: Phase,
|
||||
/// Present exactly while a lock-session thread is running.
|
||||
controller: Option<LockController>,
|
||||
/// Bumped on every shell registration; a heartbeat EOF only counts if
|
||||
/// its generation is still current (a restarted shell supersedes).
|
||||
heartbeat_gen: u64,
|
||||
shell_alive: bool,
|
||||
pam_service: String,
|
||||
}
|
||||
|
||||
pub struct Shared {
|
||||
state: Mutex<Daemon>,
|
||||
cond: Condvar,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Daemon> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn socket_path() -> Result<PathBuf> {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR is not set")?;
|
||||
Ok(PathBuf::from(runtime).join(crate::sessiond::protocol::SOCKET_RELPATH))
|
||||
}
|
||||
|
||||
pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Result<()> {
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).ok();
|
||||
}
|
||||
if socket_path.exists() {
|
||||
if UnixStream::connect(socket_path).is_ok() {
|
||||
anyhow::bail!(
|
||||
"another souveraine-sessiond is already serving {}",
|
||||
socket_path.display()
|
||||
);
|
||||
}
|
||||
warn!("removing stale socket at {}", socket_path.display());
|
||||
std::fs::remove_file(socket_path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(socket_path)
|
||||
.with_context(|| format!("binding {}", socket_path.display()))?;
|
||||
// Same-user only today. When agents get their own accounts, this widens
|
||||
// to a group grant deliberately, machined-style — never by default.
|
||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
let shared = Arc::new(Shared {
|
||||
state: Mutex::new(Daemon {
|
||||
phase: Phase::Idle,
|
||||
controller: None,
|
||||
heartbeat_gen: 0,
|
||||
shell_alive: false,
|
||||
pam_service,
|
||||
}),
|
||||
cond: Condvar::new(),
|
||||
});
|
||||
|
||||
if initial_lock {
|
||||
spawn_lock_session(&shared);
|
||||
}
|
||||
info!("sessiond serving on {}", socket_path.display());
|
||||
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(s) => {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || handle_connection(s, shared));
|
||||
}
|
||||
Err(e) => warn!("accept failed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a lock session unless one is already running. Returns whether a
|
||||
/// session is running when we leave (either ours or a pre-existing one).
|
||||
fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
|
||||
let mut d = shared.lock();
|
||||
if d.controller.is_some() {
|
||||
return true;
|
||||
}
|
||||
let (controller, rx, wake_read) = match lock::channel() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("cannot create lock channel: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// The auth worker feeds verdicts back into the SAME session's channel.
|
||||
let auth_controller = match controller.try_clone() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("cannot clone lock controller: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
d.controller = Some(controller);
|
||||
d.phase = Phase::Holding;
|
||||
drop(d);
|
||||
|
||||
let shared = Arc::clone(shared);
|
||||
std::thread::spawn(move || {
|
||||
let pam_service = shared.lock().pam_service.clone();
|
||||
let outcome = lock::run(rx, wake_read, auth_controller, pam_service);
|
||||
let mut d = shared.lock();
|
||||
d.controller = None;
|
||||
match outcome {
|
||||
Ok(SessionOutcome::Unlocked) => {
|
||||
info!("session unlocked at fallback surface");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
}
|
||||
Ok(SessionOutcome::Released) => {
|
||||
d.phase = Phase::AwaitingShellLock;
|
||||
let gen = d.heartbeat_gen;
|
||||
drop(d);
|
||||
start_ack_timer(&shared, gen);
|
||||
shared.cond.notify_all();
|
||||
return;
|
||||
}
|
||||
Ok(SessionOutcome::Denied) => {
|
||||
info!("lock denied/revoked — another locker owns the session");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("lock session failed: {e:#}");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
}
|
||||
}
|
||||
drop(d);
|
||||
shared.cond.notify_all();
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// If the shell never acks its lock after a handoff, take the lock back.
|
||||
/// A retake against a shell that DID lock is refused by the compositor
|
||||
/// (Denied outcome), so this is safe against a late or lost ack.
|
||||
fn start_ack_timer(shared: &Arc<Shared>, gen: u64) {
|
||||
let shared = Arc::clone(shared);
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_secs(LOCKED_ACK_TIMEOUT_SECS));
|
||||
let d = shared.lock();
|
||||
if d.phase == Phase::AwaitingShellLock && d.heartbeat_gen == gen {
|
||||
warn!("shell never confirmed its lock after handoff — retaking");
|
||||
drop(d);
|
||||
spawn_lock_session(&shared);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn refusal(reason: &str) -> serde_json::Value {
|
||||
serde_json::json!({ "ok": false, "reason": reason })
|
||||
}
|
||||
|
||||
fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> {
|
||||
writer.write_all(value.to_string().as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(10)));
|
||||
let mut writer = match stream.try_clone() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!("could not clone stream: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut reader = BufReader::new(stream);
|
||||
// Whether THIS connection is the registered shell heartbeat, and under
|
||||
// which generation.
|
||||
let mut heartbeat: Option<u64> = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match (&mut reader).take(MAX_REQUEST_BYTES).read_line(&mut line) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!("read error on control socket: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if line.len() as u64 >= MAX_REQUEST_BYTES {
|
||||
let _ = respond(&mut writer, refusal("request exceeds size limit"));
|
||||
break;
|
||||
}
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let response = match serde_json::from_str::<Request>(line) {
|
||||
Ok(req) => handle_request(req, &shared, &mut heartbeat),
|
||||
Err(e) => refusal(&format!("malformed request: {e}")),
|
||||
};
|
||||
if respond(&mut writer, response).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Connection gone. If it was the live heartbeat, the shell died —
|
||||
// fail closed: take the session lock regardless of prior lock state.
|
||||
if let Some(gen) = heartbeat {
|
||||
let mut d = shared.lock();
|
||||
if d.heartbeat_gen == gen && d.shell_alive {
|
||||
d.shell_alive = false;
|
||||
let holding = d.controller.is_some();
|
||||
drop(d);
|
||||
warn!("shell heartbeat lost — locking the session");
|
||||
if !holding {
|
||||
spawn_lock_session(&shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
req: Request,
|
||||
shared: &Arc<Shared>,
|
||||
heartbeat: &mut Option<u64>,
|
||||
) -> serde_json::Value {
|
||||
match req {
|
||||
Request::Status => {
|
||||
let d = shared.lock();
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"phase": d.phase,
|
||||
"shell_alive": d.shell_alive,
|
||||
})
|
||||
}
|
||||
Request::ShellReady => {
|
||||
let mut d = shared.lock();
|
||||
d.heartbeat_gen += 1;
|
||||
d.shell_alive = true;
|
||||
let gen = d.heartbeat_gen;
|
||||
*heartbeat = Some(gen);
|
||||
|
||||
let holding = d.controller.is_some();
|
||||
if holding {
|
||||
if let Some(ctl) = &d.controller {
|
||||
ctl.send(Msg::Release);
|
||||
}
|
||||
// Wait until the lock-session thread actually dropped its
|
||||
// Wayland connection; only then may the shell lock (the
|
||||
// compositor refuses a second locker while ours is alive).
|
||||
let deadline = Duration::from_secs(5);
|
||||
let (_guard, timed_out) = shared
|
||||
.cond
|
||||
.wait_timeout_while(d, deadline, |d| d.controller.is_some())
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if timed_out.timed_out() {
|
||||
return refusal("lock session did not release in time");
|
||||
}
|
||||
info!("handoff: lock released to shell (gen {gen})");
|
||||
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
|
||||
} else {
|
||||
info!("shell registered (gen {gen}), no lock held");
|
||||
if d.phase == Phase::Idle {
|
||||
d.phase = Phase::Released;
|
||||
}
|
||||
serde_json::json!({ "ok": true, "held": false, "must_lock": false })
|
||||
}
|
||||
}
|
||||
Request::LockedAck => {
|
||||
let mut d = shared.lock();
|
||||
if heartbeat.is_none() {
|
||||
return refusal("locked_ack from a connection that never sent shell_ready");
|
||||
}
|
||||
d.phase = Phase::Released;
|
||||
info!("shell lock confirmed");
|
||||
serde_json::json!({ "ok": true })
|
||||
}
|
||||
Request::Lock => {
|
||||
let d = shared.lock();
|
||||
match d.phase {
|
||||
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
|
||||
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => {
|
||||
refusal("a live shell owns the session lock; use the shell's lock IPC")
|
||||
}
|
||||
_ => {
|
||||
drop(d);
|
||||
if spawn_lock_session(shared) {
|
||||
serde_json::json!({ "ok": true })
|
||||
} else {
|
||||
refusal("could not start a lock session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml
|
|||
services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml
|
||||
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
|
||||
services/SessionEvents.qml souveraine/services/SessionEvents.qml
|
||||
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
|
||||
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
|
||||
services/SessionAudit.qml souveraine/services/SessionAudit.qml
|
||||
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
|
||||
|
|
|
|||
|
|
@ -161,13 +161,25 @@ Scope {
|
|||
}
|
||||
}
|
||||
|
||||
property bool initDone: false
|
||||
function initIfReady() {
|
||||
if (!Config.ready || !Persistent.ready) return;
|
||||
if (Config.options.lock.launchOnStartup && (Persistent.isNewHyprlandInstance || Persistent.states.lock.locked)) {
|
||||
root.lock();
|
||||
} else {
|
||||
KeyringStorage.fetchKeyringData();
|
||||
}
|
||||
if (!Config.ready || !Persistent.ready || root.initDone) return;
|
||||
root.initDone = true;
|
||||
// Register with sessiond (and start the heartbeat) before deciding
|
||||
// the startup lock state. If sessiond holds the session lock, it
|
||||
// releases on our shell_ready and we MUST lock immediately — the
|
||||
// compositor is holding an abandoned lock for us to inherit
|
||||
// (misc:allow_session_lock_restore). No sessiond = cb(false) and
|
||||
// the legacy launchOnStartup rules decide alone.
|
||||
SessiondBridge.shellReady(function(mustLock) {
|
||||
if (mustLock
|
||||
|| (Config.options.lock.launchOnStartup
|
||||
&& (Persistent.isNewHyprlandInstance || Persistent.states.lock.locked))) {
|
||||
root.lock();
|
||||
} else {
|
||||
KeyringStorage.fetchKeyringData();
|
||||
}
|
||||
});
|
||||
}
|
||||
Connections {
|
||||
target: Config
|
||||
|
|
|
|||
153
surfaces/quickshell/services/SessiondBridge.qml
Normal file
153
surfaces/quickshell/services/SessiondBridge.qml
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Shell side of the souveraine-sessiond handoff protocol.
|
||||
//
|
||||
// sessiond takes ext-session-lock before the shell exists; this bridge is
|
||||
// how the shell (a) announces itself and takes the lock over, (b) keeps the
|
||||
// heartbeat connection open so sessiond can retake the lock the moment the
|
||||
// shell dies, and (c) confirms the compositor-acked lock (locked_ack).
|
||||
//
|
||||
// The session is never unlocked during the handoff: sessiond abandons its
|
||||
// lock (connection drop) and misc:allow_session_lock_restore lets our
|
||||
// WlSessionLock inherit the locked session. See
|
||||
// souveraine/src/sessiond/protocol.rs — that file is the contract.
|
||||
//
|
||||
// No sessiond on the socket (laptop, or bring-up) = everything no-ops and
|
||||
// the legacy launchOnStartup path decides alone.
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Registered = shell_ready was answered ok on the CURRENT connection.
|
||||
property bool registered: false
|
||||
// sessiond held the session lock when we registered; we owe it a lock
|
||||
// and a locked_ack.
|
||||
property bool oweLock: false
|
||||
property bool ackSent: false
|
||||
property var pendingReady: null // callback awaiting the shell_ready response
|
||||
|
||||
function load() {}
|
||||
|
||||
// Announce the shell. cb(mustLock) fires exactly once: mustLock true
|
||||
// means sessiond was holding and the session IS locked — the shell must
|
||||
// raise its own lock surface immediately.
|
||||
function shellReady(cb) {
|
||||
if (!sock.connected) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
if (root.pendingReady) {
|
||||
// Only one handshake in flight; late duplicate answers false.
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
root.pendingReady = cb;
|
||||
readyTimeout.restart();
|
||||
sock.write(JSON.stringify({ op: "shell_ready" }) + "\n");
|
||||
sock.flush();
|
||||
}
|
||||
|
||||
function sendLockedAck() {
|
||||
if (!sock.connected || !root.registered || root.ackSent) return;
|
||||
root.ackSent = true;
|
||||
sock.write(JSON.stringify({ op: "locked_ack" }) + "\n");
|
||||
sock.flush();
|
||||
console.log("[sessiond-bridge] locked_ack sent");
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: readyTimeout
|
||||
interval: 3000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root.pendingReady) {
|
||||
console.log("[sessiond-bridge] shell_ready timed out — proceeding without sessiond");
|
||||
const cb = root.pendingReady;
|
||||
root.pendingReady = null;
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect: sessiond may restart (upgrade) or start late. While
|
||||
// connected this timer is idle; the Socket does not retry by itself.
|
||||
Timer {
|
||||
id: reconnect
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: !sock.connected
|
||||
onTriggered: sock.connected = true
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: sock
|
||||
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
||||
connected: true
|
||||
|
||||
onSocketConnected: {
|
||||
console.log("[sessiond-bridge] connected");
|
||||
// A fresh connection is never registered yet. If we were
|
||||
// registered before (sessiond restarted underneath us),
|
||||
// re-register right away so the heartbeat resumes.
|
||||
const wasRegistered = root.registered;
|
||||
root.registered = false;
|
||||
root.ackSent = false;
|
||||
if (wasRegistered) {
|
||||
root.shellReady(function(mustLock) {
|
||||
if (mustLock) {
|
||||
// sessiond locked while we were disconnected (it
|
||||
// treats our EOF as shell death). Mirror it.
|
||||
GlobalStates.screenLocked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
onSocketDisconnected: {
|
||||
console.log("[sessiond-bridge] disconnected");
|
||||
root.registered = false;
|
||||
root.pendingReady = null;
|
||||
}
|
||||
|
||||
parser: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: message => {
|
||||
let reply;
|
||||
try {
|
||||
reply = JSON.parse(message);
|
||||
} catch (e) {
|
||||
console.log("[sessiond-bridge] unparseable reply: " + message);
|
||||
return;
|
||||
}
|
||||
if (root.pendingReady) {
|
||||
// The only request we await a response for.
|
||||
const cb = root.pendingReady;
|
||||
root.pendingReady = null;
|
||||
readyTimeout.stop();
|
||||
if (reply.ok) {
|
||||
root.registered = true;
|
||||
root.oweLock = reply.must_lock === true;
|
||||
console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
|
||||
cb(root.oweLock);
|
||||
} else {
|
||||
console.log("[sessiond-bridge] shell_ready refused: " + reply.reason);
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The compositor acknowledged OUR lock surface — tell sessiond the
|
||||
// handoff is complete. Gated on secure, not the request, per doctrine.
|
||||
Connections {
|
||||
target: GlobalStates
|
||||
function onScreenLockSecureChanged() {
|
||||
if (GlobalStates.screenLockSecure && root.oweLock && !root.ackSent) {
|
||||
root.sendLockedAck();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ ShellRoot {
|
|||
|
||||
Component.onCompleted: {
|
||||
MaterialThemeLoader.reapplyTheme()
|
||||
SessiondBridge.load()
|
||||
Hyprsunset.load()
|
||||
FirstRunExperience.load()
|
||||
ConflictKiller.load()
|
||||
|
|
|
|||
Loading…
Reference in a new issue