Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/sessiond/protocol.rs
Fimeg 4fd3d244ab sessiond: bearer posture as evidence and an Action (TASK-49)
Nothing owned which link carries traffic. It was the residue of a DHCP metric,
a NetworkManager penalty and a shell script's assumption, and when those
disagreed the phone silently lost the network - every IPv4 destination leaving
over a degraded clat while wifi sat associated.

The thing this replaces, wiuf-vpn-gate, is worth stating because its three
defects are the design constraints:

- it was driven by the NM dispatcher AND a 90s timer while calling nmcli
  up/down, which is itself an NM event. 652 tunnel recycles in 90 minutes,
  ~7/min - a loop, not a bad interval. Here the probe only reads, the decision
  is a pure function, and the only egress is an Action from tick().
- its home test was ' inet 10.10.' - a /16 match that reads the foreign network
  this phone lives on (10.10.30.213) as home. home_ssids is identity; an empty
  list means never claim to be home.
- it managed CONN=wiuf while two profiles answer to that name.

LinkHealth keeps 'associated' apart from 'carrying', which is DEVICE-STATE-
MACHINE §10's source-health lesson applied to links: a wifi link that
associates and cannot route is a sensor that heartbeats and lies, and it is
what NM's +20000 penalty mislabels. A carrying cellular link therefore beats a
merely associated wifi one; wifi only wins ties.

TunnelHealth reads wg's received-byte counter, which nothing read before. Up
and deaf is a real state - measured over clat as 368 B received against 5.30
KiB sent - and every other readout on the device calls it connected.

Anti-flap is a settling window, not a rate limit: a changed preference must
hold BEARER_SETTLE (20s) before it produces a command, and the clock restarts
whenever the candidate itself changes. A steady state emits nothing, so it is
safe to evaluate every tick. Tests cover the flap that never settles, the
settled state that must not re-emit, and the dead network that must not be
flailed at.

NetworkManager stays the single writer of routes (acceptance #6): preference
is expressed as ipv4.route-metric plus device reapply, never ip route, and
reapply rather than up/down so a correction is not itself an NM event. The one
route sessiond owns is the WireGuard endpoint /32, which NM has no opinion
about.

The tunnel toggle stays the user's. tunnel_underlay() returns None when the
tunnel is off - this decides how traffic is carried, not whether the tunnel
exists.

Probed off the existing clock every 5th tick, with the lock released; no new
timer (acceptance #6) and no subprocess spawned under the state mutex.
2026-08-01 14:08:14 -04:00

558 lines
23 KiB
Rust

//! 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`).
/// 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;
/// Why a request was refused, in a form a caller can branch on.
///
/// Refusals were prose. A human reads "a live shell owns the session lock" and
/// knows what to do; an agent composing several verbs into one intent
/// (doctrine §13) cannot branch on a sentence. The reason stays — it is the
/// only thing that explains *this* refusal rather than its class — but the
/// code is what a chain reads.
///
/// The taxonomy is deliberately small. RedFlag's executor exit codes are the
/// precedent (`NET_LAYER_PLAN.md` cites them for the same reason): a fixed
/// vocabulary a caller can exhaust, not a growing list it must keep up with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefusalCode {
/// The op does not exist, or the line did not parse as a request.
/// Retrying is pointless; the caller is speaking a vocabulary this daemon
/// does not have. `describe` is the cure.
UnsupportedOp,
/// The op exists and the arguments are wrong — out of range, mismatched,
/// or self-contradictory. Retrying with the same arguments is pointless.
InvalidArgument,
/// The op and arguments are fine and the machine's current state forbids
/// it. This is the one a chain most often wants: it means *not now*, and
/// the state that forbade it is worth reading before deciding what next.
RefusedByState,
/// The caller may not do this. Reserved — nothing issues it yet, because
/// caller identity is uid 1000 for everything (audit P3). It exists so
/// that when capability tokens land, callers already branch on it.
NotPermitted,
/// A dependency this op needs is absent — no compositor, no PAM stack, no
/// sensor. Not the caller's fault and possibly transient.
Unavailable,
}
impl RefusalCode {
pub fn as_str(self) -> &'static str {
match self {
RefusalCode::UnsupportedOp => "unsupported_op",
RefusalCode::InvalidArgument => "invalid_argument",
RefusalCode::RefusedByState => "refused_by_state",
RefusalCode::NotPermitted => "not_permitted",
RefusalCode::Unavailable => "unavailable",
}
}
}
/// One entry in the verb table `describe` returns.
///
/// Serialize-only: it is a const table on this side of the wire and JSON on the
/// other, so the borrowed `&'static` fields never need to come back.
#[derive(Debug, Clone, Serialize)]
pub struct VerbDoc {
/// The `op` string, exactly as it goes on the wire.
pub op: &'static str,
/// True when the op changes something. A caller planning a chain needs to
/// know which steps are reversible reads and which are not.
pub mutates: bool,
/// What it does, in one line.
pub summary: &'static str,
/// The refusal codes this op can return. A chain plans its branches from
/// this rather than discovering them by being refused.
pub refuses: &'static [RefusalCode],
/// A minimal request line that parses. Knowing an op exists is not enough
/// to call it — `panel` needs `on`, `sensor_input` needs a source and a
/// matching value — and a caller should not have to discover required
/// fields by being refused. The test round-trips every one of these, so an
/// example that stops parsing fails the build rather than the agent.
pub example: &'static str,
}
/// The verb table. Every `Request` variant appears here; a test enforces it.
///
/// This exists because the surface was not enumerable. An agent that owns the
/// device (doctrine §13) has to be able to ask what it may do rather than be
/// told, and a verb that is missing from this table is as unreachable as one
/// that was never written.
pub const VERBS: &[VerbDoc] = &[
VerbDoc {
op: "describe",
mutates: false,
summary: "this table — the verbs, which ones mutate, how each can refuse",
refuses: &[],
example: r#"{"op":"describe"}"#,
},
VerbDoc {
op: "status",
mutates: false,
summary: "liveness and who currently owns the session lock",
refuses: &[],
example: r#"{"op":"status"}"#,
},
VerbDoc {
op: "shell_ready",
mutates: true,
summary: "the shell announces itself; this connection becomes the heartbeat",
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"shell_ready"}"#,
},
VerbDoc {
op: "locked_ack",
mutates: true,
summary: "the shell's lock surface reached compositor-acknowledged secure",
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"locked_ack"}"#,
},
VerbDoc {
op: "lock",
mutates: true,
summary: "sessiond takes the session lock itself; refused while a live shell owns it",
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"lock"}"#,
},
VerbDoc {
op: "device_state",
mutates: false,
summary: "the unified state machine: state, panel, evidence, confidence, health",
refuses: &[],
example: r#"{"op":"device_state"}"#,
},
VerbDoc {
op: "sensor_input",
mutates: true,
summary: "report a sensor reading as evidence; never an authority (doctrine §9)",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"sensor_input","source":"proximity","value":{"near":true}}"#,
},
VerbDoc {
op: "input",
mutates: true,
summary: "real user input happened; resets the idle budget",
// A squeeze is refusable — a pocket can produce one, and proximity is
// the veto (DEVICE-STATE-MACHINE §4). A power button never is.
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"input","trigger":"touch"}"#,
},
VerbDoc {
op: "button",
mutates: true,
summary: "a hardware button edge; the machine recognises taps and holds from these",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"button","button":"power","edge":"down"}"#,
},
VerbDoc {
op: "panel",
mutates: true,
summary: "the DPMS executor reports the panel's real power state",
refuses: &[],
example: r#"{"op":"panel","on":false}"#,
},
VerbDoc {
op: "forensic_log",
mutates: false,
summary: "recent trail entries from the in-memory buffer",
refuses: &[],
example: r#"{"op":"forensic_log","count":50}"#,
},
VerbDoc {
op: "subscribe",
mutates: false,
summary: "this connection becomes an event stream of notable belief changes",
refuses: &[],
example: r#"{"op":"subscribe"}"#,
},
VerbDoc {
op: "get_policy",
mutates: false,
summary: "read the timed policy — the Auto-Lock shaped settings",
refuses: &[],
example: r#"{"op":"get_policy"}"#,
},
VerbDoc {
op: "set_policy",
mutates: true,
summary: "change the timed policy; persisted, so a setting survives restart",
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
},
VerbDoc {
op: "bearer",
mutates: false,
summary: "which link carries traffic, and whether the tunnel is being answered",
refuses: &[],
example: r#"{"op":"bearer"}"#,
},
];
/// A request plus the caller's declared intent.
///
/// The wire shape is unchanged — `{"op":"panel","on":false}` still parses,
/// because `intent` is optional and flattened alongside the tagged enum. A
/// caller composing several verbs into one decision (doctrine §13) sets it
/// once per verb and the trail joins the leaves back to the intent that
/// produced them: `{"op":"panel","on":false,"intent":"quiet the room"}`.
///
/// Declared, never verified. It is a label the caller supplies about itself,
/// so it is evidence in exactly the sense doctrine §9 means — useful for
/// reconstruction, never a basis for a decision. Nothing branches on it.
#[derive(Debug, Clone, Deserialize)]
pub struct Envelope {
#[serde(flatten)]
pub request: Request,
#[serde(default)]
pub intent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
/// The verb table — what this daemon can be asked, which ops mutate, and
/// how each can refuse. A caller composing verbs reads this first.
Describe,
/// 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,
/// Full device state: the unified state machine's current state,
/// sensor evidence confidence, and doze tier. Superset of Status.
DeviceState,
/// Report a sensor reading to the device state machine.
/// The shell or a sensor daemon feeds proximity/accel/touch evidence.
SensorInput(SensorInput),
/// Real user input happened — touch, key, power button, dt2w wake.
/// Resets the idle budget that the lock-blank rule counts against.
/// This is the wire that lets the machine tell "the user is looking at
/// the lock screen" from "the lock screen has been lit for ten minutes."
Input { trigger: Option<InputTrigger> },
/// A hardware button went down or came up. Edges only — the caller reports
/// what the hardware did and nothing else.
///
/// `blueline-power-button` used to *be* the policy: it read a state file,
/// asked the shell to lock over `qs ipc`, polled `session state` twenty
/// times at 100 ms grepping for `"locked": true`, then called
/// `blueline-screen-toggle off` itself. That is a path to a dark panel that
/// never routes through `request_blank()` — so LOCK-DPMS-LESSONS §1's
/// "every path routes through it, an invariant not a coincidence" had a hole
/// in it, and the hole was the most-used control on the device. It also made
/// the button the eighth blind actor of DEVICE-STATE-MACHINE §1, with its
/// own copy of the lock-then-blank ordering and its own panel truth.
///
/// Now it reports and stops deciding. Recognition — tap, double, triple,
/// hold — happens in the machine, because a gesture is an accumulation over
/// time and time is the one thing a fire-and-forget script does not have.
Button { button: Button, edge: ButtonEdge },
/// The DPMS executor reports the panel's real power state. The machine
/// keeps the panel as a field, not a state: "locked with the screen off"
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
/// glance. Reported, never assumed — the executor owns the panel.
Panel { on: bool },
/// Query recent forensic log entries. Returns the last N entries
/// from the in-memory forensic buffer for post-hoc analysis.
ForensicLog { count: Option<usize> },
/// Turn this connection into an event stream. After the `ok`, the daemon
/// pushes one trail entry per line, unprompted, for as long as the caller
/// holds the socket open.
///
/// The point is the *filter*, not the transport. `forensic_log` already
/// hands over everything; a consumer that polls it and diffs is doing the
/// machine's job for it, badly and late. Only NOTABLE entries are pushed —
/// state transitions, a source that died or came back, a violated
/// guarantee, sensors that contradict each other. Heartbeats, ticks and
/// ordinary readings never cross.
///
/// That compression IS the product, in the same sense six strain gauges at
/// 100 Hz becoming one `squeeze` bit is the product. An agent that received
/// every reading would be reading drivers, and doctrine's load-bearing rule
/// is that interpretation may consume evidence but never a driver — reading
/// the raw stream is how gait, typing and identity get inferred from data
/// that looks innocent per-field (SECURITY-AUDIT P1). The narrowness here is
/// a security control, not a performance one.
Subscribe,
/// Read the timed policy — the Auto-Lock shaped settings.
GetPolicy,
/// Bearer posture: per-link health, the tunnel's handshake state, and
/// which bearer the machine has settled on.
///
/// A read, deliberately. The corresponding write does not exist and should
/// not: the thing that decides which link carries traffic is `tick()`, and
/// a verb that let a caller set it would be the second writer TASK-49
/// acceptance #6 forbids. What a caller can do is read this and change the
/// *policy* (`set_policy`), which is the difference between steering the
/// machine and reaching around it.
Bearer,
/// Change the timed policy. Every field is optional; omitted fields keep
/// their current value. This is the seam the Settings control center reads
/// and writes, so a control there is a view over the owning daemon rather
/// than a switch that only looks like it did something (TASK-19's rule).
SetPolicy {
/// Seconds; `Some(0)` means never blank.
lock_blank_after_secs: Option<u64>,
lock_blank_after_held_secs: Option<u64>,
dim_grace_secs: Option<u64>,
evidence_ttl_secs: Option<u64>,
dim_warning: Option<bool>,
/// Seconds a pending blank waits for its lock ack before going dark
/// anyway. Never 0 — a blank that waits for nothing is the ordering
/// bug this field exists to close.
lock_ack_budget_secs: Option<u64>,
/// Seconds; `Some(0)` means never blank an unlocked session here.
unlocked_blank_after_secs: Option<u64>,
},
}
/// What produced a user input. Recorded so the forensic trail can tell a
/// deliberate power-button press from an accidental pocket touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputTrigger {
Touch,
Key,
PowerButton,
DoubleTapToWake,
/// Active Edge — the frame was squeezed. Deliberate intent, like a button,
/// but unlike a button it is a sensor and it can be produced by a pocket:
/// a squeezed chassis is exactly what a phone in a tight pocket is. So it
/// carries the same proximity veto as tap-to-wake (`suppress_wake`), and
/// for the same reason — see DEVICE-STATE-MACHINE §4, "a covered sensor is
/// the right veto for the one wake a pocket can produce by itself".
Squeeze,
Unknown,
}
/// Which hardware button. Volume is here because the recognizer is per-button
/// and costs nothing to reuse; nothing binds them yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Button {
Power,
VolumeUp,
VolumeDown,
}
impl Button {
pub fn as_str(self) -> &'static str {
match self {
Button::Power => "power",
Button::VolumeUp => "volume_up",
Button::VolumeDown => "volume_down",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonEdge {
Down,
Up,
}
/// What the machine made of a run of edges.
///
/// `Hold` fires while the button is still down — a hold you only learn about on
/// release is a hold that cannot light anything up while you are waiting, and
/// waiting with no feedback is how a user decides the device is broken and lets
/// go. Taps resolve on the multi-tap window expiring, which is the opposite
/// trade and the right one: a double-tap must not first fire a single.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonGesture {
Tap,
DoubleTap,
TripleTap,
/// Held past the first threshold, still down.
Hold,
/// Held past the second, still down. The "you may let go now" point for
/// anything destructive — nothing binds it yet.
LongHold,
}
impl ButtonGesture {
pub fn as_str(self) -> &'static str {
match self {
ButtonGesture::Tap => "tap",
ButtonGesture::DoubleTap => "double_tap",
ButtonGesture::TripleTap => "triple_tap",
ButtonGesture::Hold => "hold",
ButtonGesture::LongHold => "long_hold",
}
}
}
/// A sensor reading fed to the device state machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorInput {
/// Which sensor produced this reading.
pub source: SensorSource,
/// The reading value.
pub value: SensorValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorSource {
Proximity,
Accelerometer,
Light,
Touch,
}
impl SensorSource {
/// The name this source is known by in the forensic trail and in logs.
/// Matches the `evidence_fresh` / `sensor_health` keys so a reader can
/// join them without a translation table.
pub fn as_str(self) -> &'static str {
match self {
SensorSource::Proximity => "proximity",
SensorSource::Accelerometer => "accel",
SensorSource::Light => "light",
SensorSource::Touch => "touch",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorValue {
/// Proximity: near or far.
Near(bool),
/// Accelerometer: moving or stationary.
Moving(bool),
/// Light: changing or stable.
Changing(bool),
/// Touch: active or inactive.
Active(bool),
}
/// 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 every_request_variant_is_in_the_verb_table() {
// The table is the vocabulary an agent composes over (doctrine §13).
// A verb missing from it is as unreachable as one never written, so
// this test is the thing that keeps them from drifting apart.
//
// Checked by round-tripping each op string through the deserializer:
// if `describe` advertises an op the daemon cannot parse, the entry is
// a lie, and if a variant is added without a table entry the count
// below fails.
for v in VERBS {
let parsed: Result<Request, _> = serde_json::from_str(v.example);
assert!(
parsed.is_ok(),
"describe advertises `{}` with example `{}`, which the daemon cannot parse: {:?}",
v.op,
v.example,
parsed.err()
);
}
// And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc.
assert_eq!(VERBS.len(), 15, "a Request variant was added without a VerbDoc");
}
#[test]
fn the_envelope_is_backward_compatible() {
// Every line written before intent existed must still parse, or the
// shell and the reporters stop talking to the daemon on upgrade —
// TASK-28's failure mode, caused by the fix for it.
let e: Envelope = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
assert!(matches!(e.request, Request::Status));
assert_eq!(e.intent, None);
let e: Envelope =
serde_json::from_str(r#"{"op":"panel","on":false,"intent":"quiet the room"}"#).unwrap();
assert!(matches!(e.request, Request::Panel { on: false }));
assert_eq!(e.intent.as_deref(), Some("quiet the room"));
}
#[test]
fn a_refusal_code_survives_the_wire() {
// A chain branches on the code, so it has to arrive intact.
let raw = serde_json::to_string(&RefusalCode::RefusedByState).unwrap();
assert_eq!(raw, r#""refused_by_state""#);
let back: RefusalCode = serde_json::from_str(&raw).unwrap();
assert_eq!(back, RefusalCode::RefusedByState);
}
#[test]
fn phase_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&Phase::AwaitingShellLock).unwrap(),
r#""awaiting_shell_lock""#
);
}
}