diff --git a/AGENTS.md b/AGENTS.md index b8bdb48..d48e094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Start with `docs/README.md`. Read `docs/IMPLEMENTATION_PLAN.md`, `docs/ARCHITECT - Keep shared wire types in `crates/contracts`, and keep `schemas/*.json` in sync with them; the contract tests enforce matching fields and enum strings. - Keep provider lifecycle behavior in `crates/provider-sdk`. - Keep the capability registry, policy, broker lifecycle, and experiment journal in `crates/control-plane`. +- Keep the local IPC transport, framing, and peer-authentication seams behind traits in `crates/ipc` (Unix domain socket now, Windows named pipe later); the privileged broker in `apps/broker` composes them over the control plane and enforces peer auth, catalog policy, and single-owner-per-knob. The non-`Send` control plane is confined to one worker thread reached through a `Send` handle. - Keep the independent crash and lease recovery path in `apps/watchdog`; it reads the journal owned by `crates/control-plane` and writes only its own restore-outcome records, never the schema. - Keep the measurement model, immutable evaluator, and replayable trial records in `apps/experiment-runner`; the evaluator stays a pure function of recorded samples and fixed bounds. - Put provider-specific code in one `sidecars/` package; sidecars may not import each other. diff --git a/Cargo.lock b/Cargo.lock index 1347832..b8628ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.2.67" @@ -79,6 +85,19 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "fpsmaxxing-broker" version = "0.1.0" +dependencies = [ + "fpsmaxxing-contracts", + "fpsmaxxing-control-plane", + "fpsmaxxing-ipc", + "fpsmaxxing-mock-provider", + "fpsmaxxing-provider-sdk", + "rusqlite", + "rustix", + "serde_json", + "tempfile", + "thiserror", + "tokio", +] [[package]] name = "fpsmaxxing-cli" @@ -138,6 +157,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "fpsmaxxing-ipc" +version = "0.1.0" +dependencies = [ + "fpsmaxxing-contracts", + "rustix", + "serde_json", + "tempfile", + "thiserror", + "tokio", +] + [[package]] name = "fpsmaxxing-mock-provider" version = "0.1.0" @@ -244,12 +275,29 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.33" @@ -435,6 +483,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -502,6 +560,32 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -514,6 +598,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.126" diff --git a/Cargo.toml b/Cargo.toml index 869c257..2d9b3f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "apps/watchdog", "crates/contracts", "crates/control-plane", + "crates/ipc", "crates/provider-sdk", "sidecars/mock-provider", "xtask", @@ -28,6 +29,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" rusqlite = { version = "0.38", features = ["bundled"] } +rustix = { version = "1", features = ["process"] } +tempfile = "3" +tokio = { version = "1", default-features = false } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 259c289..986a742 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,9 @@ The repository currently includes: - A control-plane crate holding the capability registry, bounded policy, broker lifecycle, and durable SQLite experiment journal - A working stdio MCP gateway that serves the mock path end to end - A CLI `doctor` command that reports gateway and journal status +- A privileged broker that serves the control plane over an authenticated local IPC boundary - An independent watchdog that restores prior state from the journal after a crash or lease expiry, on the Linux-safe mock path - A deterministic experiment runner that gates measured trials through an immutable evaluator and replays them from the journal alone -- Scaffolds for the privileged broker - OSS governance, security policy, issue templates, and CI - An organized [documentation index](docs/README.md) with architecture, plans, threat model, and provider guides @@ -108,6 +108,7 @@ printf '%s\n' \ The gateway speaks line-delimited JSON-RPC (MCP) on stdio and journals every lifecycle stage attempt plus a terminal outcome to `fpsmaxxing-journal.sqlite` by default. Override the journal location with `--journal ` or the `FPSMAXXING_JOURNAL_PATH` environment variable; `doctor` reads the same variable when reporting journal status. +`FPSMAXXING_JOURNAL_PATH` belongs to the gateway and the CLI only - the privileged broker deliberately does not read it. Run the watchdog against the same journal to reclaim leaked experiments: `cargo run -p fpsmaxxing-watchdog -- --once` performs a single expired-lease pass and `--recover-all` rolls back every unclosed experiment after a crash. It accepts the same `--journal ` and `FPSMAXXING_JOURNAL_PATH` overrides, plus `--interval ` for its steady-state poll loop. @@ -115,6 +116,55 @@ It accepts the same `--journal ` and `FPSMAXXING_JOURNAL_PATH` overrides, The experiment runner measures a baseline and a candidate against a deterministic stand-in for live telemetry, gates the candidate's lifecycle on the immutable evaluator's verdict, journals the trial with its spec, samples, and verdict, then replays it from the journal alone and checks the re-evaluated verdict against the recorded one. It is a demonstration binary rather than an MCP tool, takes no arguments, and journals to an in-memory SQLite database, so it leaves nothing on disk and exits non-zero if a replay diverges from the journal, falls outside the policy gate, or the broker refuses a promoted lifecycle. +### Privileged broker + +The `fpsmaxxing-broker` binary is the trusted side of the local IPC boundary. +It owns the control plane and serves capability discovery and the bounded provider lifecycle to authenticated local peers over a Unix domain socket; only the Linux transport is implemented, so the binary refuses to run elsewhere. +The gateway does not connect to it yet - it still opens an in-process control plane of its own - so the broker path is driven by the `BrokerClient` in `crates/ipc` and its end-to-end tests rather than by the MCP command above. + +Run it with no arguments; it creates and vets its own private directory for the socket and the journal. + +```bash +cargo run -p fpsmaxxing-broker +cargo run -p fpsmaxxing-broker -- --help +``` + +An explicit path is never created for you, and the directory holding it must already be owned by the broker or root and closed to every other user (mode `0700`), so create it first: + +```bash +mkdir -p "$HOME/.local/state/fpsmaxxing" && chmod 700 "$HOME/.local/state/fpsmaxxing" +cargo run -p fpsmaxxing-broker -- \ + --socket "$HOME/.local/state/fpsmaxxing/broker.sock" \ + --journal "$HOME/.local/state/fpsmaxxing/journal.sqlite" +``` + +Do not put that directory at `/run/fpsmaxxing`. +That is the privileged broker's own private directory, and it is the one directory held to exact ownership: root ownership satisfies an explicit `--socket` or `--journal` parent, but a broker accepts its private directory only when it owns that itself. +Creating `/run/fpsmaxxing` as your user therefore leaves a later root broker refusing to start until it is chowned to root or removed. +A root broker creates and vets it on its own. +A systemd unit needs both `RuntimeDirectory=fpsmaxxing` and `RuntimeDirectoryMode=0700`: `RuntimeDirectoryMode` defaults to `0755`, and the broker validates an existing private directory rather than correcting its mode, so a unit that omits the mode is refused on every start. +The broker still establishes its private directory even when both paths are given, because the single-instance lock lives there, so it also needs to be able to create `$XDG_RUNTIME_DIR/fpsmaxxing` - or `/run/fpsmaxxing`, when that variable is unset - on every start. + +| Setting | Flag | Environment variable | Default | +| --- | --- | --- | --- | +| IPC socket | `--socket ` | `FPSMAXXING_BROKER_SOCKET` | `/broker.sock` | +| Audit journal | `--journal ` | `FPSMAXXING_BROKER_JOURNAL_PATH` | `/journal.sqlite` | + +A flag wins over its environment variable, and both are broker-specific so nothing the gateway or CLI exports can move the privileged journal. +The private directory is `$XDG_RUNTIME_DIR/fpsmaxxing`, or `/run/fpsmaxxing` when `XDG_RUNTIME_DIR` is unset, is not absolute, or the broker runs as root. +The broker creates it mode `0700` whether or not an override moved the socket and the journal out of it, because the single-instance lock lives there, and refuses to start unless it and every directory above it are owned by the broker or root and are not writable by anyone else. +A path from a flag or an environment variable is held to the same bar: it must be absolute, the directory holding it must exist, and the whole chain above it is vetted, so an override cannot place a privileged socket or audit journal somewhere another user can reach it. +Give the socket and the journal a directory of their own at mode `0700`, owned by the broker or root - the default private directory already is one. +That directory is held higher than the ancestors above it, in two ways. +The sticky bit does not excuse group or world write there: sticky stops another user removing the broker's socket or journal, but not creating either one first and keeping ownership of it, so a shared root like `/tmp` is refused. +Nor is group or world traversal excused: the socket's own mode cannot be pinned, so a merely traversable directory like `/run` would put every local user in front of it, and it is refused too. +The journal file itself is created mode `0600`, and SQLite's rollback journal and write-ahead log inherit that. +Only one broker may run per user: it takes an exclusive lock on `/broker.lock` before the journal is opened and before the socket is bound, so a second broker exits non-zero without having touched either. +That lock is not derived from `--socket` or `--journal`, so neither of those, nor the environment variables behind them, buys a second instance - the knobs two brokers would drive belong to the machine, not to the paths they were handed. +`XDG_RUNTIME_DIR` does move it, because it moves the private directory it sits in. +A root broker ignores that variable, so the privileged broker always locks `/run/fpsmaxxing/broker.lock` and one instance is guaranteed; an unprivileged user who runs two brokers under two different values for it gets two locks and two brokers, which is a dev-path concession rather than a boundary, since a same-uid caller is already admitted by the ACL. +The kernel releases the lock when the process ends, crash included, so a restart needs no cleanup - it rebinds over the socket file the previous run left behind. + ## Architecture ```text diff --git a/apps/broker/Cargo.toml b/apps/broker/Cargo.toml index c4d6d6e..7d65900 100644 --- a/apps/broker/Cargo.toml +++ b/apps/broker/Cargo.toml @@ -7,5 +7,23 @@ repository.workspace = true rust-version.workspace = true publish = false +[dependencies] +fpsmaxxing-contracts.workspace = true +fpsmaxxing-control-plane = { path = "../../crates/control-plane" } +fpsmaxxing-ipc = { path = "../../crates/ipc" } +fpsmaxxing-mock-provider = { path = "../../sidecars/mock-provider" } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "io-util", "time"] } + +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["fs"] } + +[dev-dependencies] +fpsmaxxing-provider-sdk.workspace = true +rusqlite.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["test-util"] } + [lints] workspace = true diff --git a/apps/broker/src/lib.rs b/apps/broker/src/lib.rs new file mode 100644 index 0000000..2b3729d --- /dev/null +++ b/apps/broker/src/lib.rs @@ -0,0 +1,857 @@ +//! The privileged broker: the trusted side of the IPC boundary. +//! +//! The broker owns the [`ControlPlane`] (provider plus durable journal) and +//! exposes exactly two operations - capability discovery and a bounded provider +//! lifecycle - to authenticated local peers. It layers three fail-closed checks +//! over the control plane: +//! +//! - peer authentication ([`fpsmaxxing_ipc::PeerAuthorizer`]) before any request +//! is read, so an unauthenticated or foreign peer is refused; +//! - a capability catalog check, so a raw shell command, an arbitrary Registry +//! path, or any out-of-catalog id is rejected before the provider is touched; +//! - single-owner-per-knob enforcement ([`OwnershipLedger`]), so a second +//! concurrent owner of a setting is refused. +//! +//! The [`ControlPlane`] is not `Send`, so it is confined to one worker thread +//! ([`spawn_service`]) reached through a `Send` [`BrokerHandle`]. Connections are +//! accepted asynchronously over the [`fpsmaxxing_ipc::LocalTransport`] seam - a +//! Unix domain socket today, a Windows named pipe later - and each connection's +//! decoded requests are dispatched to that worker. +//! +//! The broker fails fast rather than degrading. If the worker thread ever stops, +//! including by panicking mid-lifecycle and leaving provider state applied and +//! un-rolled-back, [`serve`] returns an error instead of answering every later +//! request with an internal fault, and the process exits non-zero. A fatal +//! accept error does the same. Deploy the broker under a supervisor (systemd +//! `Restart=on-failure` or equivalent) so that exit becomes a restart, and let +//! the watchdog own recovery of any state left behind. + +mod ownership; + +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::io; +use std::sync::Arc; +use std::time::Duration; + +use fpsmaxxing_contracts::ChangeRequest; +use fpsmaxxing_contracts::ipc::{ + BrokerErrorKind, BrokerOp, BrokerRequest, BrokerResponse, LifecycleReport, +}; +use fpsmaxxing_control_plane::{ControlPlane, ControlPlaneError}; +use fpsmaxxing_ipc::{ + Accepted, FrameError, LocalTransport, PeerAuthorizer, read_frame, write_frame, +}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot}; + +pub use ownership::{OwnerConflict, OwnershipGuard, OwnershipLedger}; + +/// Backlog of in-flight requests queued for the single control-plane worker. +const REQUEST_BACKLOG: usize = 64; + +/// Connections served at once; further peers wait in the transport's backlog. +pub const MAX_CONNECTIONS: usize = 32; + +/// How long a served connection may stall in one direction before it is closed. +/// +/// Both directions are bounded by it: an idle peer holding a descriptor, a peer +/// that declares a large frame and then stalls while the reader holds its +/// buffer, and a peer that stops draining its responses until the broker's own +/// write blocks. Without the last of these a peer that never reads could pin a +/// connection slot in `write_all` forever. +pub const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long a peer refused by the ACL has to take its rejection frame. +/// +/// A refused peer has not authenticated, so it must not inherit +/// [`CONNECTION_IDLE_TIMEOUT`]: one that never reads would otherwise hold its +/// connection slot for the whole idle budget, and a loop of them could exhaust +/// [`MAX_CONNECTIONS`] before any peer has authenticated. A local peer that is +/// reading takes a rejection frame from the socket buffer immediately, so this +/// is generous for the legitimate case and cheap for the hostile one. +pub const REJECTION_WRITE_TIMEOUT: Duration = Duration::from_millis(250); + +/// Pause after a per-connection accept failure, so a repeated one cannot spin. +const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(50); + +/// What a peer refused by the ACL is told, in place of why. +/// +/// [`fpsmaxxing_ipc::AuthError`] names both the peer's uid and the broker's own, +/// and the caller it would reach has not authenticated, so its `Display` must +/// not cross the boundary: that is the same rule [`wire_error`] applies to host +/// detail below. The uid pair is traced locally instead. +const UNAUTHORIZED_MESSAGE: &str = "peer is not authorized"; + +/// Longest run of client-supplied text echoed back in an error message. +/// +/// Text the client chose is bounded only by [`fpsmaxxing_ipc::MAX_FRAME_BYTES`] +/// on the way in. Echoed verbatim it would push the response past that same +/// limit, `write_frame` would refuse it, and the peer would get a dropped +/// connection instead of the typed error this boundary promises. +const MAX_ECHOED_BYTES: usize = 120; + +/// Shortens client-supplied text to [`MAX_ECHOED_BYTES`] for an error message. +/// +/// Every message built from something the client sent goes through this, so no +/// single echo site has to remember the frame limit. +fn echoed(text: &str) -> String { + if text.len() <= MAX_ECHOED_BYTES { + return text.to_owned(); + } + let cut = (0..=MAX_ECHOED_BYTES) + .rev() + .find(|index| text.is_char_boundary(*index)) + .unwrap_or_default(); + format!("{}...", &text[..cut]) +} + +/// The request handler at the trusted end of the IPC boundary. +/// +/// It owns the control plane and enforces the broker's fail-closed policy and +/// single-owner-per-knob invariant. Because it holds the non-`Send` control +/// plane, one instance lives on a dedicated worker thread; use [`spawn_service`] +/// to create it there and reach it through a [`BrokerHandle`]. +pub struct BrokerService { + plane: RefCell, + ledger: Arc, + catalog: BTreeSet, +} + +impl BrokerService { + /// Builds a broker service around an opened control plane. + /// + /// The capability catalog is captured once from the provider manifest and + /// used to reject out-of-catalog requests before the provider is touched. + #[must_use] + pub fn new(plane: ControlPlane, ledger: Arc) -> Self { + let catalog = plane + .capabilities() + .capabilities + .iter() + .map(|capability| capability.id.clone()) + .collect(); + Self { + plane: RefCell::new(plane), + ledger, + catalog, + } + } + + /// Handles one decoded request and returns a typed response. + /// + /// This is synchronous and may block on the durable journal; it runs on the + /// worker thread created by [`spawn_service`]. + #[must_use] + pub fn handle(&self, request: BrokerRequest) -> BrokerResponse { + match request.op { + BrokerOp::Discover => self.discover(), + BrokerOp::RunLifecycle => self.run_lifecycle(request.owner, request.change), + } + } + + fn discover(&self) -> BrokerResponse { + BrokerResponse::capabilities(self.plane.borrow().capabilities().clone()) + } + + fn run_lifecycle( + &self, + owner: Option, + change: Option, + ) -> BrokerResponse { + let Some(owner) = owner.filter(|owner| !owner.trim().is_empty()) else { + return BrokerResponse::error( + BrokerErrorKind::Malformed, + "run-lifecycle requires a non-empty owner", + ); + }; + let Some(change) = change else { + return BrokerResponse::error( + BrokerErrorKind::Malformed, + "run-lifecycle requires a change", + ); + }; + if !self.catalog.contains(&change.capability_id) { + return BrokerResponse::error( + BrokerErrorKind::UnknownCapability, + format!( + "capability {} is not in the catalog", + echoed(&change.capability_id) + ), + ); + } + let _guard = match self.ledger.acquire(&change.capability_id, &owner) { + Ok(guard) => guard, + Err(conflict) => { + return BrokerResponse::error( + BrokerErrorKind::OwnerConflict, + echoed(&conflict.to_string()), + ); + } + }; + let outcome = self.plane.borrow_mut().run_lifecycle(&change); + match outcome { + Ok(result) => BrokerResponse::lifecycle(LifecycleReport { + provider_id: result.provider_id, + preview: result.preview, + verified: result.verified, + rolled_back: result.rolled_back, + }), + Err(error) => wire_error(&error), + } + } +} + +/// Reduces a control-plane failure to a response that may cross the boundary. +/// +/// One match decides both the wire kind and the message, so the two cannot +/// disagree about whether a variant is client-caused when a new one is added. +/// +/// Only the two client-caused variants describe the caller's own request, so +/// only they are forwarded, and then only through [`echoed`] because they can +/// quote the request back. Every other variant wraps a `SQLite`, serialization, +/// or provider error whose `Display` can carry journal paths and other host +/// detail, which [`fpsmaxxing_contracts::ipc::BrokerErrorBody`] promises never +/// to expose: those are reduced to the stable machine-readable stage kind and +/// the full error is traced locally instead. +fn wire_error(error: &ControlPlaneError) -> BrokerResponse { + match error { + ControlPlaneError::UnknownCapability(_) => BrokerResponse::error( + BrokerErrorKind::UnknownCapability, + echoed(&error.to_string()), + ), + ControlPlaneError::PolicyDenied(_) => { + BrokerResponse::error(BrokerErrorKind::PolicyDenied, echoed(&error.to_string())) + } + _ => { + eprintln!("fpsmaxxing-broker: lifecycle failed: {error}"); + BrokerResponse::error( + BrokerErrorKind::LifecycleFailed, + format!("lifecycle failed: {}", error.kind()), + ) + } + } +} + +struct Job { + request: BrokerRequest, + respond: oneshot::Sender, +} + +/// A cloneable, `Send` handle to the control-plane worker thread. +/// +/// Connection tasks dispatch decoded requests through this handle; the worker +/// serializes them onto the single control plane and returns typed responses. +#[derive(Clone)] +pub struct BrokerHandle { + jobs: mpsc::Sender, +} + +impl BrokerHandle { + /// Dispatches one request to the worker and awaits its typed response. + /// + /// If the worker has stopped, a typed [`BrokerErrorKind::Internal`] response + /// is returned rather than an error, so a connection loop stays uniform. + pub async fn dispatch(&self, request: BrokerRequest) -> BrokerResponse { + let (respond, response) = oneshot::channel(); + if self.jobs.send(Job { request, respond }).await.is_err() { + return BrokerResponse::error(BrokerErrorKind::Internal, "broker worker has stopped"); + } + response.await.unwrap_or_else(|_| { + BrokerResponse::error( + BrokerErrorKind::Internal, + "broker worker dropped the request", + ) + }) + } + + /// Resolves once the control-plane worker thread has stopped. + /// + /// The worker owns the receiving end of the job channel, so it is dropped + /// when the thread ends for any reason - including a panic unwinding out of + /// [`BrokerService::handle`]. [`serve`] waits on this so a broker that has + /// permanently lost its control plane exits instead of staying up and + /// answering every request with an internal fault. + pub async fn worker_stopped(&self) { + self.jobs.closed().await; + } +} + +/// Spawns the worker thread that owns the control plane and returns a handle. +/// +/// The non-`Send` [`BrokerService`] is constructed by `build` on the worker +/// thread that will own it, so the control plane never crosses a thread +/// boundary. The returned future resolves once `build` reports success. +/// +/// # Errors +/// +/// Returns an error if the worker thread cannot be spawned, `build` fails, or +/// the worker exits before reporting readiness. +pub async fn spawn_service(build: F) -> io::Result +where + F: FnOnce() -> io::Result + Send + 'static, +{ + let (jobs_tx, mut jobs_rx) = mpsc::channel::(REQUEST_BACKLOG); + let (ready_tx, ready_rx) = oneshot::channel::>(); + std::thread::Builder::new() + .name("fpsmaxxing-broker-worker".to_owned()) + .spawn(move || match build() { + Ok(service) => { + if ready_tx.send(Ok(())).is_err() { + return; + } + while let Some(job) = jobs_rx.blocking_recv() { + let response = service.handle(job.request); + let _ = job.respond.send(response); + } + } + Err(error) => { + let _ = ready_tx.send(Err(error)); + } + })?; + match ready_rx.await { + Ok(Ok(())) => Ok(BrokerHandle { jobs: jobs_tx }), + Ok(Err(error)) => Err(error), + Err(_) => Err(io::Error::other("broker worker exited during startup")), + } +} + +/// Serves broker requests over `transport` until the broker can no longer run. +/// +/// Each connection is authenticated once against `authorizer` before any request +/// is read, then handled on its own task and dispatched to `broker`. A +/// per-connection fault - including an accept error that aborted a single +/// connection - ends only that connection. At most [`MAX_CONNECTIONS`] are +/// served concurrently, and a connection that stalls for +/// [`CONNECTION_IDLE_TIMEOUT`] in either direction - a peer that sends nothing, +/// or one that stops reading its responses - is closed, so a peer cannot pin a +/// task, a descriptor, or a frame buffer forever. +/// +/// This never returns `Ok`: it runs until a fatal condition, then reports it so +/// the process can exit non-zero and a supervisor can restart it. +/// +/// # Errors +/// +/// Returns an error when the control-plane worker thread has stopped or the +/// transport fails in a way that is not specific to one connection. +pub async fn serve( + transport: T, + broker: BrokerHandle, + authorizer: Arc, +) -> io::Result<()> +where + T: LocalTransport, +{ + let connections = Arc::new(Semaphore::new(MAX_CONNECTIONS)); + loop { + let (permit, accepted) = tokio::select! { + biased; + () = broker.worker_stopped() => { + return Err(io::Error::other( + "broker control-plane worker stopped; restart the broker process", + )); + } + admitted = admit(&transport, &connections) => match admitted { + Ok(admitted) => admitted, + Err(error) if is_transient_accept_error(&error) => { + eprintln!("fpsmaxxing-broker: accept failed: {error}"); + tokio::time::sleep(ACCEPT_RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + }, + }; + let connection_broker = broker.clone(); + let authorizer = Arc::clone(&authorizer); + tokio::spawn(async move { + let _permit = permit; + if let Err(error) = handle_connection(accepted, connection_broker, authorizer).await { + eprintln!("fpsmaxxing-broker: connection error: {error}"); + } + }); + } +} + +/// Waits for a free connection slot, then accepts the next peer into it. +/// +/// The slot is taken before the accept so a peer beyond [`MAX_CONNECTIONS`] +/// waits in the transport's own backlog rather than in a task of its own. This +/// whole future is dropped when [`serve`] sees the worker stop, so a queued slot +/// or a pending accept is released rather than leaked. +async fn admit( + transport: &T, + connections: &Arc, +) -> io::Result<(OwnedSemaphorePermit, Accepted)> +where + T: LocalTransport, +{ + let permit = Arc::clone(connections) + .acquire_owned() + .await + .map_err(io::Error::other)?; + Ok((permit, transport.accept().await?)) +} + +/// Whether an accept failure aborted one connection rather than the endpoint. +/// +/// Anything else - a closed or unusable listener, descriptor exhaustion - is +/// persistent: retrying it would spin, so [`serve`] surfaces it as fatal. +fn is_transient_accept_error(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::ConnectionAborted | io::ErrorKind::Interrupted + ) +} + +/// Authenticates one peer, then serves its framed requests until it goes away. +/// +/// A peer that fails the ACL is answered with a typed rejection under the short +/// [`REJECTION_WRITE_TIMEOUT`] and then dropped, so an unauthenticated caller +/// cannot hold a connection slot for the full [`CONNECTION_IDLE_TIMEOUT`]. The +/// rejection carries [`UNAUTHORIZED_MESSAGE`] and nothing about why. +/// +/// The verified [`fpsmaxxing_ipc::PeerIdentity`] is used for the ACL check and +/// then dropped. Under the interim same-uid ACL every authorized peer is the +/// same identity, so journaling the peer uid and pid against each lifecycle, and +/// authenticating the client-supplied owner label against them, only carry their +/// weight once split-privilege ACLs arrive; both are tracked as follow-up work +/// `fpsm-broker-splitacl`. +async fn handle_connection( + accepted: Accepted, + broker: BrokerHandle, + authorizer: Arc, +) -> Result<(), FrameError> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let Accepted { mut stream, peer } = accepted; + if let Err(error) = authorizer.authorize(&peer) { + eprintln!("fpsmaxxing-broker: refused a peer: {error}"); + let response = + BrokerResponse::error(BrokerErrorKind::Unauthenticated, UNAUTHORIZED_MESSAGE); + let _ = write_response_within(&mut stream, &response, REJECTION_WRITE_TIMEOUT).await; + return Ok(()); + } + loop { + let read = tokio::time::timeout(CONNECTION_IDLE_TIMEOUT, read_frame(&mut stream)).await; + let Ok(read) = read else { + return Ok(()); + }; + let frame = match read { + Ok(Some(frame)) => frame, + Ok(None) => return Ok(()), + Err(FrameError::Empty) => { + // The body was zero bytes, so the stream is still at a frame + // boundary and this peer can keep talking. + let response = + BrokerResponse::error(BrokerErrorKind::Malformed, "frame body is empty"); + write_response(&mut stream, &response).await?; + continue; + } + Err(FrameError::TooLarge { length }) => { + let response = BrokerResponse::error( + BrokerErrorKind::Malformed, + format!("frame length {length} exceeds the maximum"), + ); + let _ = write_response(&mut stream, &response).await; + return Ok(()); + } + Err(error) => return Err(error), + }; + let response = match serde_json::from_slice::(&frame) { + Ok(request) => broker.dispatch(request).await, + Err(error) => BrokerResponse::error( + BrokerErrorKind::Malformed, + format!("invalid request: {}", echoed(&error.to_string())), + ), + }; + write_response(&mut stream, &response).await?; + } +} + +/// Writes one response, giving the peer [`CONNECTION_IDLE_TIMEOUT`] to take it. +/// +/// A peer that pipelines requests and never reads fills the socket's send buffer +/// and would otherwise park this task in `write_all` for good, holding its +/// connection slot; the timeout turns that into an error that ends the +/// connection and returns the slot. +async fn write_response(writer: &mut W, response: &BrokerResponse) -> Result<(), FrameError> +where + W: AsyncWrite + Unpin, +{ + write_response_within(writer, response, CONNECTION_IDLE_TIMEOUT).await +} + +/// Writes one response, giving the peer `deadline` to take it. +async fn write_response_within( + writer: &mut W, + response: &BrokerResponse, + deadline: Duration, +) -> Result<(), FrameError> +where + W: AsyncWrite + Unpin, +{ + let bytes = serde_json::to_vec(response).map_err(io::Error::other)?; + tokio::time::timeout(deadline, write_frame(writer, &bytes)) + .await + .map_err(|_| { + FrameError::Io(io::Error::new( + io::ErrorKind::TimedOut, + "peer did not accept the response within its write deadline", + )) + })? +} + +#[cfg(test)] +mod tests { + use std::io; + use std::num::NonZeroU64; + use std::pin::Pin; + use std::sync::Arc; + use std::task::{Context, Poll}; + + use fpsmaxxing_contracts::ipc::{BrokerErrorKind, BrokerOutcome, BrokerRequest}; + use fpsmaxxing_contracts::{ChangeRequest, ProviderManifest, StateSnapshot}; + use fpsmaxxing_control_plane::ControlPlane; + use fpsmaxxing_ipc::{ + Accepted, FrameError, MAX_FRAME_BYTES, PeerAuthorizer, PeerIdentity, SameUidAuthorizer, + }; + use fpsmaxxing_mock_provider::MockProvider; + use fpsmaxxing_provider_sdk::{Provider, ProviderError}; + use serde_json::json; + use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + use tokio::sync::mpsc; + + use super::{ + BrokerHandle, BrokerResponse, BrokerService, CONNECTION_IDLE_TIMEOUT, MAX_ECHOED_BYTES, + OwnershipLedger, REJECTION_WRITE_TIMEOUT, echoed, handle_connection, + }; + + /// Host detail a provider error might carry; it must not reach the wire. + const HOST_DETAIL: &str = "/var/lib/fpsmaxxing/private/journal.sqlite"; + + /// A provider whose apply fails with an error carrying host detail. + struct LeakyProvider; + + impl Provider for LeakyProvider { + fn manifest(&self) -> ProviderManifest { + MockProvider::new(0).manifest() + } + + fn snapshot(&self) -> Result { + MockProvider::new(0).snapshot() + } + + fn preview(&self, request: &ChangeRequest) -> Result { + MockProvider::new(0).preview(request) + } + + fn apply(&mut self, _request: &ChangeRequest) -> Result<(), ProviderError> { + Err(ProviderError::Unavailable(HOST_DETAIL.to_owned())) + } + + fn verify(&self, _request: &ChangeRequest) -> Result { + Ok(true) + } + + fn rollback(&mut self, _snapshot: &StateSnapshot) -> Result<(), ProviderError> { + Ok(()) + } + } + + fn service() -> (BrokerService, Arc) { + service_with(Box::new(MockProvider::new(0))) + } + + fn service_with(provider: Box) -> (BrokerService, Arc) { + let plane = ControlPlane::open(provider, ":memory:").expect("control plane should open"); + let ledger = Arc::new(OwnershipLedger::new()); + (BrokerService::new(plane, Arc::clone(&ledger)), ledger) + } + + fn change(capability_id: &str, value: u64, lease: u64) -> ChangeRequest { + ChangeRequest { + capability_id: capability_id.to_owned(), + parameters: json!({ "value": value }), + lease_seconds: NonZeroU64::new(lease).expect("lease is non-zero"), + } + } + + fn error_body(response: &BrokerResponse) -> &fpsmaxxing_contracts::ipc::BrokerErrorBody { + match response { + BrokerResponse::Error { error } => error, + other => panic!("expected a typed error, got {other:?}"), + } + } + + fn error_kind(response: &BrokerResponse) -> BrokerErrorKind { + error_body(response).kind + } + + #[test] + fn discover_returns_the_catalog() { + let (service, _ledger) = service(); + let response = service.handle(BrokerRequest::discover()); + assert_eq!(response.outcome(), BrokerOutcome::Capabilities); + let BrokerResponse::Capabilities { capabilities } = response else { + panic!("discover should answer with the catalog"); + }; + assert!( + capabilities + .capabilities + .iter() + .any(|c| c.id == "mock.value") + ); + } + + #[test] + fn full_lifecycle_succeeds() { + let (service, _ledger) = service(); + let response = service.handle(BrokerRequest::run_lifecycle( + "gateway", + change("mock.value", 42, 30), + )); + assert_eq!(response.outcome(), BrokerOutcome::Lifecycle); + let BrokerResponse::Lifecycle { lifecycle } = response else { + panic!("a completed lifecycle should answer with its report"); + }; + assert!(lifecycle.verified && lifecycle.rolled_back); + } + + #[test] + fn out_of_catalog_capability_is_refused() { + let (service, _ledger) = service(); + for capability in ["shell.exec", "registry.set", "mock.unknown"] { + let response = service.handle(BrokerRequest::run_lifecycle( + "gateway", + change(capability, 1, 30), + )); + assert_eq!( + error_kind(&response), + BrokerErrorKind::UnknownCapability, + "{capability} must be refused as out-of-catalog" + ); + } + } + + #[test] + fn missing_owner_is_malformed() { + let (service, _ledger) = service(); + let request = BrokerRequest { + op: fpsmaxxing_contracts::ipc::BrokerOp::RunLifecycle, + owner: None, + change: Some(change("mock.value", 1, 30)), + }; + assert_eq!( + error_kind(&service.handle(request)), + BrokerErrorKind::Malformed + ); + } + + #[test] + fn out_of_bounds_value_is_policy_denied() { + let (service, _ledger) = service(); + let response = service.handle(BrokerRequest::run_lifecycle( + "gateway", + change("mock.value", 101, 30), + )); + assert_eq!(error_kind(&response), BrokerErrorKind::PolicyDenied); + assert_eq!( + error_body(&response).message, + "policy denied request: mock.value is bounded to 0..=100" + ); + } + + #[test] + fn an_internal_failure_does_not_cross_the_boundary_verbatim() { + let (service, _ledger) = service_with(Box::new(LeakyProvider)); + let response = service.handle(BrokerRequest::run_lifecycle( + "gateway", + change("mock.value", 42, 30), + )); + assert_eq!(error_kind(&response), BrokerErrorKind::LifecycleFailed); + let message = &error_body(&response).message; + assert!( + !message.contains(HOST_DETAIL), + "host detail leaked to the client: {message}" + ); + assert_eq!(message, "lifecycle failed: provider"); + } + + #[test] + fn an_oversized_capability_id_is_answered_within_the_frame_limit() { + let (service, _ledger) = service(); + let oversized = "z".repeat(MAX_FRAME_BYTES as usize); + let response = service.handle(BrokerRequest::run_lifecycle( + "gateway", + change(&oversized, 1, 30), + )); + assert_eq!(error_kind(&response), BrokerErrorKind::UnknownCapability); + let encoded = serde_json::to_vec(&response).expect("response should encode"); + assert!( + u32::try_from(encoded.len()).is_ok_and(|length| length <= MAX_FRAME_BYTES), + "an echoed capability id must not push the response past the frame limit" + ); + } + + #[test] + fn an_oversized_owner_label_cannot_bloat_a_conflict_message() { + let (service, ledger) = service(); + let oversized = "owner-".repeat(MAX_FRAME_BYTES as usize / 6); + let _guard = ledger + .acquire("mock.value", &oversized) + .expect("the first owner should hold the knob"); + let response = service.handle(BrokerRequest::run_lifecycle( + "owner-b", + change("mock.value", 42, 30), + )); + assert_eq!(error_kind(&response), BrokerErrorKind::OwnerConflict); + assert!(error_body(&response).message.len() <= MAX_ECHOED_BYTES + 3); + } + + #[test] + fn echoing_truncates_on_a_character_boundary() { + assert_eq!(echoed("short"), "short"); + // Three-byte characters: the cap does not land on a boundary, so a + // by-byte cut would panic or emit invalid UTF-8. + let text = "\u{3053}".repeat(MAX_ECHOED_BYTES); + let shortened = echoed(&text); + let kept = shortened + .strip_suffix("...") + .expect("a shortened echo should be marked"); + assert!(kept.len() <= MAX_ECHOED_BYTES); + assert!(text.starts_with(kept)); + assert!(kept.chars().all(|character| character == '\u{3053}')); + } + + #[test] + fn second_owner_of_a_held_knob_is_refused() { + let (service, ledger) = service(); + let _guard = ledger + .acquire("mock.value", "owner-a") + .expect("first owner should hold the knob"); + let response = service.handle(BrokerRequest::run_lifecycle( + "owner-b", + change("mock.value", 42, 30), + )); + assert_eq!(error_kind(&response), BrokerErrorKind::OwnerConflict); + } + + /// The uid both the stalled peer and its authorizer are built around. + const TEST_UID: u32 = 4242; + + /// A peer that sends one frame and then never reads and never writes. + /// + /// Writes park forever, standing in for a socket whose send buffer a peer + /// has stopped draining. + struct StalledPeer { + request: Vec, + sent: usize, + } + + impl AsyncRead for StalledPeer { + fn poll_read( + self: Pin<&mut Self>, + _context: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + let peer = self.get_mut(); + let remaining = &peer.request[peer.sent..]; + if remaining.is_empty() { + return Poll::Pending; + } + let take = remaining.len().min(buffer.remaining()); + buffer.put_slice(&remaining[..take]); + peer.sent += take; + Poll::Ready(Ok(())) + } + } + + impl AsyncWrite for StalledPeer { + fn poll_write( + self: Pin<&mut Self>, + _context: &mut Context<'_>, + _buffer: &[u8], + ) -> Poll> { + Poll::Pending + } + + fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn poll_shutdown(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test(start_paused = true)] + async fn a_peer_that_never_reads_its_response_cannot_pin_the_connection() { + // The worker is never reached: an undecodable body is answered inline, + // so the connection only has the stalled write left to block on. + let (jobs, _worker) = mpsc::channel(1); + let mut request = 4u32.to_be_bytes().to_vec(); + request.extend_from_slice(b"junk"); + let accepted = Accepted { + stream: StalledPeer { request, sent: 0 }, + peer: PeerIdentity { + uid: TEST_UID, + pid: None, + }, + }; + let authorizer: Arc = Arc::new(SameUidAuthorizer::new(TEST_UID)); + + // The outer bound turns a regression into a failure rather than a hang: + // with the write unbounded it is the only timer left to fire. + let started = tokio::time::Instant::now(); + let error = tokio::time::timeout( + CONNECTION_IDLE_TIMEOUT * 4, + handle_connection(accepted, BrokerHandle { jobs }, authorizer), + ) + .await + .expect("the connection must not outlive the idle timeout") + .expect_err("a peer that never reads must not hold the connection"); + + assert!( + matches!(&error, FrameError::Io(io) if io.kind() == io::ErrorKind::TimedOut), + "unexpected connection outcome: {error}" + ); + assert!( + started.elapsed() >= CONNECTION_IDLE_TIMEOUT, + "the connection ended before the idle timeout" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_rejected_peer_that_never_reads_cannot_pin_its_connection_slot() { + // The peer fails the ACL, so no request is ever read and the only thing + // left to block on is the rejection write it refuses to take. + let (jobs, _worker) = mpsc::channel(1); + let accepted = Accepted { + stream: StalledPeer { + request: Vec::new(), + sent: 0, + }, + peer: PeerIdentity { + uid: TEST_UID, + pid: None, + }, + }; + let authorizer: Arc = Arc::new(SameUidAuthorizer::new(TEST_UID + 1)); + + let started = tokio::time::Instant::now(); + tokio::time::timeout( + CONNECTION_IDLE_TIMEOUT, + handle_connection(accepted, BrokerHandle { jobs }, authorizer), + ) + .await + .expect("a refused peer must not hold its slot for the idle timeout") + .expect("a refused peer ends its own connection, not the broker"); + + let elapsed = started.elapsed(); + assert!( + elapsed >= REJECTION_WRITE_TIMEOUT && elapsed < CONNECTION_IDLE_TIMEOUT, + "an unauthenticated peer held its slot for {elapsed:?}" + ); + } +} diff --git a/apps/broker/src/main.rs b/apps/broker/src/main.rs index 4cce700..0baa455 100644 --- a/apps/broker/src/main.rs +++ b/apps/broker/src/main.rs @@ -1,5 +1,1293 @@ //! Privileged provider broker process. +//! +//! Owns the control plane and serves capability discovery and bounded provider +//! lifecycles to authenticated local peers over a Unix domain socket. No raw +//! shell, Registry path, or hardware primitive crosses this boundary. +//! +//! Only the Unix domain socket transport is implemented; the Windows named-pipe +//! transport is deliberately out of scope, so the binary refuses to run there. +//! +//! The broker always establishes one owner-only private directory of its own, +//! under `$XDG_RUNTIME_DIR` (or `/run`). Unless `--socket`/`--journal` or their +//! environment overrides (`FPSMAXXING_BROKER_SOCKET` and +//! `FPSMAXXING_BROKER_JOURNAL_PATH`, both broker-only) name a path, the socket +//! and the journal live there rather than beside the inherited working +//! directory. Wherever a path came from, it is held to the same bar before it +//! is used: absolute, directly inside a directory no other user can reach, +//! under an ancestor chain no other user can write. +//! +//! Only one broker may run per uid. An exclusive advisory lock on a fixed file +//! in that private directory is taken before the journal is opened and before +//! the socket is bound, so a second process refuses to start rather than +//! driving the same knobs through an ownership ledger of its own. That is +//! unconditional for a root broker, whose private directory is always +//! `/run/fpsmaxxing`; an unprivileged one locks wherever the inherited +//! `XDG_RUNTIME_DIR` puts that directory, so the guard is best-effort on the +//! dev path it serves. +//! +//! The process exits non-zero on any fatal condition, including the loss of the +//! control-plane worker thread; run it under a supervisor that restarts it. +#[cfg(unix)] +#[tokio::main] +async fn main() { + if let Err(error) = unix::run().await { + eprintln!("fpsmaxxing-broker: {error}"); + std::process::exit(1); + } +} + +#[cfg(not(unix))] fn main() { - println!("fpsmaxxing-broker: scaffold ready; no privileged actions are enabled"); + eprintln!( + "fpsmaxxing-broker: only the Unix domain socket transport is implemented; the Windows named-pipe transport is not yet available" + ); + std::process::exit(1); +} + +#[cfg(unix)] +mod unix { + use std::env; + use std::error::Error; + use std::ffi::{OsStr, OsString}; + use std::fs::{DirBuilder, File, OpenOptions, Permissions}; + use std::io; + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use fpsmaxxing_broker::{BrokerService, OwnershipLedger, serve, spawn_service}; + use fpsmaxxing_control_plane::ControlPlane; + use fpsmaxxing_ipc::{PeerAuthorizer, SameUidAuthorizer, UnixSocketTransport}; + use fpsmaxxing_mock_provider::MockProvider; + use rustix::fs::{FlockOperation, flock}; + use rustix::io::Errno; + use thiserror::Error; + + /// Value-taking flags this binary accepts, in [`USAGE`] order. + const FLAGS: [&str; 2] = ["--socket", "--journal"]; + + /// Arguments that ask for [`USAGE`] instead of a run. + /// + /// Recognized by [`parse_args`] only where a flag is expected, never as the + /// value of one. + const HELP_FLAGS: [&str; 2] = ["--help", "-h"]; + + /// What `--help` prints. + const USAGE: &str = "\ +Usage: fpsmaxxing-broker [--socket ] [--journal ] + + --socket Unix domain socket to listen on + (environment: FPSMAXXING_BROKER_SOCKET) + --journal SQLite audit journal to write + (environment: FPSMAXXING_BROKER_JOURNAL_PATH) + -h, --help Print this message and exit + +A flag wins over its environment variable. Unset, each falls back into the +broker's own owner-only directory under $XDG_RUNTIME_DIR (or /run when it is +unset, is not absolute, or the broker runs as root); that directory is +established either way, because the lock that admits one broker per uid lives +in it. Every path must be absolute and sit in an existing directory owned by +the broker or root that no other user can reach (mode 0700), itself under a +chain of directories owned by the broker or root that no other user can write, +or the broker refuses to start."; + + /// Environment override for `--socket`. + const SOCKET_ENV: &str = "FPSMAXXING_BROKER_SOCKET"; + + /// Environment override for `--journal`. + /// + /// Deliberately distinct from the `FPSMAXXING_JOURNAL_PATH` the unprivileged + /// gateway and CLI read. Sharing that variable would let an operator who + /// exported it for the CLI silently move the privileged broker's audit + /// journal out of its owner-only directory and into a file the gateway is + /// writing concurrently. + const JOURNAL_ENV: &str = "FPSMAXXING_BROKER_JOURNAL_PATH"; + + /// Directory the broker keeps its socket and journal in by default. + const PRIVATE_DIR_NAME: &str = "fpsmaxxing"; + + /// Where [`PRIVATE_DIR_NAME`] lives when `XDG_RUNTIME_DIR` is not usable. + const FALLBACK_RUNTIME_BASE: &str = "/run"; + + /// The only mode the broker's private directory may have: owner access only. + const PRIVATE_DIR_MODE: u32 = 0o700; + + /// Bits that make a directory writable by group or world. + const OTHER_WRITE_BITS: u32 = 0o022; + + /// Bits that give any user but the owner access to a directory. + const OTHER_ACCESS_BITS: u32 = 0o077; + + /// The sticky bit: only an entry's owner may rename or remove it. + const STICKY_BIT: u32 = 0o1000; + + /// The superuser, trusted to own any ancestor of the private directory. + const ROOT_UID: u32 = 0; + + /// Socket file name inside the broker's private directory. + const DEFAULT_SOCKET_NAME: &str = "broker.sock"; + + /// Journal file name inside the broker's private directory. + const DEFAULT_JOURNAL_NAME: &str = "journal.sqlite"; + + /// The only mode the audit journal may have: owner access only. + const JOURNAL_FILE_MODE: u32 = 0o600; + + /// Suffixes `SQLite` appends to a database path for its side files. + const JOURNAL_SIDE_SUFFIXES: [&str; 3] = ["-journal", "-wal", "-shm"]; + + /// Single-instance lock file name inside the broker's private directory. + /// + /// Fixed rather than derived from the socket or the journal: the knobs two + /// brokers would fight over are the machine's, not a path's, so an instance + /// is scoped to the uid that runs it however it was pointed at its files. + const LOCK_FILE_NAME: &str = "broker.lock"; + + /// Why the command line was refused. + /// + /// A privileged daemon must not silently relocate its socket or journal + /// because an argument was mistyped, so every parse failure is fatal. + #[derive(Debug, Error)] + pub enum ArgError { + /// A recognized flag was given without a value. + #[error("{0} requires a value")] + MissingValue(String), + /// A recognized flag was given more than once. + #[error("{0} was given more than once")] + Repeated(String), + /// An argument is neither one of [`FLAGS`] nor one of [`HELP_FLAGS`]. + #[error("unrecognized argument {argument}; expected one of {expected}", expected = FLAGS.join(", "))] + Unrecognized { + /// The rejected argument, verbatim. + argument: String, + }, + /// An argument is not valid UTF-8. + /// + /// A path variable is read as a raw `OsString`, but the command line is + /// matched against flag names, so a byte sequence that is not UTF-8 is + /// reported as the typed failure it is rather than aborting the process + /// mid-parse the way `env::args` would. + #[error("argument {argument} is not valid UTF-8")] + NotUnicode { + /// The rejected argument, with each invalid sequence replaced. + argument: String, + }, + } + + /// Socket and journal locations resolved from the command line. + #[derive(Debug, Default, Eq, PartialEq)] + pub struct Options { + /// Value of `--socket`, when supplied. + pub socket: Option, + /// Value of `--journal`, when supplied. + pub journal: Option, + } + + /// What a parsed command line asks the binary to do. + #[derive(Debug, Eq, PartialEq)] + pub enum Invocation { + /// Serve with these locations. + Run(Options), + /// Print [`USAGE`] and exit successfully. + Help, + } + + pub async fn run() -> Result<(), Box> { + let options = match parse_args(env::args_os().skip(1))? { + Invocation::Help => { + println!("{USAGE}"); + return Ok(()); + } + Invocation::Run(options) => options, + }; + let authorizer = SameUidAuthorizer::for_current_process(); + let broker_uid = authorizer.expected_uid(); + let Paths { + socket: socket_path, + journal: journal_path, + lock: lock_path, + } = resolve_paths(options, broker_uid)?; + let _single_instance = lock_single_instance(&lock_path)?; + + let ledger = Arc::new(OwnershipLedger::new()); + let broker = spawn_service(move || { + let provider = Box::new(MockProvider::new(0)); + restrict_journal(&journal_path)?; + let plane = ControlPlane::open(provider, &journal_path).map_err(io::Error::other)?; + Ok(BrokerService::new(plane, ledger)) + }) + .await?; + + let transport = UnixSocketTransport::bind(&socket_path)?; + let authorizer: Arc = Arc::new(authorizer); + println!( + "fpsmaxxing-broker: listening on {} (same-uid ACL, uid {broker_uid})", + socket_path.display() + ); + serve(transport, broker, authorizer).await?; + Ok(()) + } + + /// Every path a running broker owns. + #[derive(Debug, Eq, PartialEq)] + pub struct Paths { + /// Where the IPC endpoint is bound. + pub socket: PathBuf, + /// Where the audit journal is written. + pub journal: PathBuf, + /// The file [`lock_single_instance`] locks. + pub lock: PathBuf, + } + + /// Resolves the socket, journal, and lock locations, in override order. + /// + /// An explicit flag wins over the matching environment variable. Anything + /// left unset falls back into the broker's private directory rather than + /// beside the inherited working directory, so a privileged daemon never + /// places its IPC endpoint or its durable audit journal somewhere it does + /// not own. + /// + /// The environment is read with [`env::var_os`] rather than `env::var`, so a + /// path that is not UTF-8 relocates the socket or journal as configured + /// instead of being silently dropped back to the default. + fn resolve_paths(options: Options, broker_uid: u32) -> io::Result { + let base = runtime_base(env::var_os("XDG_RUNTIME_DIR").as_deref(), broker_uid); + resolve_paths_from(options, broker_uid, &base, |name| env::var_os(name)) + } + + /// [`resolve_paths`] against an arbitrary runtime base and environment. + /// + /// Only [`SOCKET_ENV`] and [`JOURNAL_ENV`] are ever consulted; the broker + /// shares no path variable with the unprivileged gateway or CLI. + /// + /// The private directory under `base` is created and vetted whatever the + /// overrides say, because the single-instance lock lives in it and must not + /// move with them: a lock keyed to the journal would let two brokers pointed + /// at different journals both start and then share one socket, since only + /// the path that was left unset falls back to the default. + /// + /// Every resolved path is put through [`vet_resolved_path`], whatever named + /// it. An environment variable is inherited from whoever started the broker, + /// so honoring one verbatim would hand that caller the choice of where a + /// root-owned socket and audit journal are created - exactly what + /// [`runtime_base`] refuses them for `XDG_RUNTIME_DIR`. + fn resolve_paths_from( + options: Options, + broker_uid: u32, + base: &Path, + lookup: F, + ) -> io::Result + where + F: Fn(&str) -> Option, + { + let socket = options + .socket + .map(OsString::from) + .or_else(|| lookup(SOCKET_ENV)); + let journal = options + .journal + .map(OsString::from) + .or_else(|| lookup(JOURNAL_ENV)); + let directory = private_directory_in(base, broker_uid)?; + let paths = Paths { + socket: socket.map_or_else(|| directory.join(DEFAULT_SOCKET_NAME), PathBuf::from), + journal: journal.map_or_else(|| directory.join(DEFAULT_JOURNAL_NAME), PathBuf::from), + lock: directory.join(LOCK_FILE_NAME), + }; + vet_resolved_path(&paths.socket, broker_uid)?; + vet_resolved_path(&paths.journal, broker_uid)?; + Ok(paths) + } + + /// Refuses a resolved socket or journal path the broker must not use. + /// + /// A relative path would resolve against the inherited working directory, + /// which is as much the caller's choice as the variable that named it. A + /// parent another user may write is what makes the socket path raceable at + /// bind time and the journal readable, so the whole chain above the file is + /// vetted - the same vet the default directory gets. + /// + /// The directory that directly holds the entry is held to a stricter bar + /// than the ancestors above it: owner access only, sticky or not. Sticky + /// stops another user renaming or removing the broker's socket or journal, + /// but not creating that entry first in a shared directory like `/tmp` and + /// keeping ownership of the file a privileged broker then writes every + /// `apply-intent` record into; and a merely traversable directory like + /// `/run` puts every local user in front of a socket whose own mode cannot + /// be pinned. Higher up, neither is the threat - swapping a vetted + /// directory is - and sticky does prevent that. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::PermissionDenied`] for a path that is not + /// absolute or names no parent directory, or whatever [`vet_directory`] + /// refuses the parent or an ancestor with. + fn vet_resolved_path(path: &Path, broker_uid: u32) -> io::Result<()> { + let parent = path + .parent() + .filter(|_| path.is_absolute()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{} must be an absolute path to an entry inside a directory", + path.display() + ), + ) + })?; + vet_directory(parent, broker_uid, Bar::OwnerOnly)?; + match parent.parent() { + Some(above) => vet_ancestors(above, broker_uid), + None => Ok(()), + } + } + + /// Takes the exclusive lock that makes this process the only broker. + /// + /// Single-owner-per-knob holds inside one process because one ownership + /// ledger arbitrates it, and two brokers would hold one each. Nothing about + /// binding the socket closes that across processes: an existing entry + /// cannot be told apart from a live endpoint without a probe, and a probe + /// is not a lock - two brokers can both find the path stale, and the second + /// one's unlink then strands the first on an unlinked inode, still serving + /// its connected clients and still driving the same knobs. + /// + /// An advisory lock has no such window. It is taken here, before the + /// journal is created or opened and before the socket is bound, so a second + /// broker touches neither. The kernel drops it when the last descriptor for + /// it closes, so a crashed broker leaves nothing to clean up - which is why + /// the returned file must be held for as long as the broker serves. + /// + /// The lock file is [`LOCK_FILE_NAME`] in the broker's private directory, + /// which [`private_directory_in`] has already held to owner-only access, so + /// no other user can create it first or take it. Its location is fixed + /// rather than derived from the socket or the journal, so an instance is + /// scoped to the uid that runs it: what two brokers contend for is the + /// machine's knobs, which no path override makes separate. The directory + /// holding it still follows `XDG_RUNTIME_DIR` for an unprivileged broker, + /// so a single instance is guaranteed only for a root broker, which + /// [`runtime_base`] refuses that variable for. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::AddrInUse`] when another broker holds the lock, + /// or an error naming the lock file when it cannot be created or locked. + fn lock_single_instance(path: &Path) -> io::Result { + let file = create_owner_only(path)?; + match flock(&file, FlockOperation::NonBlockingLockExclusive) { + Ok(()) => Ok(file), + Err(errno) if errno == Errno::WOULDBLOCK => Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!( + "{} is held by a running broker; only one may own this machine's knobs", + path.display() + ), + )), + Err(errno) => Err(named(path, "locked", &io::Error::from(errno))), + } + } + + /// Restricts the audit journal to its owner before the journal is opened. + /// + /// `SQLite` creates a database with mode `0666` masked by the inherited + /// umask, so an operator-supplied path outside the broker's own `0700` + /// directory would otherwise hold every `apply-intent` record - the full + /// change request - in a world-readable file. Creating the database + /// owner-only first closes that, and closes it for the rollback journal and + /// write-ahead log too: `SQLite` copies the database file's mode onto the + /// side files it creates beside it. A side file left behind by an earlier + /// run is restricted directly, since nothing will recreate it. + /// + /// # Errors + /// + /// Returns an error if the journal cannot be created or if it or an existing + /// side file cannot be restricted. + fn restrict_journal(path: &Path) -> io::Result<()> { + drop(create_owner_only(path)?); + for suffix in JOURNAL_SIDE_SUFFIXES { + let mut side = path.as_os_str().to_owned(); + side.push(suffix); + match restrict_to_owner(Path::new(&side)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + + /// Opens `path` at [`JOURNAL_FILE_MODE`], creating it when it is absent. + /// + /// The requested mode only applies to a file this call creates, and the + /// inherited umask strips bits from it even then, so the mode is reapplied + /// afterwards. Both files the broker owns outright - the audit journal and + /// the single-instance lock - are created through here, so neither can be + /// left readable by another user. + /// + /// # Errors + /// + /// Returns an error naming `path` if it cannot be opened or restricted. + fn create_owner_only(path: &Path) -> io::Result { + let file = OpenOptions::new() + .create(true) + .write(true) + .mode(JOURNAL_FILE_MODE) + .open(path) + .map_err(|error| named(path, "opened", &error))?; + restrict_to_owner(path)?; + Ok(file) + } + + /// Sets `path` to [`JOURNAL_FILE_MODE`], naming it on failure. + fn restrict_to_owner(path: &Path) -> io::Result<()> { + std::fs::set_permissions(path, Permissions::from_mode(JOURNAL_FILE_MODE)) + .map_err(|error| named(path, "restricted", &error)) + } + + /// Names `path` in `error`, which `std`'s io errors never carry themselves. + /// + /// The broker takes three configurable paths, so a bare `Permission denied` + /// leaves an operator no way to tell which of them the start-up failed on. + fn named(path: &Path, action: &str, error: &io::Error) -> io::Error { + io::Error::new( + error.kind(), + format!("{} cannot be {action}: {error}", path.display()), + ) + } + + /// Chooses the base directory [`PRIVATE_DIR_NAME`] is created under. + /// + /// `XDG_RUNTIME_DIR` is a session variable the broker inherits from whoever + /// started it, so it is honored only when it names an absolute path and only + /// for an unprivileged broker. A broker running as root ignores it outright: + /// root has no per-session runtime directory, and following an inherited one + /// would let the caller choose where a root-owned socket and audit journal + /// are created. + fn runtime_base(xdg_runtime_dir: Option<&OsStr>, broker_uid: u32) -> PathBuf { + if broker_uid == ROOT_UID { + return PathBuf::from(FALLBACK_RUNTIME_BASE); + } + xdg_runtime_dir + .map(PathBuf::from) + .filter(|base| base.is_absolute()) + .unwrap_or_else(|| PathBuf::from(FALLBACK_RUNTIME_BASE)) + } + + /// Creates and vets [`PRIVATE_DIR_NAME`] under `base`. + /// + /// `base` and every ancestor above it are vetted first, because a parent + /// another user may write is what makes the socket path raceable at bind + /// time and the journal readable: such a user could swap a vetted directory + /// for a symlink before the bind and steer a privileged broker's socket and + /// journal to a path of their choosing. + /// + /// A directory the broker created is vetted too: the inherited umask can + /// strip bits from the requested mode - which is why the mode is reapplied + /// after a successful create - and the entry may already have been something + /// else. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be created, or if it is not a + /// directory owned by `broker_uid` with mode [`PRIVATE_DIR_MODE`], or if + /// [`vet_ancestors`] refuses the path it sits in. + fn private_directory_in(base: &Path, broker_uid: u32) -> io::Result { + vet_ancestors(base, broker_uid)?; + let directory = base.join(PRIVATE_DIR_NAME); + match DirBuilder::new().mode(PRIVATE_DIR_MODE).create(&directory) { + Ok(()) => { + std::fs::set_permissions(&directory, Permissions::from_mode(PRIVATE_DIR_MODE)) + .map_err(|error| named(&directory, "restricted", &error))?; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(named(&directory, "created", &error)), + } + let metadata = std::fs::symlink_metadata(&directory) + .map_err(|error| named(&directory, "inspected", &error))?; + if metadata.is_dir() + && metadata.uid() == broker_uid + && metadata.mode() & 0o777 == PRIVATE_DIR_MODE + { + Ok(directory) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{} must be a directory owned by uid {broker_uid} with mode {PRIVATE_DIR_MODE:o}", + directory.display() + ), + )) + } + } + + /// How much of a directory's mode the broker insists on. + #[derive(Clone, Copy)] + enum Bar { + /// The bar for a directory that directly holds the socket or the + /// journal: no other user may read, write, or traverse it. Traversal is + /// what puts a peer in front of the socket, and the socket's own mode + /// cannot be pinned, so the directory is the only place to deny it. + OwnerOnly, + /// The bar for an ancestor: no other user may write it, unless it is + /// sticky. The threat an ancestor carries is the swap of the directory + /// below it for one the broker does not own, and sticky - only an + /// entry's owner may rename or remove it - is exactly what prevents + /// that. This is how `/tmp` and similar shared roots are protected. + NoForeignWrite, + } + + impl Bar { + /// Whether `mode` gives some other user access this bar denies. + fn refuses(self, mode: u32) -> bool { + match self { + Self::OwnerOnly => mode & OTHER_ACCESS_BITS != 0, + Self::NoForeignWrite => mode & OTHER_WRITE_BITS != 0 && mode & STICKY_BIT == 0, + } + } + + /// How the refusal reads to an operator. + fn requirement(self) -> &'static str { + match self { + Self::OwnerOnly => "no other user can reach", + Self::NoForeignWrite => "no other user can write", + } + } + } + + /// Refuses a directory another user could tamper with or reach through. + /// + /// It must be a real directory - not a symlink - owned by the broker or by + /// root, and closed to other users to whatever degree `bar` demands. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::PermissionDenied`] if the directory fails the + /// bar, or the underlying error - named with the directory, since a path + /// the broker will not create is the likeliest reason it cannot be + /// inspected - if it cannot be inspected at all. + fn vet_directory(directory: &Path, broker_uid: u32, bar: Bar) -> io::Result<()> { + let metadata = std::fs::symlink_metadata(directory) + .map_err(|error| named(directory, "inspected", &error))?; + let owned_by_trusted_uid = metadata.uid() == broker_uid || metadata.uid() == ROOT_UID; + if !metadata.is_dir() || !owned_by_trusted_uid || bar.refuses(metadata.mode()) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{} must be a directory owned by uid {broker_uid} or root that {requirement} it", + directory.display(), + requirement = bar.requirement() + ), + )); + } + Ok(()) + } + + /// Refuses a base whose own path another user could tamper with. + /// + /// Every component from `base` up to `/` goes through [`vet_directory`] at + /// [`Bar::NoForeignWrite`]. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::PermissionDenied`] for the first component that + /// fails, or the underlying error if a component cannot be inspected. + fn vet_ancestors(base: &Path, broker_uid: u32) -> io::Result<()> { + for ancestor in base.ancestors() { + vet_directory(ancestor, broker_uid, Bar::NoForeignWrite)?; + } + Ok(()) + } + + /// Whether an argument standing in value position is really a flag. + /// + /// Every value-taking flag is long, and [`HELP_FLAGS`] adds the one short + /// flag, so a value matching either is a mistyped command line, not a path. + fn looks_like_flag(value: &str) -> bool { + value.starts_with("--") || HELP_FLAGS.contains(&value) + } + + /// Parses `--flag value` and `--flag=value` pairs, rejecting anything else. + /// + /// A help flag is recognized only where a flag is expected. In value + /// position it is refused like any other flag-shaped value, so the mistyped + /// `--socket --help` stays fatal instead of exiting successfully - which a + /// supervisor would read as a clean stop of a broker that never came up. + /// + /// In the separated form the value may not itself look like a flag, so + /// `--socket --journal /var/j.sqlite` is a mistyped command line rather than + /// a socket literally named `--journal`. Use `--socket=--journal` to mean it. + /// + /// Arguments arrive as `OsString`, so a byte sequence that is not UTF-8 + /// becomes [`ArgError::NotUnicode`] rather than the mid-iteration panic + /// `env::args` would raise. + /// + /// # Errors + /// + /// Returns [`ArgError`] for an argument that is not UTF-8 or is + /// unrecognized, a repeated flag, or a flag whose value is missing, empty, + /// or another flag. + pub fn parse_args(arguments: I) -> Result + where + I: IntoIterator, + { + let mut options = Options::default(); + let mut arguments = arguments.into_iter().map(|argument| { + argument + .into_string() + .map_err(|argument| ArgError::NotUnicode { + argument: argument.to_string_lossy().into_owned(), + }) + }); + while let Some(argument) = arguments.next() { + let argument = argument?; + if HELP_FLAGS.contains(&argument.as_str()) { + return Ok(Invocation::Help); + } + let (flag, inline) = match argument.split_once('=') { + Some((flag, value)) => (flag.to_owned(), Some(value.to_owned())), + None => (argument.clone(), None), + }; + let slot = match flag.as_str() { + "--socket" => &mut options.socket, + "--journal" => &mut options.journal, + _ => return Err(ArgError::Unrecognized { argument }), + }; + if slot.is_some() { + return Err(ArgError::Repeated(flag)); + } + let value = match inline { + Some(value) => value, + None => arguments + .next() + .transpose()? + .filter(|value| !looks_like_flag(value)) + .ok_or_else(|| ArgError::MissingValue(flag.clone()))?, + }; + if value.is_empty() { + return Err(ArgError::MissingValue(flag)); + } + *slot = Some(value); + } + Ok(Invocation::Run(options)) + } + + #[cfg(test)] + mod tests { + use std::cell::RefCell; + use std::collections::BTreeSet; + use std::ffi::{OsStr, OsString}; + use std::io; + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::path::Path; + + use fpsmaxxing_control_plane::ControlPlane; + use fpsmaxxing_mock_provider::MockProvider; + + use super::{ + ArgError, FALLBACK_RUNTIME_BASE, HELP_FLAGS, Invocation, JOURNAL_ENV, + JOURNAL_FILE_MODE, LOCK_FILE_NAME, Options, PRIVATE_DIR_MODE, PRIVATE_DIR_NAME, + ROOT_UID, SOCKET_ENV, lock_single_instance, parse_args, private_directory_in, + resolve_paths_from, restrict_journal, runtime_base, + }; + + /// The variable the unprivileged gateway and CLI use for their journal. + const GATEWAY_JOURNAL_ENV: &str = "FPSMAXXING_JOURNAL_PATH"; + + /// An unprivileged uid, so `XDG_RUNTIME_DIR` is eligible at all. + const SESSION_UID: u32 = 1000; + + fn parse(arguments: &[&str]) -> Result { + parse_args(arguments.iter().map(OsString::from)) + } + + /// The [`Options`] a command line that asks for a run resolves to. + fn run_options(arguments: &[&str]) -> Options { + match parse(arguments).expect("the command line should parse") { + Invocation::Run(options) => options, + Invocation::Help => panic!("{arguments:?} should not ask for usage"), + } + } + + /// A lookup that answers every name with a vettable path under `base`. + /// + /// It records which names were asked for, so a test can prove the broker + /// never reaches for a variable that is not its own. + fn recording_lookup<'a>( + base: &'a Path, + seen: &'a RefCell>, + ) -> impl Fn(&str) -> Option + 'a { + move |name| { + seen.borrow_mut().insert(name.to_owned()); + Some(base.join(name).into_os_string()) + } + } + + /// A lookup that answers only [`SOCKET_ENV`], with `value`. + fn socket_env_lookup(value: &Path) -> impl Fn(&str) -> Option + '_ { + move |name| (name == SOCKET_ENV).then(|| value.as_os_str().to_owned()) + } + + /// The uid the test process creates files as, read from one it created. + fn own_uid(created: &Path) -> u32 { + std::fs::symlink_metadata(created) + .expect("a just-created path should stat") + .uid() + } + + fn chmod(path: &Path, mode: u32) { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .expect("mode should apply"); + } + + /// A temporary directory no other user can reach. + /// + /// `tempfile` honors the inherited umask, which typically leaves the + /// directory traversable - the one shape a directory that directly + /// holds the socket or the journal may not have. + fn owner_only_tempdir() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + chmod(directory.path(), PRIVATE_DIR_MODE); + directory + } + + /// A path as the `String` a command-line flag would carry. + fn path_string(path: &Path) -> String { + path.to_str() + .expect("a temporary path should be UTF-8") + .to_owned() + } + + #[test] + fn both_flag_forms_are_accepted() { + assert_eq!( + run_options(&["--socket", "/run/b.sock", "--journal=/var/j.sqlite"]), + Options { + socket: Some("/run/b.sock".to_owned()), + journal: Some("/var/j.sqlite".to_owned()), + } + ); + assert_eq!(run_options(&[]), Options::default()); + } + + #[test] + fn a_flag_shaped_value_needs_the_inline_form() { + assert!(matches!( + parse(&["--socket", "--journal", "/var/j.sqlite"]) + .expect_err("a swallowed flag must not become the socket path"), + ArgError::MissingValue(_) + )); + let options = run_options(&["--socket=--journal"]); + assert_eq!(options.socket.as_deref(), Some("--journal")); + } + + #[test] + fn a_mistyped_command_line_is_fatal() { + assert!(matches!( + parse(&["--socket"]) + .expect_err("a trailing flag must not fall back to the default"), + ArgError::MissingValue(_) + )); + assert!(matches!( + parse(&["--socket="]).expect_err("an empty value must be refused"), + ArgError::MissingValue(_) + )); + assert!(matches!( + parse(&["--sockett", "/run/b.sock"]).expect_err("a typo must be refused"), + ArgError::Unrecognized { .. } + )); + assert!(matches!( + parse(&["/run/b.sock"]).expect_err("a positional argument must be refused"), + ArgError::Unrecognized { .. } + )); + assert!(matches!( + parse(&["--socket", "/a", "--socket", "/b"]) + .expect_err("a repeated flag must be refused"), + ArgError::Repeated(_) + )); + } + + #[test] + fn a_non_utf8_argument_is_a_typed_error_not_a_panic() { + let invalid = OsString::from_vec(vec![b'/', 0xff, b'x']); + for arguments in [ + vec![invalid.clone()], + vec![OsString::from("--socket"), invalid.clone()], + ] { + assert!( + matches!( + parse_args(arguments).expect_err("a non-UTF-8 argument must be refused"), + ArgError::NotUnicode { .. } + ), + "a privileged daemon must fail with a named argument, not a panic" + ); + } + } + + #[test] + fn the_journal_never_comes_from_the_gateway_environment() { + let base = owner_only_tempdir(); + let uid = own_uid(base.path()); + let seen = RefCell::new(BTreeSet::new()); + let paths = resolve_paths_from( + Options::default(), + uid, + base.path(), + recording_lookup(base.path(), &seen), + ) + .expect("both paths come from the environment"); + + assert_eq!(paths.socket, base.path().join(SOCKET_ENV)); + assert_eq!(paths.journal, base.path().join(JOURNAL_ENV)); + assert_eq!(JOURNAL_ENV, "FPSMAXXING_BROKER_JOURNAL_PATH"); + assert!( + !seen.borrow().contains(GATEWAY_JOURNAL_ENV), + "the privileged broker must not read the gateway's journal variable" + ); + assert_eq!( + *seen.borrow(), + [SOCKET_ENV.to_owned(), JOURNAL_ENV.to_owned()] + .into_iter() + .collect() + ); + } + + #[test] + fn explicit_flags_win_over_the_environment() { + let base = owner_only_tempdir(); + let uid = own_uid(base.path()); + let seen = RefCell::new(BTreeSet::new()); + let options = Options { + socket: Some(path_string(&base.path().join("b.sock"))), + journal: Some(path_string(&base.path().join("j.sqlite"))), + }; + let paths = resolve_paths_from( + options, + uid, + base.path(), + recording_lookup(base.path(), &seen), + ) + .expect("explicit flags name both endpoints"); + assert_eq!(paths.socket, base.path().join("b.sock")); + assert_eq!(paths.journal, base.path().join("j.sqlite")); + assert!( + seen.borrow().is_empty(), + "a flag must not consult the environment at all" + ); + } + + #[test] + fn the_instance_lock_stays_in_the_private_directory_whatever_the_overrides() { + let base = owner_only_tempdir(); + let uid = own_uid(base.path()); + let private = base.path().join(PRIVATE_DIR_NAME); + + // Two brokers pointed at different journals are still one instance: + // the knobs they would both drive belong to the machine, not to a + // path either of them was handed. + let mut locks = Vec::new(); + for journal in ["a.sqlite", "b.sqlite"] { + let options = Options { + journal: Some(path_string(&base.path().join(journal))), + ..Options::default() + }; + let paths = resolve_paths_from(options, uid, base.path(), |_| None) + .expect("an owner-only base is sound"); + assert_eq!( + paths.socket, + private.join("broker.sock"), + "only the path left unset falls back, so both share one socket" + ); + assert_eq!(paths.lock, private.join(LOCK_FILE_NAME)); + locks.push(paths.lock); + } + + let _held = lock_single_instance(&locks[0]).expect("the first broker should be alone"); + let error = lock_single_instance(&locks[1]) + .expect_err("a second broker on this machine must be refused"); + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + } + + #[test] + fn the_private_directory_is_established_even_when_both_paths_are_overridden() { + let base = owner_only_tempdir(); + let uid = own_uid(base.path()); + let options = Options { + socket: Some(path_string(&base.path().join("b.sock"))), + journal: Some(path_string(&base.path().join("j.sqlite"))), + }; + let paths = resolve_paths_from(options, uid, base.path(), |_| None) + .expect("an owner-only base is sound"); + + let private = base.path().join(PRIVATE_DIR_NAME); + assert_eq!(paths.lock, private.join(LOCK_FILE_NAME)); + let metadata = + std::fs::symlink_metadata(&private).expect("the private directory should stat"); + assert!(metadata.is_dir()); + assert_eq!( + metadata.mode() & 0o777, + PRIVATE_DIR_MODE, + "the lock must live somewhere no other user can take it first" + ); + } + + #[test] + fn a_relative_path_from_the_environment_is_refused() { + let base = owner_only_tempdir(); + let uid = own_uid(base.path()); + let options = Options { + journal: Some(path_string(&base.path().join("j.sqlite"))), + ..Options::default() + }; + let error = resolve_paths_from( + options, + uid, + base.path(), + socket_env_lookup(Path::new("broker.sock")), + ) + .expect_err("a relative override would land beside the inherited cwd"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn a_path_from_the_environment_under_a_writable_parent_is_refused() { + let outer = owner_only_tempdir(); + let uid = own_uid(outer.path()); + let reachable = outer.path().join("reachable"); + std::fs::create_dir(&reachable).expect("directory should create"); + chmod(&reachable, 0o777); + + let options = Options { + journal: Some(path_string(&outer.path().join("j.sqlite"))), + ..Options::default() + }; + let error = resolve_paths_from( + options, + uid, + outer.path(), + socket_env_lookup(&reachable.join("broker.sock")), + ) + .expect_err("an override under a world-writable parent must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + + // The same override is accepted once nobody else can write its parent. + chmod(&reachable, PRIVATE_DIR_MODE); + let options = Options { + journal: Some(path_string(&outer.path().join("j.sqlite"))), + ..Options::default() + }; + let paths = resolve_paths_from( + options, + uid, + outer.path(), + socket_env_lookup(&reachable.join("broker.sock")), + ) + .expect("an owner-only parent is sound"); + assert_eq!(paths.socket, reachable.join("broker.sock")); + } + + #[test] + fn a_sticky_directory_may_not_hold_the_socket_or_the_journal() { + let outer = owner_only_tempdir(); + let uid = own_uid(outer.path()); + let shared = outer.path().join("shared"); + std::fs::create_dir(&shared).expect("directory should create"); + chmod(&shared, 0o1777); + + // Sticky stops another user removing the broker's entry, not + // creating it first and keeping ownership of what the broker writes. + let options = Options { + socket: Some(path_string(&shared.join("broker.sock"))), + journal: Some(path_string(&outer.path().join("j.sqlite"))), + }; + let error = resolve_paths_from(options, uid, outer.path(), |_| None) + .expect_err("a sticky world-writable parent must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + + let options = Options { + socket: Some(path_string(&outer.path().join("b.sock"))), + journal: Some(path_string(&shared.join("j.sqlite"))), + }; + let error = resolve_paths_from(options, uid, outer.path(), |_| None) + .expect_err("the journal is held to the same bar as the socket"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + + // Higher up, sticky is sound: it prevents the swap of the + // owner-only directory that does hold them. + let private = shared.join(PRIVATE_DIR_NAME); + std::fs::create_dir(&private).expect("directory should create"); + chmod(&private, PRIVATE_DIR_MODE); + let options = Options { + socket: Some(path_string(&private.join("broker.sock"))), + journal: Some(path_string(&private.join("j.sqlite"))), + }; + let paths = resolve_paths_from(options, uid, shared.as_path(), |_| None) + .expect("an owner-only directory under a sticky ancestor is sound"); + assert_eq!(paths.socket, private.join("broker.sock")); + assert_eq!(paths.journal, private.join("j.sqlite")); + } + + #[test] + fn a_traversable_directory_may_not_hold_the_socket_or_the_journal() { + let outer = owner_only_tempdir(); + let uid = own_uid(outer.path()); + // The shape of /run: nobody else may write it, but everybody may + // traverse it, and the socket's own mode cannot be pinned. + let traversable = outer.path().join("run"); + std::fs::create_dir(&traversable).expect("directory should create"); + chmod(&traversable, 0o755); + + for options in [ + Options { + socket: Some(path_string(&traversable.join("broker.sock"))), + journal: Some(path_string(&outer.path().join("j.sqlite"))), + }, + Options { + socket: Some(path_string(&outer.path().join("b.sock"))), + journal: Some(path_string(&traversable.join("j.sqlite"))), + }, + ] { + let error = resolve_paths_from(options, uid, outer.path(), |_| None) + .expect_err("a world-traversable parent must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + // Only the owner-only shape the broker's own default already has + // is accepted, even though the ancestor above it stays traversable. + let private = traversable.join(PRIVATE_DIR_NAME); + std::fs::create_dir(&private).expect("directory should create"); + chmod(&private, PRIVATE_DIR_MODE); + let options = Options { + socket: Some(path_string(&private.join("broker.sock"))), + journal: Some(path_string(&private.join("j.sqlite"))), + }; + let paths = resolve_paths_from(options, uid, traversable.as_path(), |_| None) + .expect("an owner-only directory under a traversable ancestor is sound"); + assert_eq!(paths.socket, private.join("broker.sock")); + assert_eq!(paths.journal, private.join("j.sqlite")); + } + + #[test] + fn a_flag_path_is_vetted_the_same_way_as_the_environment() { + let base = owner_only_tempdir(); + let uid = own_uid(base.path()); + let options = Options { + socket: Some("broker.sock".to_owned()), + journal: Some(path_string(&base.path().join("j.sqlite"))), + }; + let error = resolve_paths_from(options, uid, base.path(), |_| None) + .expect_err("a flag must not bypass the vet an override is held to"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn the_journal_is_owner_only_once_it_is_open() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let journal = base.path().join("journal.sqlite"); + restrict_journal(&journal).expect("the journal should be restricted"); + let plane = ControlPlane::open(Box::new(MockProvider::new(0)), &journal) + .expect("control plane should open"); + drop(plane); + + let metadata = std::fs::symlink_metadata(&journal).expect("journal should stat"); + assert_eq!( + metadata.mode() & 0o777, + JOURNAL_FILE_MODE, + "the privileged audit journal must not be readable by anyone else" + ); + } + + #[test] + fn an_existing_journal_and_its_side_files_are_restricted() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let journal = base.path().join("journal.sqlite"); + std::fs::write(&journal, b"").expect("journal should create"); + chmod(&journal, 0o644); + let side = base.path().join("journal.sqlite-wal"); + std::fs::write(&side, b"").expect("side file should create"); + chmod(&side, 0o644); + + restrict_journal(&journal).expect("an existing journal should be restricted"); + for path in [&journal, &side] { + let metadata = std::fs::symlink_metadata(path).expect("path should stat"); + assert_eq!(metadata.mode() & 0o777, JOURNAL_FILE_MODE); + } + } + + #[test] + fn a_second_broker_cannot_take_the_instance_lock() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let lock = base.path().join(LOCK_FILE_NAME); + let journal = base.path().join("journal.sqlite"); + + let held = lock_single_instance(&lock).expect("the first broker should be alone"); + let error = lock_single_instance(&lock) + .expect_err("a second broker must not run beside the first"); + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + assert!( + !journal.exists(), + "a refused broker must not have touched the incumbent's journal" + ); + + // The kernel releases the lock with the last descriptor for it, so + // a crashed broker leaves nothing for its restart to clean up. + drop(held); + lock_single_instance(&lock).expect("a released lock should be takeable again"); + } + + #[test] + fn the_instance_lock_is_owner_only() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let lock = base.path().join(LOCK_FILE_NAME); + let _held = lock_single_instance(&lock).expect("the lock should be taken"); + + let metadata = std::fs::symlink_metadata(&lock).expect("the lock file should stat"); + assert_eq!(metadata.mode() & 0o777, JOURNAL_FILE_MODE); + } + + #[test] + fn a_help_flag_is_recognized_only_in_flag_position() { + for flag in HELP_FLAGS { + assert_eq!( + parse(&[flag]).expect("a help flag should parse"), + Invocation::Help, + "{flag} should ask for usage" + ); + assert_eq!( + parse(&["--journal", "/var/j.sqlite", flag]) + .expect("a help flag should parse anywhere a flag may stand"), + Invocation::Help + ); + assert!( + matches!(parse(&["--socket", flag]), Err(ArgError::MissingValue(_))), + "{flag} in value position is a mistyped command line, not usage" + ); + } + } + + #[test] + fn an_unusable_runtime_directory_falls_back_to_run() { + let fallback = Path::new(FALLBACK_RUNTIME_BASE); + assert_eq!(runtime_base(None, SESSION_UID), fallback); + assert_eq!(runtime_base(Some(OsStr::new("")), SESSION_UID), fallback); + assert_eq!( + runtime_base(Some(OsStr::new("run/user/1000")), SESSION_UID), + fallback, + "a relative runtime directory would resolve against the inherited cwd" + ); + assert_eq!( + runtime_base(Some(OsStr::new("/run/user/1000")), SESSION_UID), + Path::new("/run/user/1000") + ); + } + + #[test] + fn a_privileged_broker_ignores_the_inherited_runtime_directory() { + assert_eq!( + runtime_base(Some(OsStr::new("/tmp/attacker-owned")), ROOT_UID), + Path::new(FALLBACK_RUNTIME_BASE), + "root must not follow a runtime directory its caller chose" + ); + } + + #[test] + fn a_base_under_a_writable_ancestor_is_refused() { + let outer = tempfile::tempdir().expect("temporary directory should exist"); + let uid = own_uid(outer.path()); + let base = outer.path().join("base"); + std::fs::create_dir(&base).expect("base should create"); + chmod(&base, PRIVATE_DIR_MODE); + + // The base itself is sound; the directory holding it is not, so + // another user could swap the base for a symlink after the vet. + chmod(outer.path(), 0o777); + let error = private_directory_in(&base, uid) + .expect_err("a world-writable ancestor must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!( + !base.join(PRIVATE_DIR_NAME).exists(), + "nothing may be created under a refused ancestor" + ); + + // Restoring the ancestor, and making it sticky instead, both pass. + chmod(outer.path(), 0o755); + private_directory_in(&base, uid).expect("a sound ancestor chain is accepted"); + chmod(outer.path(), 0o1777); + private_directory_in(&base, uid).expect("a sticky ancestor cannot be swapped"); + } + + #[test] + fn a_symlinked_base_is_refused() { + let outer = tempfile::tempdir().expect("temporary directory should exist"); + let uid = own_uid(outer.path()); + let target = outer.path().join("target"); + std::fs::create_dir(&target).expect("target should create"); + chmod(&target, PRIVATE_DIR_MODE); + let base = outer.path().join("base"); + std::os::unix::fs::symlink(&target, &base).expect("symlink should create"); + + let error = + private_directory_in(&base, uid).expect_err("a symlinked base must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn a_missing_private_directory_is_created_owner_only() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let uid = own_uid(base.path()); + let directory = + private_directory_in(base.path(), uid).expect("the directory should be created"); + assert_eq!(directory, base.path().join(PRIVATE_DIR_NAME)); + let metadata = std::fs::symlink_metadata(&directory).expect("directory should stat"); + assert!(metadata.is_dir()); + assert_eq!(metadata.mode() & 0o777, PRIVATE_DIR_MODE); + } + + #[test] + fn an_existing_owner_only_private_directory_is_reused() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let uid = own_uid(base.path()); + let directory = base.path().join(PRIVATE_DIR_NAME); + std::fs::create_dir(&directory).expect("directory should create"); + chmod(&directory, PRIVATE_DIR_MODE); + assert_eq!( + private_directory_in(base.path(), uid).expect("an owner-only directory is sound"), + directory + ); + } + + #[test] + fn a_reachable_or_foreign_private_directory_is_refused() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let uid = own_uid(base.path()); + let directory = base.path().join(PRIVATE_DIR_NAME); + + std::fs::create_dir(&directory).expect("directory should create"); + chmod(&directory, 0o755); + let error = private_directory_in(base.path(), uid) + .expect_err("a group- and world-reachable directory must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + + chmod(&directory, PRIVATE_DIR_MODE); + let error = private_directory_in(base.path(), uid.wrapping_add(1)) + .expect_err("a directory owned by another uid must be refused"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn a_symlinked_private_directory_is_refused() { + let base = tempfile::tempdir().expect("temporary directory should exist"); + let uid = own_uid(base.path()); + let target = base.path().join("elsewhere"); + std::fs::create_dir(&target).expect("target should create"); + chmod(&target, PRIVATE_DIR_MODE); + std::os::unix::fs::symlink(&target, base.path().join(PRIVATE_DIR_NAME)) + .expect("symlink should create"); + + let error = private_directory_in(base.path(), uid) + .expect_err("a symlink must not stand in for the private directory"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + } } diff --git a/apps/broker/src/ownership.rs b/apps/broker/src/ownership.rs new file mode 100644 index 0000000..1ab4414 --- /dev/null +++ b/apps/broker/src/ownership.rs @@ -0,0 +1,144 @@ +//! Single-owner-per-knob enforcement. +//! +//! The safety invariant "only one provider owns a setting at a time" is +//! enforced here: a knob (a capability id) can be held by at most one owner at +//! once. A second owner is refused, fail-closed, until the first releases the +//! knob. Ownership is released automatically when its [`OwnershipGuard`] is +//! dropped, so a completed or panicking lifecycle never leaks a lease. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use thiserror::Error; + +/// Returned when a knob is already owned by another owner. +#[derive(Clone, Debug, Error, Eq, PartialEq)] +#[error("capability {capability_id} is already owned by {held_by}")] +pub struct OwnerConflict { + /// The contested capability id. + pub capability_id: String, + /// The owner that currently holds the knob. + pub held_by: String, +} + +/// Tracks which owner holds each knob and enforces a single owner per knob. +#[derive(Debug, Default)] +pub struct OwnershipLedger { + owners: Mutex>, +} + +impl OwnershipLedger { + /// Builds an empty ledger. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Acquires exclusive ownership of `capability_id` for `owner`. + /// + /// The returned [`OwnershipGuard`] releases the knob when dropped. + /// + /// # Errors + /// + /// Returns [`OwnerConflict`] if another owner already holds the knob. + pub fn acquire( + self: &Arc, + capability_id: &str, + owner: &str, + ) -> Result { + let mut owners = self.lock(); + if let Some(held_by) = owners.get(capability_id) { + return Err(OwnerConflict { + capability_id: capability_id.to_owned(), + held_by: held_by.clone(), + }); + } + owners.insert(capability_id.to_owned(), owner.to_owned()); + Ok(OwnershipGuard { + ledger: Arc::clone(self), + capability_id: capability_id.to_owned(), + }) + } + + /// Returns the current owner of a knob, if any. + #[must_use] + pub fn owner_of(&self, capability_id: &str) -> Option { + self.lock().get(capability_id).cloned() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.owners + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Holds a knob's ownership and releases it on drop. +#[derive(Debug)] +pub struct OwnershipGuard { + ledger: Arc, + capability_id: String, +} + +impl OwnershipGuard { + /// Returns the capability id this guard holds. + #[must_use] + pub fn capability_id(&self) -> &str { + &self.capability_id + } +} + +impl Drop for OwnershipGuard { + fn drop(&mut self) { + self.ledger.lock().remove(&self.capability_id); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::OwnershipLedger; + + #[test] + fn first_owner_acquires_and_second_owner_is_refused() { + let ledger = Arc::new(OwnershipLedger::new()); + let guard = ledger + .acquire("mock.value", "owner-a") + .expect("first owner should acquire"); + assert_eq!(ledger.owner_of("mock.value").as_deref(), Some("owner-a")); + + let conflict = ledger + .acquire("mock.value", "owner-b") + .expect_err("second owner should be refused"); + assert_eq!(conflict.held_by, "owner-a"); + assert_eq!(conflict.capability_id, "mock.value"); + + drop(guard); + assert!(ledger.owner_of("mock.value").is_none()); + } + + #[test] + fn distinct_knobs_have_independent_owners() { + let ledger = Arc::new(OwnershipLedger::new()); + let _first = ledger + .acquire("mock.value", "owner-a") + .expect("first knob should acquire"); + let _second = ledger + .acquire("power.scheme", "owner-b") + .expect("a different knob is independently ownable"); + } + + #[test] + fn releasing_a_knob_lets_a_new_owner_take_it() { + let ledger = Arc::new(OwnershipLedger::new()); + { + let _guard = ledger + .acquire("mock.value", "owner-a") + .expect("first owner should acquire"); + } + let _reacquired = ledger + .acquire("mock.value", "owner-b") + .expect("a released knob should be re-acquirable"); + } +} diff --git a/apps/broker/tests/integration.rs b/apps/broker/tests/integration.rs new file mode 100644 index 0000000..aa0bfd5 --- /dev/null +++ b/apps/broker/tests/integration.rs @@ -0,0 +1,411 @@ +//! Linux-safe end-to-end coverage for the broker's authenticated IPC boundary. +//! +//! A gateway-side [`BrokerClient`] connects over a real Unix domain socket and +//! drives a full mock-provider lifecycle through the broker, and the durable +//! journal is inspected to prove the transaction landed. The remaining tests +//! prove the fail-closed boundaries: a foreign peer is refused, a second owner +//! of a held knob is refused, malformed frames are rejected without taking the +//! broker down, and a broker that loses its control-plane worker shuts down +//! instead of serving on without one. +//! +//! The Unix domain socket transport is the only one implemented, so these tests +//! compile only on Unix. +#![cfg(unix)] + +use std::io; +use std::num::NonZeroU64; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use fpsmaxxing_broker::{BrokerService, MAX_CONNECTIONS, OwnershipLedger, serve, spawn_service}; +use fpsmaxxing_contracts::ipc::{ + BrokerErrorBody, BrokerErrorKind, BrokerOutcome, BrokerRequest, BrokerResponse, +}; +use fpsmaxxing_contracts::{ChangeRequest, ProviderManifest, StateSnapshot}; +use fpsmaxxing_control_plane::ControlPlane; +use fpsmaxxing_ipc::{ + BrokerClient, MAX_FRAME_BYTES, PeerAuthorizer, SameUidAuthorizer, UnixSocketTransport, + read_frame, write_frame, +}; +use fpsmaxxing_mock_provider::MockProvider; +use fpsmaxxing_provider_sdk::{Provider, ProviderError}; +use rusqlite::Connection; +use serde_json::json; +use tempfile::TempDir; +use tokio::io::AsyncWriteExt; +use tokio::net::UnixStream; +use tokio::task::JoinHandle; + +/// A provider that panics mid-lifecycle, standing in for a worker-thread fault. +struct PanickingProvider; + +impl Provider for PanickingProvider { + fn manifest(&self) -> ProviderManifest { + MockProvider::new(0).manifest() + } + + fn snapshot(&self) -> Result { + MockProvider::new(0).snapshot() + } + + fn preview(&self, request: &ChangeRequest) -> Result { + MockProvider::new(0).preview(request) + } + + fn apply(&mut self, _request: &ChangeRequest) -> Result<(), ProviderError> { + panic!("provider faulted while applying"); + } + + fn verify(&self, _request: &ChangeRequest) -> Result { + unreachable!("apply panics first"); + } + + fn rollback(&mut self, _snapshot: &StateSnapshot) -> Result<(), ProviderError> { + unreachable!("apply panics first"); + } +} + +/// A running broker bound to a temporary socket and journal. +struct TestBroker { + socket: PathBuf, + journal: PathBuf, + ledger: Arc, + serve_task: JoinHandle>, + _dir: TempDir, +} + +impl Drop for TestBroker { + fn drop(&mut self) { + self.serve_task.abort(); + } +} + +/// Starts a broker; `trusted_uid` overrides the ACL for the foreign-peer test. +async fn start(trusted_uid: Option) -> TestBroker { + start_with(trusted_uid, || Box::new(MockProvider::new(0))).await +} + +async fn start_with(trusted_uid: Option, provider: F) -> TestBroker +where + F: FnOnce() -> Box + Send + 'static, +{ + let dir = tempfile::tempdir().expect("temporary directory should exist"); + let socket = dir.path().join("broker.sock"); + let journal = dir.path().join("journal.sqlite"); + + let ledger = Arc::new(OwnershipLedger::new()); + let build_ledger = Arc::clone(&ledger); + let build_journal = journal.clone(); + let broker = spawn_service(move || { + let plane = ControlPlane::open(provider(), &build_journal).map_err(io::Error::other)?; + Ok(BrokerService::new(plane, build_ledger)) + }) + .await + .expect("broker service should start"); + + let transport = UnixSocketTransport::bind(&socket).expect("socket should bind"); + let authorizer: Arc = match trusted_uid { + Some(uid) => Arc::new(SameUidAuthorizer::new(uid)), + None => Arc::new(SameUidAuthorizer::for_current_process()), + }; + let serve_task = tokio::spawn(serve(transport, broker, authorizer)); + + TestBroker { + socket, + journal, + ledger, + serve_task, + _dir: dir, + } +} + +fn change(value: u64) -> ChangeRequest { + ChangeRequest { + capability_id: "mock.value".to_owned(), + parameters: json!({ "value": value }), + lease_seconds: NonZeroU64::new(30).expect("lease is non-zero"), + } +} + +fn journal_stages(journal: &Path) -> Vec { + let connection = Connection::open(journal).expect("journal should open"); + connection + .prepare("SELECT stage FROM experiment_journal ORDER BY sequence") + .expect("query should prepare") + .query_map([], |row| row.get::<_, String>(0)) + .expect("query should execute") + .collect::, _>>() + .expect("rows should read") +} + +fn expect_error(response: &BrokerResponse) -> &BrokerErrorBody { + match response { + BrokerResponse::Error { error } => error, + other => panic!("expected a typed error, got {other:?}"), + } +} + +fn expect_capabilities(response: BrokerResponse) -> ProviderManifest { + match response { + BrokerResponse::Capabilities { capabilities } => capabilities, + other => panic!("expected the capability catalog, got {other:?}"), + } +} + +#[tokio::test] +async fn client_runs_a_journaled_lifecycle_over_the_socket() { + let broker = start(None).await; + let mut client = BrokerClient::connect(&broker.socket) + .await + .expect("client should connect"); + + let discover = client + .request(&BrokerRequest::discover()) + .await + .expect("discover should respond"); + let manifest = expect_capabilities(discover); + assert!(manifest.capabilities.iter().any(|c| c.id == "mock.value")); + + let lifecycle = client + .request(&BrokerRequest::run_lifecycle("gateway", change(42))) + .await + .expect("lifecycle should respond"); + let BrokerResponse::Lifecycle { lifecycle } = lifecycle else { + panic!("a completed lifecycle should answer with its report"); + }; + assert_eq!(lifecycle.provider_id, "mock"); + assert!(lifecycle.verified && lifecycle.rolled_back); + + assert_eq!( + journal_stages(&broker.journal), + [ + "snapshot", + "preview", + "apply-intent", + "apply", + "verify", + "rollback", + "rollback-verify", + "completed" + ] + ); +} + +#[tokio::test] +async fn foreign_peer_is_refused() { + // Trust an impossible uid so the connecting peer (our own uid) is foreign. + let broker = start(Some(u32::MAX)).await; + let mut stream = UnixStream::connect(&broker.socket) + .await + .expect("client should connect"); + + let frame = read_frame(&mut stream) + .await + .expect("a rejection frame should read") + .expect("the broker should proactively reject"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + let error = expect_error(&response); + assert_eq!(error.kind, BrokerErrorKind::Unauthenticated); + + // The caller has not authenticated, so it learns that it was refused and + // nothing else: neither its own uid nor the broker's may cross. The socket + // the broker bound reports the uid it runs as, which is also this peer's. + let broker_uid = std::fs::symlink_metadata(&broker.socket) + .expect("the bound socket should stat") + .uid(); + assert_eq!(error.message, "peer is not authorized"); + for uid in [u32::MAX, broker_uid] { + assert!( + !error.message.contains(&uid.to_string()), + "a uid reached an unauthenticated peer: {}", + error.message + ); + } + + // No lifecycle ever reached the journal. + assert!(journal_stages(&broker.journal).is_empty()); +} + +#[tokio::test] +async fn second_owner_of_a_held_knob_is_refused() { + let broker = start(None).await; + let guard = broker + .ledger + .acquire("mock.value", "owner-a") + .expect("owner-a should hold the knob"); + + let mut client = BrokerClient::connect(&broker.socket) + .await + .expect("client should connect"); + let denied = client + .request(&BrokerRequest::run_lifecycle("owner-b", change(42))) + .await + .expect("conflict should respond"); + assert_eq!(expect_error(&denied).kind, BrokerErrorKind::OwnerConflict); + + // Releasing the knob lets the next owner through over the same connection. + drop(guard); + let granted = client + .request(&BrokerRequest::run_lifecycle("owner-b", change(7))) + .await + .expect("released knob should respond"); + assert_eq!(granted.outcome(), BrokerOutcome::Lifecycle); +} + +#[tokio::test] +async fn malformed_frames_are_rejected_without_crashing_the_broker() { + let broker = start(None).await; + + // Valid framing, non-JSON body: rejected, and the connection keeps serving. + let mut stream = UnixStream::connect(&broker.socket) + .await + .expect("client should connect"); + write_frame(&mut stream, b"this is not json") + .await + .expect("garbage should send"); + let frame = read_frame(&mut stream) + .await + .expect("a response should read") + .expect("the broker should answer"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + assert_eq!(expect_error(&response).kind, BrokerErrorKind::Malformed); + + let discover = serde_json::to_vec(&BrokerRequest::discover()).expect("discover should encode"); + write_frame(&mut stream, &discover) + .await + .expect("discover should send"); + let frame = read_frame(&mut stream) + .await + .expect("a response should read") + .expect("the connection should survive one bad frame"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + assert_eq!(response.outcome(), BrokerOutcome::Capabilities); + + // An oversized declared length is refused without allocation. + let mut stream = UnixStream::connect(&broker.socket) + .await + .expect("client should connect"); + stream + .write_all(&(MAX_FRAME_BYTES + 1).to_be_bytes()) + .await + .expect("oversized length should send"); + stream.flush().await.expect("flush should succeed"); + let frame = read_frame(&mut stream) + .await + .expect("a response should read") + .expect("the broker should answer"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + assert_eq!(expect_error(&response).kind, BrokerErrorKind::Malformed); + + // A zero-length frame is answered and the same connection keeps serving. + let mut stream = UnixStream::connect(&broker.socket) + .await + .expect("client should connect"); + stream + .write_all(&0u32.to_be_bytes()) + .await + .expect("empty frame should send"); + stream.flush().await.expect("flush should succeed"); + let frame = read_frame(&mut stream) + .await + .expect("a response should read") + .expect("the broker should answer an empty frame"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + assert_eq!(expect_error(&response).kind, BrokerErrorKind::Malformed); + write_frame(&mut stream, &discover) + .await + .expect("discover should send"); + let frame = read_frame(&mut stream) + .await + .expect("a response should read") + .expect("the connection should survive an empty frame"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + assert_eq!(response.outcome(), BrokerOutcome::Capabilities); + + // A brand-new client still works, proving the broker never crashed. + let mut client = BrokerClient::connect(&broker.socket) + .await + .expect("client should reconnect"); + let discover = client + .request(&BrokerRequest::discover()) + .await + .expect("discover should respond"); + assert_eq!(discover.outcome(), BrokerOutcome::Capabilities); +} + +#[tokio::test] +async fn concurrent_connections_are_capped_and_the_slot_is_returned() { + let broker = start(None).await; + + // Fill every slot with a peer that connects and then says nothing. + let mut idle = Vec::new(); + for _ in 0..MAX_CONNECTIONS { + idle.push( + BrokerClient::connect(&broker.socket) + .await + .expect("an in-cap client should connect"), + ); + } + // Drive one of them so the broker has demonstrably accepted the whole batch. + let served = idle[0] + .request(&BrokerRequest::discover()) + .await + .expect("an in-cap client should be served"); + assert_eq!(served.outcome(), BrokerOutcome::Capabilities); + + // The kernel completes this connect, but the broker must not serve it yet. + // The request is written once so the retry below cannot desynchronize it. + let mut queued = UnixStream::connect(&broker.socket) + .await + .expect("an over-cap client should still reach the backlog"); + let discover = serde_json::to_vec(&BrokerRequest::discover()).expect("discover should encode"); + write_frame(&mut queued, &discover) + .await + .expect("discover should send"); + assert!( + tokio::time::timeout(Duration::from_millis(250), read_frame(&mut queued)) + .await + .is_err(), + "an over-cap connection must wait for a free slot" + ); + + // Releasing a slot lets the queued peer through. + idle.truncate(MAX_CONNECTIONS - 1); + let frame = tokio::time::timeout(Duration::from_secs(10), read_frame(&mut queued)) + .await + .expect("a freed slot should admit the queued peer") + .expect("a response should read") + .expect("the queued peer should be served"); + let response: BrokerResponse = serde_json::from_slice(&frame).expect("response should be JSON"); + assert_eq!(response.outcome(), BrokerOutcome::Capabilities); +} + +#[tokio::test] +async fn a_dead_control_plane_worker_shuts_the_broker_down() { + let mut broker = start_with(None, || Box::new(PanickingProvider)).await; + let mut client = BrokerClient::connect(&broker.socket) + .await + .expect("client should connect"); + + // The panic unwinds out of the worker thread; this request is answered as an + // internal fault rather than hanging. + let faulted = client + .request(&BrokerRequest::run_lifecycle("gateway", change(42))) + .await + .expect("the faulted request should still be answered"); + assert_eq!(expect_error(&faulted).kind, BrokerErrorKind::Internal); + + // The broker must not stay up without a control plane: serve reports the + // loss so the process exits and a supervisor restarts it. + let outcome = tokio::time::timeout(std::time::Duration::from_secs(10), &mut broker.serve_task) + .await + .expect("serve should stop once the worker is gone") + .expect("the serve task should not itself panic"); + let error = outcome.expect_err("serve must report the lost worker"); + assert!( + error.to_string().contains("worker stopped"), + "unexpected shutdown reason: {error}" + ); +} diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index b58b1ef..7c13713 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -17,7 +17,7 @@ thiserror.workspace = true [dev-dependencies] rusqlite.workspace = true -tempfile = "3" +tempfile.workspace = true [lints] workspace = true diff --git a/crates/contracts/src/ipc.rs b/crates/contracts/src/ipc.rs new file mode 100644 index 0000000..502f940 --- /dev/null +++ b/crates/contracts/src/ipc.rs @@ -0,0 +1,472 @@ +//! Wire contracts for the authenticated local broker IPC boundary. +//! +//! These types are the only vocabulary the unprivileged side may speak to the +//! privileged broker. The broker exposes exactly two operations - capability +//! discovery and a bounded provider lifecycle - and no field can carry a raw +//! shell command, an arbitrary Registry path, or a raw hardware primitive. +//! The transport that carries these messages is abstracted behind a trait in +//! the `fpsmaxxing-ipc` crate: a Unix domain socket on Linux today, a Windows +//! named pipe later. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ChangeRequest, ProviderManifest}; + +/// The operation a client asks the privileged broker to perform. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrokerOp { + /// Return the typed, policy-approved capability catalog. + Discover, + /// Run snapshot, preview, apply, verify, and rollback for one bounded + /// change. + RunLifecycle, +} + +/// A request sent to the broker over the authenticated local IPC boundary. +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BrokerRequest { + /// Operation the client is invoking. + pub op: BrokerOp, + /// Logical owner that holds the single-owner lease for `run-lifecycle`; the + /// broker rejects a second concurrent owner of the same knob. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// Bounded change for `run-lifecycle`; ignored by `discover`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change: Option, +} + +impl BrokerRequest { + /// Builds a capability discovery request. + #[must_use] + pub const fn discover() -> Self { + Self { + op: BrokerOp::Discover, + owner: None, + change: None, + } + } + + /// Builds a lifecycle request owned by `owner` for one bounded change. + #[must_use] + pub fn run_lifecycle(owner: impl Into, change: ChangeRequest) -> Self { + Self { + op: BrokerOp::RunLifecycle, + owner: Some(owner.into()), + change: Some(change), + } + } +} + +/// Which payload a [`BrokerResponse`] carries. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrokerOutcome { + /// The response carries a capability catalog. + Capabilities, + /// The response carries a completed lifecycle report. + Lifecycle, + /// The response carries a typed error. + Error, +} + +/// A response returned by the broker. +/// +/// The `outcome` discriminator and the payload it names are one value, so a tag +/// without its payload - or a tag paired with another variant's payload - is not +/// representable and does not deserialize. A consumer therefore never has to +/// unwrap a payload the protocol promises is present, and a malformed or +/// version-skewed peer is refused at decode time rather than panicking a caller. +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(tag = "outcome", rename_all = "kebab-case", deny_unknown_fields)] +pub enum BrokerResponse { + /// The typed, policy-approved capability catalog. + Capabilities { + /// The catalog the broker advertises. + capabilities: ProviderManifest, + }, + /// A completed provider lifecycle. + Lifecycle { + /// The auditable result of the lifecycle. + lifecycle: LifecycleReport, + }, + /// A typed denial or failure. + Error { + /// Why the request was denied or failed. + error: BrokerErrorBody, + }, +} + +impl BrokerResponse { + /// Builds a capability-catalog response. + #[must_use] + pub const fn capabilities(manifest: ProviderManifest) -> Self { + Self::Capabilities { + capabilities: manifest, + } + } + + /// Builds a completed-lifecycle response. + #[must_use] + pub const fn lifecycle(report: LifecycleReport) -> Self { + Self::Lifecycle { lifecycle: report } + } + + /// Builds a typed error response. + #[must_use] + pub fn error(kind: BrokerErrorKind, message: impl Into) -> Self { + Self::Error { + error: BrokerErrorBody { + kind, + message: message.into(), + }, + } + } + + /// Returns which payload this response carries. + #[must_use] + pub const fn outcome(&self) -> BrokerOutcome { + match self { + Self::Capabilities { .. } => BrokerOutcome::Capabilities, + Self::Lifecycle { .. } => BrokerOutcome::Lifecycle, + Self::Error { .. } => BrokerOutcome::Error, + } + } +} + +/// The auditable result of a completed provider lifecycle, mirrored onto the +/// wire so the unprivileged side never links against the control plane. +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LifecycleReport { + /// Provider that owned the change. + pub provider_id: String, + /// Human-readable preview produced before the write. + pub preview: String, + /// Whether the requested value was observed after apply. + pub verified: bool, + /// Whether the captured baseline was restored before returning. + pub rolled_back: bool, +} + +/// A stable, machine-readable classification for a broker denial or failure. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrokerErrorKind { + /// The peer failed the local IPC authentication check and was rejected + /// before any request was processed. + Unauthenticated, + /// The frame or request could not be decoded into a typed request. + Malformed, + /// The capability id is not in the provider catalog. + UnknownCapability, + /// The request violated the bounded broker policy. + PolicyDenied, + /// Another owner already holds the requested knob. + OwnerConflict, + /// The provider lifecycle failed after the request was accepted. + LifecycleFailed, + /// The broker hit an unexpected internal fault. + Internal, +} + +/// A typed error body returned to the client. +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BrokerErrorBody { + /// Stable machine-readable error classification. + pub kind: BrokerErrorKind, + /// Human-readable detail; never contains secrets or raw host primitives. + pub message: String, +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::num::NonZeroU64; + + use serde_json::{Value, json}; + + use super::{ + BrokerErrorBody, BrokerErrorKind, BrokerOp, BrokerOutcome, BrokerRequest, BrokerResponse, + LifecycleReport, + }; + use crate::ChangeRequest; + use crate::test_support::{assert_same_shape, properties, string_set, wire_string}; + + const REQUEST_SCHEMA: &str = include_str!("../../../schemas/broker-request.schema.json"); + const RESPONSE_SCHEMA: &str = include_str!("../../../schemas/broker-response.schema.json"); + + fn request_schema() -> Value { + serde_json::from_str(REQUEST_SCHEMA).expect("request schema should parse") + } + + fn response_schema() -> Value { + serde_json::from_str(RESPONSE_SCHEMA).expect("response schema should parse") + } + + /// Indexes a tagged-union schema's `oneOf` branches by their `outcome` tag. + fn branches_by_tag(schema: &Value) -> BTreeSet { + schema["oneOf"] + .as_array() + .expect("a tagged union should declare oneOf") + .iter() + .map(|branch| { + branch["properties"]["outcome"]["const"] + .as_str() + .expect("every branch should pin its outcome tag") + .to_owned() + }) + .collect() + } + + /// Returns the `oneOf` branch a tagged-union schema gives to `tag`. + fn branch(schema: &Value, tag: &str) -> Value { + schema["oneOf"] + .as_array() + .expect("a tagged union should declare oneOf") + .iter() + .find(|branch| branch["properties"]["outcome"]["const"] == json!(tag)) + .unwrap_or_else(|| panic!("the schema should declare a {tag} branch")) + .clone() + } + + fn sample_change() -> ChangeRequest { + ChangeRequest { + capability_id: "mock.value".to_owned(), + parameters: json!({ "value": 42 }), + lease_seconds: NonZeroU64::new(30).expect("lease is non-zero"), + } + } + + fn sample_report() -> LifecycleReport { + LifecycleReport { + provider_id: "mock".to_owned(), + preview: "set mock.value from 0 to 42".to_owned(), + verified: true, + rolled_back: true, + } + } + + #[test] + fn enum_wire_strings_match_schemas() { + assert_eq!(wire_string(BrokerOp::Discover), "discover"); + assert_eq!(wire_string(BrokerOp::RunLifecycle), "run-lifecycle"); + assert_eq!(wire_string(BrokerOutcome::Capabilities), "capabilities"); + assert_eq!(wire_string(BrokerOutcome::Lifecycle), "lifecycle"); + assert_eq!(wire_string(BrokerOutcome::Error), "error"); + assert_eq!( + wire_string(BrokerErrorKind::Unauthenticated), + "unauthenticated" + ); + assert_eq!(wire_string(BrokerErrorKind::Malformed), "malformed"); + assert_eq!( + wire_string(BrokerErrorKind::UnknownCapability), + "unknown-capability" + ); + assert_eq!(wire_string(BrokerErrorKind::PolicyDenied), "policy-denied"); + assert_eq!( + wire_string(BrokerErrorKind::OwnerConflict), + "owner-conflict" + ); + assert_eq!( + wire_string(BrokerErrorKind::LifecycleFailed), + "lifecycle-failed" + ); + assert_eq!(wire_string(BrokerErrorKind::Internal), "internal"); + + assert_eq!( + string_set(&request_schema()["properties"]["op"]["enum"]), + [BrokerOp::Discover, BrokerOp::RunLifecycle] + .map(wire_string) + .into_iter() + .collect() + ); + let response = response_schema(); + assert_eq!( + branches_by_tag(&response), + [ + BrokerOutcome::Capabilities, + BrokerOutcome::Lifecycle, + BrokerOutcome::Error, + ] + .map(wire_string) + .into_iter() + .collect() + ); + assert_eq!( + string_set(&response["$defs"]["BrokerErrorBody"]["properties"]["kind"]["enum"]), + [ + BrokerErrorKind::Unauthenticated, + BrokerErrorKind::Malformed, + BrokerErrorKind::UnknownCapability, + BrokerErrorKind::PolicyDenied, + BrokerErrorKind::OwnerConflict, + BrokerErrorKind::LifecycleFailed, + BrokerErrorKind::Internal, + ] + .map(wire_string) + .into_iter() + .collect() + ); + } + + #[test] + fn request_fields_match_schema() { + let schema = request_schema(); + assert_same_shape(&schemars::schema_for!(BrokerRequest), &schema); + assert_eq!(schema["required"], json!(["op"])); + assert_eq!(schema["additionalProperties"], json!(false)); + } + + #[test] + fn response_variants_match_schema() { + let schema = response_schema(); + let generated = serde_json::to_value(schemars::schema_for!(BrokerResponse)) + .expect("generated schema should serialize"); + assert_eq!(branches_by_tag(&generated), branches_by_tag(&schema)); + for tag in branches_by_tag(&schema) { + let generated = branch(&generated, &tag); + let checked_in = branch(&schema, &tag); + assert_eq!(properties(&generated), properties(&checked_in), "{tag}"); + assert_eq!( + string_set(&generated["required"]), + string_set(&checked_in["required"]), + "{tag}" + ); + assert_eq!(checked_in["additionalProperties"], json!(false), "{tag}"); + assert!( + string_set(&checked_in["required"]).contains("outcome"), + "{tag} must require its own tag" + ); + } + } + + #[test] + fn change_request_fields_match_request_schema_defs() { + assert_same_shape( + &schemars::schema_for!(ChangeRequest), + &request_schema()["$defs"]["ChangeRequest"], + ); + } + + #[test] + fn lifecycle_report_fields_match_response_schema_defs() { + assert_same_shape( + &schemars::schema_for!(LifecycleReport), + &response_schema()["$defs"]["LifecycleReport"], + ); + } + + #[test] + fn error_body_fields_match_response_schema_defs() { + assert_same_shape( + &schemars::schema_for!(BrokerErrorBody), + &response_schema()["$defs"]["BrokerErrorBody"], + ); + } + + #[test] + fn change_request_parameters_are_an_object_in_both() { + assert_eq!( + request_schema()["$defs"]["ChangeRequest"]["properties"]["parameters"]["type"], + json!("object") + ); + let generated = serde_json::to_value(schemars::schema_for!(ChangeRequest)) + .expect("generated schema should serialize"); + assert_eq!( + generated["properties"]["parameters"]["type"], + json!("object") + ); + + let mut serialized = + serde_json::to_value(sample_change()).expect("change should serialize"); + serialized["parameters"] = json!("value=42"); + assert!(serde_json::from_value::(serialized).is_err()); + } + + #[test] + fn unknown_fields_are_rejected() { + let mut serialized = + serde_json::to_value(BrokerRequest::discover()).expect("request should serialize"); + serialized["unexpected"] = json!(true); + assert!(serde_json::from_value::(serialized).is_err()); + + let mut serialized = serde_json::to_value(BrokerResponse::error( + BrokerErrorKind::PolicyDenied, + "denied", + )) + .expect("response should serialize"); + serialized["unexpected"] = json!(true); + assert!(serde_json::from_value::(serialized).is_err()); + } + + #[test] + fn a_tag_without_its_payload_is_not_representable() { + for tag in ["capabilities", "lifecycle", "error"] { + assert!( + serde_json::from_value::(json!({ "outcome": tag })).is_err(), + "{tag} must not deserialize without its payload" + ); + } + } + + #[test] + fn a_mismatched_tag_and_payload_is_not_representable() { + let report = serde_json::to_value(sample_report()).expect("report should serialize"); + for tag in ["capabilities", "error"] { + assert!( + serde_json::from_value::( + json!({ "outcome": tag, "lifecycle": report }) + ) + .is_err(), + "{tag} must not deserialize carrying a lifecycle report" + ); + } + } + + #[test] + fn requests_round_trip() { + for request in [ + BrokerRequest::discover(), + BrokerRequest::run_lifecycle("owner-a", sample_change()), + ] { + let serialized = serde_json::to_value(&request).expect("request should serialize"); + let deserialized: BrokerRequest = + serde_json::from_value(serialized).expect("request should deserialize"); + assert_eq!(request, deserialized); + } + } + + #[test] + fn responses_round_trip() { + let responses = [ + BrokerResponse::lifecycle(sample_report()), + BrokerResponse::error(BrokerErrorKind::OwnerConflict, "held by owner-a"), + ]; + for response in responses { + let serialized = serde_json::to_value(&response).expect("response should serialize"); + assert_eq!( + serialized["outcome"], + json!(wire_string(response.outcome())) + ); + let deserialized: BrokerResponse = + serde_json::from_value(serialized).expect("response should deserialize"); + assert_eq!(response, deserialized); + } + } + + #[test] + fn error_body_carries_the_typed_kind() { + let body = BrokerErrorBody { + kind: BrokerErrorKind::UnknownCapability, + message: "shell.exec".to_owned(), + }; + let value = serde_json::to_value(&body).expect("error body should serialize"); + assert_eq!(value["kind"], "unknown-capability"); + assert_eq!(value["message"], "shell.exec"); + } +} diff --git a/crates/contracts/src/lib.rs b/crates/contracts/src/lib.rs index e0a2f5a..6a52f37 100644 --- a/crates/contracts/src/lib.rs +++ b/crates/contracts/src/lib.rs @@ -1,9 +1,13 @@ //! Versioned data contracts shared by `FPSMaxxing` applications and sidecars. +pub mod ipc; +#[cfg(test)] +mod test_support; + use std::num::{NonZeroU32, NonZeroU64}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; /// The safety classification attached to a capability or requested change. @@ -82,7 +86,9 @@ pub const MAX_LEASE_SECONDS: u64 = 300; pub struct ChangeRequest { /// Capability being invoked. pub capability_id: String, - /// Capability-specific parameters. + /// Capability-specific parameters; always a JSON object. + #[serde(deserialize_with = "object_parameters")] + #[schemars(with = "serde_json::Map")] pub parameters: Value, /// Automatic rollback deadline in seconds; every mutation carries a /// non-zero TTL lease, at most [`MAX_LEASE_SECONDS`]. @@ -90,6 +96,23 @@ pub struct ChangeRequest { pub lease_seconds: NonZeroU64, } +/// Accepts only a JSON object for [`ChangeRequest::parameters`]. +/// +/// Capability parameters are always named, and the checked-in schemas type the +/// field as an object. Without this the Rust type would accept a bare scalar or +/// array that every schema validator on the same wire would reject. +fn object_parameters<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let parameters = Value::deserialize(deserializer)?; + if parameters.is_object() { + Ok(parameters) + } else { + Err(serde::de::Error::custom("parameters must be a JSON object")) + } +} + /// Opaque provider state captured before a change. #[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -300,6 +323,7 @@ mod tests { MetricSummary, NonZeroU32, NonZeroU64, Persistence, ProviderManifest, RiskClass, Verdict, VerdictReason, }; + use crate::test_support::{properties, serialized_fields, string_set, wire_string}; const CAPABILITY_SCHEMA: &str = include_str!("../../../schemas/capability.schema.json"); const SIDECAR_SCHEMA: &str = include_str!("../../../schemas/sidecar.schema.json"); @@ -307,28 +331,6 @@ mod tests { const VERDICT_SCHEMA: &str = include_str!("../../../schemas/verdict.schema.json"); const METRIC_SAMPLE_SCHEMA: &str = include_str!("../../../schemas/metric-sample.schema.json"); - fn wire_string(value: impl serde::Serialize) -> String { - serde_json::to_value(value) - .expect("serialization should succeed") - .as_str() - .expect("enums should serialize to strings") - .to_owned() - } - - fn string_set(values: &Value) -> BTreeSet { - values - .as_array() - .expect("schema field should be an array") - .iter() - .map(|value| { - value - .as_str() - .expect("schema entries should be strings") - .to_owned() - }) - .collect() - } - fn sample_capability() -> CapabilityDescriptor { CapabilityDescriptor { id: "process.cpu-affinity".to_owned(), @@ -394,22 +396,8 @@ mod tests { fn capability_fields_match_capability_schema() { let schema: Value = serde_json::from_str(CAPABILITY_SCHEMA).expect("capability schema should parse"); - let serialized = - serde_json::to_value(sample_capability()).expect("capability should serialize"); - let fields: BTreeSet = serialized - .as_object() - .expect("capability should serialize to an object") - .keys() - .cloned() - .collect(); - - let properties: BTreeSet = schema["properties"] - .as_object() - .expect("schema should declare properties") - .keys() - .cloned() - .collect(); - assert_eq!(fields, properties); + let fields = serialized_fields(sample_capability()); + assert_eq!(fields, properties(&schema)); assert_eq!(fields, string_set(&schema["required"])); assert_eq!(schema["additionalProperties"], json!(false)); } @@ -418,22 +406,8 @@ mod tests { fn manifest_fields_match_sidecar_schema() { let schema: Value = serde_json::from_str(SIDECAR_SCHEMA).expect("sidecar schema should parse"); - let serialized = - serde_json::to_value(sample_manifest()).expect("manifest should serialize"); - let fields: BTreeSet = serialized - .as_object() - .expect("manifest should serialize to an object") - .keys() - .cloned() - .collect(); - - let properties: BTreeSet = schema["properties"] - .as_object() - .expect("schema should declare properties") - .keys() - .cloned() - .collect(); - assert_eq!(fields, properties); + let fields = serialized_fields(sample_manifest()); + assert_eq!(fields, properties(&schema)); assert_eq!(fields, string_set(&schema["required"])); assert_eq!(schema["additionalProperties"], json!(false)); } @@ -499,6 +473,21 @@ mod tests { ); } + #[test] + fn non_object_parameters_are_rejected_like_the_schema() { + for parameters in [json!("value=1"), json!(1), json!([1]), json!(null)] { + let serialized = json!({ + "capability_id": "mock.value", + "parameters": parameters, + "lease_seconds": 1 + }); + assert!( + serde_json::from_value::(serialized).is_err(), + "{parameters} must not deserialize as capability parameters" + ); + } + } + /// Asserts that two object schemas declare the same fields. fn assert_object_parity(label: &str, generated: &Value, checked_in: &Value) { let generated_properties: BTreeSet = generated["properties"] diff --git a/crates/contracts/src/test_support.rs b/crates/contracts/src/test_support.rs new file mode 100644 index 0000000..cf38242 --- /dev/null +++ b/crates/contracts/src/test_support.rs @@ -0,0 +1,71 @@ +//! Shared helpers for the schema-synchronization tests. +//! +//! `schemas/*.json` are hand-written and must agree with the types in this +//! crate. Every schema-sync test module compares them the same way, so the +//! comparison lives here once rather than being re-derived per module. + +use std::collections::BTreeSet; + +use serde_json::Value; + +/// Returns the wire string a serialized enum variant produces. +pub fn wire_string(value: impl serde::Serialize) -> String { + serde_json::to_value(value) + .expect("serialization should succeed") + .as_str() + .expect("enums should serialize to strings") + .to_owned() +} + +/// Collects a JSON array of strings, such as a schema's `required` list. +pub fn string_set(values: &Value) -> BTreeSet { + values + .as_array() + .expect("schema field should be an array") + .iter() + .map(|value| { + value + .as_str() + .expect("schema entries should be strings") + .to_owned() + }) + .collect() +} + +/// Collects the property names an object schema declares. +pub fn properties(schema: &Value) -> BTreeSet { + schema["properties"] + .as_object() + .expect("schema should declare properties") + .keys() + .cloned() + .collect() +} + +/// Collects the field names a value serializes to. +pub fn serialized_fields(value: impl serde::Serialize) -> BTreeSet { + serde_json::to_value(value) + .expect("value should serialize") + .as_object() + .expect("value should serialize to an object") + .keys() + .cloned() + .collect() +} + +/// Asserts that `generated` and `checked_in` declare the same object shape. +/// +/// Property names, the `required` list, and `additionalProperties` are compared; +/// a definition that drifts in any of the three fails the calling test. +pub fn assert_same_shape(generated: &schemars::Schema, checked_in: &Value) { + let generated = serde_json::to_value(generated).expect("generated schema should serialize"); + assert_eq!(properties(&generated), properties(checked_in)); + assert_eq!( + string_set(&generated["required"]), + string_set(&checked_in["required"]) + ); + assert_eq!( + generated["additionalProperties"], + checked_in["additionalProperties"] + ); +} diff --git a/crates/control-plane/Cargo.toml b/crates/control-plane/Cargo.toml index ade8e8c..437d700 100644 --- a/crates/control-plane/Cargo.toml +++ b/crates/control-plane/Cargo.toml @@ -16,7 +16,7 @@ serde_json.workspace = true thiserror.workspace = true [dev-dependencies] -tempfile = "3" +tempfile.workspace = true [lints] workspace = true diff --git a/crates/ipc/Cargo.toml b/crates/ipc/Cargo.toml new file mode 100644 index 0000000..f0657b9 --- /dev/null +++ b/crates/ipc/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "fpsmaxxing-ipc" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +fpsmaxxing-contracts.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["net", "io-util"] } + +[target.'cfg(unix)'.dependencies] +rustix.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "io-util", "net"] } + +[target.'cfg(unix)'.dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/ipc/src/auth.rs b/crates/ipc/src/auth.rs new file mode 100644 index 0000000..33f29b9 --- /dev/null +++ b/crates/ipc/src/auth.rs @@ -0,0 +1,157 @@ +//! Fail-closed peer authentication for the local IPC boundary. +//! +//! The transport resolves each connection's [`PeerIdentity`]; a +//! [`PeerAuthorizer`] then decides, fail-closed, whether that peer may talk to +//! the broker. On Linux the identity comes from `SO_PEERCRED` and +//! [`SameUidAuthorizer`] enforces a same-uid ACL. The trait is the portable +//! contract: a Windows named-pipe transport would resolve a client SID and a +//! SID-ACL authorizer would satisfy the same trait without touching callers. + +use thiserror::Error; + +/// The local identity of a connected peer, resolved by the transport. +/// +/// On Unix this is populated from `SO_PEERCRED`. A Windows named-pipe transport +/// would populate the same structure from the client's token, and a Windows +/// [`PeerAuthorizer`] would authorize it against a SID ACL. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerIdentity { + /// The peer's user id from `SO_PEERCRED` on Unix. + pub uid: u32, + /// The peer's process id when the platform reports it. + pub pid: Option, +} + +/// Fail-closed authorization of a connected local peer. +/// +/// Implementations must return [`Err`] for any peer that is not explicitly +/// permitted, so an unrecognized or spoofed peer is refused rather than served. +pub trait PeerAuthorizer: Send + Sync { + /// Returns `Ok(())` only for an explicitly permitted peer. + /// + /// # Errors + /// + /// Returns [`AuthError`] for any peer that fails the ACL check. + fn authorize(&self, peer: &PeerIdentity) -> Result<(), AuthError>; +} + +/// Why a peer was refused at the IPC boundary. +#[derive(Debug, Error)] +pub enum AuthError { + /// The peer's uid does not match the uid the broker runs as. + #[error("peer uid {actual} does not match the broker uid {expected}")] + ForeignUid { + /// The uid the broker trusts. + expected: u32, + /// The uid the connecting peer presented. + actual: u32, + }, +} + +/// Authorizes only peers whose uid matches the broker's own uid. +/// +/// This is the Linux ACL primitive: the broker trusts exactly the uid it runs +/// as, so a process owned by any other user is refused before any request is +/// read. +/// +/// This same-uid ACL is the deliberate *interim* policy for the current +/// single-user, Linux-safe mock path, where the broker and its only client run +/// as the same desktop user. It is not the shipping policy for the privileged +/// split described in `docs/ARCHITECTURE.md`: once the broker runs as a service +/// account, an unprivileged gateway is refused by construction. The real +/// privilege-split ACL arrives with the Windows named-pipe SID implementation of +/// [`PeerAuthorizer`], which is tracked separately; callers reach it through +/// this trait, so no call site changes when it lands. +/// +/// One consequence of the interim: every authorized peer is the same identity, +/// so the verified [`PeerIdentity`] distinguishes nothing once the ACL has +/// passed. Journaling the peer uid and pid against each lifecycle, and +/// authenticating the client-supplied owner label against them, only carry their +/// weight once split-privilege ACLs arrive; both are tracked as follow-up work +/// `fpsm-broker-splitacl`. +pub struct SameUidAuthorizer { + expected_uid: u32, +} + +impl SameUidAuthorizer { + /// Builds an authorizer that trusts exactly `expected_uid`. + #[must_use] + pub const fn new(expected_uid: u32) -> Self { + Self { expected_uid } + } + + /// Builds an authorizer that trusts the calling process's own effective uid. + /// + /// The trust anchor is the broker's own credentials, never a by-name + /// filesystem lookup of the socket: an attacker who can replace the socket + /// path must not be able to redirect the ACL onto their own uid. + #[cfg(unix)] + #[must_use] + pub fn for_current_process() -> Self { + Self::new(rustix::process::geteuid().as_raw()) + } + + /// Returns the uid this authorizer trusts. + #[must_use] + pub const fn expected_uid(&self) -> u32 { + self.expected_uid + } +} + +impl PeerAuthorizer for SameUidAuthorizer { + fn authorize(&self, peer: &PeerIdentity) -> Result<(), AuthError> { + if peer.uid == self.expected_uid { + Ok(()) + } else { + Err(AuthError::ForeignUid { + expected: self.expected_uid, + actual: peer.uid, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::{AuthError, PeerAuthorizer, PeerIdentity, SameUidAuthorizer}; + + #[test] + fn same_uid_peer_is_authorized() { + let authorizer = SameUidAuthorizer::new(1000); + let peer = PeerIdentity { + uid: 1000, + pid: Some(42), + }; + assert!(authorizer.authorize(&peer).is_ok()); + } + + #[test] + fn foreign_uid_peer_is_refused() { + let authorizer = SameUidAuthorizer::new(1000); + let peer = PeerIdentity { + uid: 1001, + pid: Some(42), + }; + let error = authorizer + .authorize(&peer) + .expect_err("a foreign uid must be refused"); + assert!(matches!( + error, + AuthError::ForeignUid { + expected: 1000, + actual: 1001 + } + )); + } + + #[cfg(unix)] + #[test] + fn the_current_process_is_authorized_by_its_own_credentials() { + let authorizer = SameUidAuthorizer::for_current_process(); + let peer = PeerIdentity { + uid: rustix::process::geteuid().as_raw(), + pid: Some(std::process::id().cast_signed()), + }; + assert!(authorizer.authorize(&peer).is_ok()); + } +} diff --git a/crates/ipc/src/client.rs b/crates/ipc/src/client.rs new file mode 100644 index 0000000..800ff1b --- /dev/null +++ b/crates/ipc/src/client.rs @@ -0,0 +1,70 @@ +//! The unprivileged-side client for the broker IPC boundary. + +use std::io; +use std::path::Path; + +use fpsmaxxing_contracts::ipc::{BrokerRequest, BrokerResponse}; +use thiserror::Error; +use tokio::net::UnixStream; + +use crate::frame::{FrameError, read_frame, write_frame}; + +/// A failure while talking to the broker. +#[derive(Debug, Error)] +pub enum ClientError { + /// A frame could not be read or written. + #[error(transparent)] + Frame(#[from] FrameError), + /// The connection could not be established. + #[error(transparent)] + Io(#[from] io::Error), + /// A request or response could not be encoded or decoded. + #[error(transparent)] + Codec(#[from] serde_json::Error), + /// The broker closed the connection before answering. + #[error("broker closed the connection before responding")] + Closed, +} + +/// A client for the broker's authenticated local IPC boundary. +/// +/// This is the unprivileged side of that boundary: it reaches the privileged +/// broker without linking against the control plane. The alpha gateway is not +/// wired to it yet - it still opens an in-process control plane of its own - so +/// today's only caller is the broker's end-to-end coverage. It speaks the same +/// length-delimited framing and typed [`BrokerRequest`]/[`BrokerResponse`] +/// contract that a Windows named-pipe client would reuse unchanged. +pub struct BrokerClient { + stream: UnixStream, +} + +impl BrokerClient { + /// Connects to the broker socket at `path`. + /// + /// # Errors + /// + /// Returns an error if the socket cannot be reached. + pub async fn connect(path: impl AsRef) -> io::Result { + Ok(Self { + stream: UnixStream::connect(path).await?, + }) + } + + /// Sends one request and reads the broker's response. + /// + /// # Errors + /// + /// Returns an error if the request cannot be encoded or sent, or the broker + /// closes the connection or returns an undecodable response. + pub async fn request( + &mut self, + request: &BrokerRequest, + ) -> Result { + let bytes = serde_json::to_vec(request)?; + write_frame(&mut self.stream, &bytes).await?; + let frame = read_frame(&mut self.stream) + .await? + .ok_or(ClientError::Closed)?; + Ok(serde_json::from_slice(&frame)?) + } +} diff --git a/crates/ipc/src/frame.rs b/crates/ipc/src/frame.rs new file mode 100644 index 0000000..454e953 --- /dev/null +++ b/crates/ipc/src/frame.rs @@ -0,0 +1,171 @@ +//! Length-delimited framing for the local IPC boundary. +//! +//! Every message is a big-endian `u32` byte length followed by that many bytes +//! of UTF-8 JSON. Frames are bounded so a hostile or buggy peer cannot force an +//! unbounded allocation; an over-long or truncated frame fails closed instead +//! of crashing the reader. The framing is transport-agnostic and works over any +//! `tokio` stream, so the Unix socket used today and a future Windows named pipe +//! share it unchanged. + +use std::io::{self, ErrorKind}; + +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +/// The largest frame body the broker will read or write, in bytes. +pub const MAX_FRAME_BYTES: u32 = 1 << 20; + +/// A failure while reading or writing a length-delimited frame. +#[derive(Debug, Error)] +pub enum FrameError { + /// The underlying transport read or write failed. + #[error(transparent)] + Io(#[from] io::Error), + /// The declared or supplied frame length exceeds [`MAX_FRAME_BYTES`]. + #[error("frame length {length} exceeds the {max} byte maximum", max = MAX_FRAME_BYTES)] + TooLarge { + /// The rejected frame length in bytes. + length: u64, + }, + /// The frame declared a zero-length body; every frame carries a message. + #[error("frame body is empty")] + Empty, +} + +/// Writes one length-delimited frame and flushes it. +/// +/// # Errors +/// +/// Returns [`FrameError::Empty`] when `body` is empty and +/// [`FrameError::TooLarge`] when it exceeds [`MAX_FRAME_BYTES`] - the two frames +/// [`read_frame`] refuses - or [`FrameError::Io`] when the transport write fails. +pub async fn write_frame(writer: &mut W, body: &[u8]) -> Result<(), FrameError> +where + W: AsyncWrite + Unpin, +{ + if body.is_empty() { + return Err(FrameError::Empty); + } + let len = u64::try_from(body.len()).unwrap_or(u64::MAX); + if len > u64::from(MAX_FRAME_BYTES) { + return Err(FrameError::TooLarge { length: len }); + } + #[allow(clippy::cast_possible_truncation)] + let len = len as u32; + writer.write_all(&len.to_be_bytes()).await?; + writer.write_all(body).await?; + writer.flush().await?; + Ok(()) +} + +/// Reads one length-delimited frame. +/// +/// Returns `Ok(None)` when the peer closes the stream cleanly at a frame +/// boundary, so a serve loop can distinguish a graceful disconnect from a fault. +/// +/// # Errors +/// +/// Returns [`FrameError::Empty`] when the declared length is zero, +/// [`FrameError::TooLarge`] when it exceeds [`MAX_FRAME_BYTES`] (the reader +/// refuses to allocate it), or [`FrameError::Io`] when the transport fails or a +/// frame is truncated mid-stream. Both typed rejections are answerable: an empty +/// frame leaves the stream at a frame boundary, and an over-long one leaves the +/// undrained body behind, so a serve loop replies and then closes. +pub async fn read_frame(reader: &mut R) -> Result>, FrameError> +where + R: AsyncRead + Unpin, +{ + let mut len_buf = [0u8; 4]; + let mut filled = 0; + while filled < len_buf.len() { + let read = reader.read(&mut len_buf[filled..]).await?; + if read == 0 { + if filled == 0 { + return Ok(None); + } + return Err(FrameError::Io(io::Error::new( + ErrorKind::UnexpectedEof, + "truncated frame length prefix", + ))); + } + filled += read; + } + let len = u32::from_be_bytes(len_buf); + if len == 0 { + return Err(FrameError::Empty); + } + if len > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge { + length: u64::from(len), + }); + } + let mut body = vec![0u8; len as usize]; + reader.read_exact(&mut body).await?; + Ok(Some(body)) +} + +#[cfg(test)] +mod tests { + use super::{FrameError, MAX_FRAME_BYTES, read_frame, write_frame}; + + #[tokio::test] + async fn frames_round_trip() { + let mut buffer = Vec::new(); + write_frame(&mut buffer, b"hello broker") + .await + .expect("write should succeed"); + let mut cursor = std::io::Cursor::new(buffer); + let body = read_frame(&mut cursor) + .await + .expect("read should succeed") + .expect("a frame should be present"); + assert_eq!(body, b"hello broker"); + } + + #[tokio::test] + async fn clean_eof_at_boundary_returns_none() { + let mut cursor = std::io::Cursor::new(Vec::new()); + assert!( + read_frame(&mut cursor) + .await + .expect("clean eof should not error") + .is_none() + ); + } + + #[tokio::test] + async fn oversized_declared_length_is_refused_without_allocating() { + let mut framed = (MAX_FRAME_BYTES + 1).to_be_bytes().to_vec(); + framed.extend_from_slice(b"body"); + let mut cursor = std::io::Cursor::new(framed); + let error = read_frame(&mut cursor) + .await + .expect_err("oversized frame should be refused"); + assert!(matches!(error, FrameError::TooLarge { .. })); + } + + #[tokio::test] + async fn an_empty_body_is_refused_by_the_writer_and_the_reader() { + let mut buffer = Vec::new(); + let error = write_frame(&mut buffer, b"") + .await + .expect_err("an empty body must not reach the wire"); + assert!(matches!(error, FrameError::Empty)); + assert!(buffer.is_empty(), "a refused frame writes no bytes"); + + let mut cursor = std::io::Cursor::new(0u32.to_be_bytes().to_vec()); + let error = read_frame(&mut cursor) + .await + .expect_err("an empty frame must be refused"); + assert!(matches!(error, FrameError::Empty)); + } + + #[tokio::test] + async fn truncated_length_prefix_is_an_error() { + let mut cursor = std::io::Cursor::new(vec![0u8, 0u8]); + let error = read_frame(&mut cursor) + .await + .expect_err("truncated prefix should error"); + assert!(matches!(error, FrameError::Io(_))); + } +} diff --git a/crates/ipc/src/lib.rs b/crates/ipc/src/lib.rs new file mode 100644 index 0000000..aa47533 --- /dev/null +++ b/crates/ipc/src/lib.rs @@ -0,0 +1,23 @@ +//! Local IPC transport, framing, and peer authentication for the broker. +//! +//! The broker's northbound boundary is a local, authenticated request/response +//! channel. This crate keeps the platform-specific transport and the peer ACL +//! behind traits so the Linux Unix-domain-socket path used today and a future +//! Windows named-pipe path share one contract. The typed wire messages live in +//! [`fpsmaxxing_contracts::ipc`]; this crate only frames and moves them. + +pub mod auth; +pub mod frame; +pub mod transport; + +#[cfg(unix)] +pub mod client; + +pub use auth::{AuthError, PeerAuthorizer, PeerIdentity, SameUidAuthorizer}; +pub use frame::{FrameError, MAX_FRAME_BYTES, read_frame, write_frame}; +pub use transport::{Accepted, LocalTransport}; + +#[cfg(unix)] +pub use client::{BrokerClient, ClientError}; +#[cfg(unix)] +pub use transport::UnixSocketTransport; diff --git a/crates/ipc/src/transport.rs b/crates/ipc/src/transport.rs new file mode 100644 index 0000000..0c898b1 --- /dev/null +++ b/crates/ipc/src/transport.rs @@ -0,0 +1,301 @@ +//! The local IPC transport seam. +//! +//! [`LocalTransport`] abstracts how the broker accepts authenticated local +//! connections and resolves each peer's identity. [`UnixSocketTransport`] is the +//! Linux implementation over a Unix domain socket; a Windows named-pipe +//! implementation would satisfy the same trait later, so the broker's serve +//! loop never names a concrete transport. + +use std::future::Future; +use std::io; + +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::auth::PeerIdentity; + +/// An accepted local connection together with its resolved peer identity. +pub struct Accepted { + /// The bidirectional byte stream for this connection. + pub stream: S, + /// The peer identity the transport resolved at accept time. + pub peer: PeerIdentity, +} + +/// A bound local IPC endpoint that accepts authenticated peer connections. +/// +/// The associated [`LocalTransport::Stream`] carries the framed request and +/// response bytes, and [`LocalTransport::accept`] resolves the peer identity so +/// the broker can authorize it before reading any request. +pub trait LocalTransport { + /// The accepted connection's byte stream. + type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static; + + /// Accepts the next inbound connection and resolves its peer identity. + /// + /// # Errors + /// + /// Returns an error when the underlying endpoint cannot accept a connection + /// or the peer identity cannot be resolved. + fn accept(&self) -> impl Future>> + Send; +} + +#[cfg(unix)] +pub use unix::UnixSocketTransport; + +#[cfg(unix)] +mod unix { + use std::io; + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + use std::path::{Path, PathBuf}; + + use tokio::net::{UnixListener, UnixStream}; + + use super::{Accepted, LocalTransport}; + use crate::auth::PeerIdentity; + + /// Which socket file a bound transport is responsible for unlinking. + /// + /// A path is not an identity: another instance may have replaced the entry + /// since the bind, and unlinking that one would leave a live broker no + /// client can reach. The device and inode pin the entry this transport + /// actually created. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct SocketIdentity { + dev: u64, + ino: u64, + } + + impl SocketIdentity { + /// Reads the identity of the socket entry currently at `path`. + fn of(path: &Path) -> io::Result { + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| named(path, "inspected", &error))?; + if !metadata.file_type().is_socket() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} is no longer a socket", path.display()), + )); + } + Ok(Self { + dev: metadata.dev(), + ino: metadata.ino(), + }) + } + } + + /// A Unix domain socket implementation of [`LocalTransport`]. + /// + /// The socket file is removed when the transport is dropped so a restarted + /// broker can rebind cleanly. Only the entry this transport bound is + /// unlinked - matched by device and inode, not by name - so neither an + /// unrelated entry nor a successor instance's live endpoint is destroyed. + /// + /// Confidentiality is not this type's job. The bind does not touch the + /// socket file's mode, because the only ways to pin one are a by-name + /// `chmod` - which follows symlinks, so a peer able to write the parent + /// directory could redirect it onto an unrelated file - and a window in the + /// process-global umask, which silently strips bits from every other + /// thread's file and directory creation for its duration. `fchmod` is not an + /// alternative: it reaches the sockfs inode, not the bound path. Two checks + /// carry that weight instead: the caller places the socket in a directory + /// only its owner may traverse (see the broker's private directory), and the + /// [`crate::PeerAuthorizer`] gate authenticates every peer from + /// `SO_PEERCRED` before a request is read. + #[derive(Debug)] + pub struct UnixSocketTransport { + listener: UnixListener, + path: PathBuf, + identity: SocketIdentity, + } + + impl UnixSocketTransport { + /// Binds a fresh socket at `path`, replacing a stale socket file. + /// + /// A socket file outlives the process that bound it, so a socket + /// already at `path` is unlinked and rebound - it is the file a crashed + /// broker leaves behind. Nothing here has to tell that file apart from + /// a live endpoint, because this bind is not what keeps a second broker + /// out: the caller takes an exclusive lock in its own private directory + /// before it reaches this code (see the broker's single-instance lock), + /// so a second instance has already been refused by the time this runs. + /// A probe would be the weaker guard anyway - stat, probe, unlink, and + /// bind are four steps, and two brokers can both find the same path + /// stale. + /// + /// A regular file, a directory, or a symlink already at `path` fails + /// closed with [`io::ErrorKind::AlreadyExists`] rather than being + /// deleted, so a mistyped `--socket` on a privileged broker cannot + /// destroy an unrelated file. + /// + /// Every failure names `path`. The broker has three configurable paths, + /// and `std`'s io errors carry none of them. + /// + /// # Errors + /// + /// Returns an error if `path` holds a non-socket entry, cannot be + /// inspected, cannot be removed, or the socket cannot be bound. + pub fn bind(path: impl AsRef) -> io::Result { + let path = path.as_ref().to_path_buf(); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_socket() => { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(named(&path, "removed", &error)), + } + } + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} exists and is not a socket", path.display()), + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(named(&path, "inspected", &error)), + } + let listener = + UnixListener::bind(&path).map_err(|error| named(&path, "bound", &error))?; + let identity = SocketIdentity::of(&path).inspect_err(|_| { + let _ = std::fs::remove_file(&path); + })?; + Ok(Self { + listener, + path, + identity, + }) + } + + /// Returns the bound socket path. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + } + + /// Names `path` in `error`, which `std`'s io errors never carry themselves. + /// + /// The socket is one of three paths the broker can be pointed at, so a bare + /// `Permission denied` leaves an operator no way to tell which of them the + /// start-up failed on. + fn named(path: &Path, action: &str, error: &io::Error) -> io::Error { + io::Error::new( + error.kind(), + format!("{} cannot be {action}: {error}", path.display()), + ) + } + + impl LocalTransport for UnixSocketTransport { + type Stream = UnixStream; + + async fn accept(&self) -> io::Result> { + let (stream, _addr) = self.listener.accept().await?; + let credentials = stream.peer_cred()?; + let peer = PeerIdentity { + uid: credentials.uid(), + pid: credentials.pid(), + }; + Ok(Accepted { stream, peer }) + } + } + + impl Drop for UnixSocketTransport { + fn drop(&mut self) { + if SocketIdentity::of(&self.path).is_ok_and(|identity| identity == self.identity) { + let _ = std::fs::remove_file(&self.path); + } + } + } + + #[cfg(test)] + mod tests { + use std::io; + + use tokio::net::UnixStream; + + use super::{LocalTransport, UnixSocketTransport}; + + #[tokio::test] + async fn a_bound_socket_accepts_a_local_peer() { + let dir = tempfile::tempdir().expect("temporary directory should exist"); + let path = dir.path().join("broker.sock"); + let transport = UnixSocketTransport::bind(&path).expect("socket should bind"); + assert_eq!(transport.path(), path); + + let connect = tokio::spawn(async move { UnixStream::connect(&path).await }); + let accepted = transport + .accept() + .await + .expect("the peer should be accepted"); + connect + .await + .expect("the connecting task should finish") + .expect("the peer should connect"); + assert_eq!( + accepted.peer.uid, + rustix::process::geteuid().as_raw(), + "a local peer's credentials should be resolved at accept time" + ); + } + + #[tokio::test] + async fn bind_replaces_a_stale_socket() { + let dir = tempfile::tempdir().expect("temporary directory should exist"); + let path = dir.path().join("broker.sock"); + // std's listener leaves the socket file behind, like a crashed broker. + drop(std::os::unix::net::UnixListener::bind(&path).expect("stale socket should bind")); + UnixSocketTransport::bind(&path).expect("a stale socket should be replaced"); + } + + #[tokio::test] + async fn a_bind_failure_names_the_socket_path() { + let dir = tempfile::tempdir().expect("temporary directory should exist"); + let path = dir.path().join("absent").join("broker.sock"); + let error = UnixSocketTransport::bind(&path) + .expect_err("no socket can be bound under a directory that is not there"); + assert!( + error.to_string().contains(&path.display().to_string()), + "the broker takes three configurable paths, so a failure must say which: {error}" + ); + } + + #[tokio::test] + async fn drop_spares_a_socket_this_transport_did_not_bind() { + let dir = tempfile::tempdir().expect("temporary directory should exist"); + let path = dir.path().join("broker.sock"); + let transport = UnixSocketTransport::bind(&path).expect("socket should bind"); + + // A successor takes the path over, as a manual rebind would. + std::fs::remove_file(&path).expect("the bound socket should be removed"); + let successor = + std::os::unix::net::UnixListener::bind(&path).expect("the successor should bind"); + + drop(transport); + assert!( + std::fs::symlink_metadata(&path).is_ok(), + "an older instance must not unlink a live successor's endpoint" + ); + drop(successor); + } + + #[tokio::test] + async fn bind_refuses_to_delete_a_non_socket_path() { + let dir = tempfile::tempdir().expect("temporary directory should exist"); + for name in ["regular-file", "directory", "symlink"] { + let path = dir.path().join(name); + match name { + "directory" => std::fs::create_dir(&path).expect("directory should create"), + "symlink" => std::os::unix::fs::symlink("/dev/null", &path) + .expect("symlink should create"), + _ => std::fs::write(&path, b"not a socket").expect("file should write"), + } + let error = UnixSocketTransport::bind(&path) + .expect_err("a non-socket path must not be replaced"); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists, "{name}"); + assert!( + std::fs::symlink_metadata(&path).is_ok(), + "{name} must survive the refused bind" + ); + } + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2576fb3..30bc98d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,7 +3,8 @@ FPSMaxxing separates reasoning, policy, privilege, hardware integration, measurement, and recovery. The current read-only alpha implements the gateway, an in-process control-plane seam (`crates/control-plane`) holding the capability registry, bounded policy, broker lifecycle, and durable SQLite experiment journal, and a deterministic experiment runner (`apps/experiment-runner`) that gates measured trials through an immutable evaluator, all wired to a single mock provider. -The independent watchdog restore path is implemented against that journal on the Linux-safe mock path (`apps/watchdog`); the privileged broker remains a scaffold. +The privileged broker exposes that control plane over an authenticated local IPC boundary on the Linux-safe socket path (`apps/broker`, `crates/ipc`), and the independent watchdog restore path is implemented against the journal on the Linux-safe mock path (`apps/watchdog`). +The gateway does not route through the broker yet - it still opens an in-process control plane of its own - so the two paths run side by side over separate journals. ## Processes @@ -15,6 +16,58 @@ The unprivileged MCP server translates agent tool calls into typed capability re The privileged Windows service accepts authenticated local requests from the gateway, revalidates policy, journals the transaction, and supervises provider sidecars. It exposes no raw command or memory primitive. +`apps/broker` implements this on the Linux-safe path. +It owns the control plane on a dedicated worker thread and serves exactly two operations - capability discovery and a bounded provider lifecycle - to authenticated local peers over the transport seam in `crates/ipc` (a Unix domain socket now, a Windows named pipe later). +Three fail-closed checks guard the boundary: peer authentication before any request is read (a Linux `SO_PEERCRED` same-uid ACL, shaped so a Windows SID ACL can satisfy the same trait), a capability-catalog check that rejects raw shell, arbitrary Registry paths, and out-of-catalog ids, and single-owner-per-knob enforcement that refuses a second concurrent owner of a setting. +Typed request and response messages live in `crates/contracts` with `schemas/broker-request.schema.json` and `schemas/broker-response.schema.json` kept in sync; a malformed frame is rejected with a typed error without taking the broker down. +A frame is a big-endian `u32` body length followed by that many bytes of JSON, bounded at one mebibyte so a hostile peer cannot force an unbounded allocation; the framing is transport-agnostic, so a named-pipe transport would reuse it unchanged. +At most 32 connections are served at once, and one that stalls for 30 seconds in either direction is closed, so a peer cannot pin a task, a descriptor, or a frame buffer; a peer refused by the ACL gets a far shorter deadline to take its rejection frame, because it has not authenticated and must not be able to hold a connection slot for the full idle budget. +A response is a tagged union of its outcome and that outcome's payload, so a tag without its payload never crosses the boundary and no consumer has to unwrap one. +Any text a client chose is truncated before it is quoted back in an error message, so a rejected request is always answered with a typed error rather than a response too large for the frame limit. + +The same-uid ACL is the deliberate interim policy for the current single-user, Linux-safe mock path, where the broker and its only client run as the same desktop user. +It is not the shipping policy for the privilege split described above: once the broker runs as a service account, an unprivileged gateway would be refused by construction. +The real privilege-split ACL arrives with the Windows named-pipe SID implementation of the `PeerAuthorizer` trait, tracked separately; because every caller reaches the ACL through that trait, no call site changes when it lands. +The trusted uid is read from the broker's own effective credentials rather than from the socket file's owner, so replacing the socket path cannot redirect the ACL. +In this interim every authorized peer is the same identity, so journaling the verified peer uid and pid against each lifecycle, and authenticating the client-supplied owner label against them, only carry their weight once split-privilege ACLs arrive; both are tracked as follow-up work `fpsm-broker-splitacl`. +A peer refused by the ACL is told only that it is not authorized: the refusal names neither uid, because the caller it reaches has not authenticated, and the uid pair is traced locally instead. +The broker always establishes one owner-only directory of its own under `$XDG_RUNTIME_DIR` (or `/run`), and unless an explicit path is given it keeps its socket and its journal there rather than beside the inherited working directory, so no other user can race the socket path or read the audit journal. +`XDG_RUNTIME_DIR` is inherited from whoever started the broker, so it is honored only when it is absolute and only for an unprivileged broker; a broker running as root always uses `/run`. +That directory and every directory above it must be owned by the broker or root and unwritable by anyone else, or the broker fails closed: a writable parent is what would let another user swap a vetted directory for a symlink between the check and the bind. +Every resolved path is held to that same bar, whether a flag, an environment variable, or the default named it: it must be absolute, and the whole chain above it is vetted, so an inherited variable cannot buy a caller the placement `XDG_RUNTIME_DIR` is filtered to deny them. +The directory that directly holds the socket or the journal is held higher still: no other user may reach it at all, sticky or not. +Sticky only stops another user renaming or removing the broker's entry, not creating that entry first in a shared directory like `/tmp` and keeping ownership of the file the broker then writes every `apply-intent` record into. +Traversal alone is enough to reach the socket, whose own mode cannot be pinned, so a directory like `/run` that merely lets everyone through is refused as well. +Above that directory, neither is the threat and swapping a directory is, which is exactly what sticky prevents, so the not-writable-by-others bar with its sticky exemption stands where it is sound. +The socket file's own mode is not pinned, because the only ways to do so are a symlink-following `chmod` or a window in the process-global umask; confidentiality rests on the owner-only directory and the `SO_PEERCRED` gate instead. +What keeps single-owner-per-knob true across processes is an exclusive advisory lock rather than the bind: the broker locks a fixed `0600` file in its private directory before the journal is opened and before the socket is bound, so a second broker refuses to start instead of driving the same knobs through an ownership ledger of its own, and it refuses before it has touched either. +The bind cannot do that job, because it cannot be made atomic: stat, probe, unlink, and bind are four steps, and two brokers that both found the same socket file stale would leave the first serving an unlinked inode - still answering its connected clients - while the second owned the path, with nothing logged anywhere. +The kernel releases the lock with the last descriptor for it, so a broker that crashed leaves its successor nothing to clean up, and the socket file it left behind is simply rebound. +The transport unlinks on drop only the entry it bound itself - matched by device and inode - so an exiting instance can never strand a live successor. +The lock is placed by uid rather than derived from the socket or the journal, and the private directory holding it is established even when both of those are overridden, so neither `--socket` nor `--journal` nor the environment variables behind them buy a second instance. +Keying it to a path would not hold: only the path left unset falls back to the default, so two brokers given different journals would take two locks and then share one socket. +What they contend for is the machine's knobs, not a file, and a supervisor-level single-instance unit can layer on top later. +For the privileged deployment the guarantee is unconditional: a root broker ignores `XDG_RUNTIME_DIR`, so its private directory is always the fixed `/run/fpsmaxxing` and there is exactly one lock file to contend for. +An unprivileged broker keeps that directory wherever the inherited `XDG_RUNTIME_DIR` puts it, so the lock moves with the variable: one user starting two brokers under two different values for it takes two locks, and both start. +Single-instance is therefore best-effort off the privileged path, which is the dev and test path it serves. +That concession gives up no boundary. +A same-uid caller who can vary `XDG_RUNTIME_DIR` is already inside the trust domain the same-uid ACL admits, and could drive the same knobs through the running broker without starting a second one; the shipping broker runs as root, where the variable is refused and the guarantee is airtight. +The audit journal is different: it holds every `apply-intent` record and outlives the process, so it is created mode `0600` before it is opened, which also restricts the rollback journal and write-ahead log `SQLite` creates beside it. +The broker reads only broker-specific overrides (`FPSMAXXING_BROKER_SOCKET`, `FPSMAXXING_BROKER_JOURNAL_PATH`) and never the `FPSMAXXING_JOURNAL_PATH` the unprivileged gateway and CLI use, so an operator who exported that variable cannot move the privileged audit journal. +Both are read as raw `OsString` values, so a path that is not UTF-8 relocates the socket or journal as configured rather than being silently dropped back to the default. +The command line is read as `OsString` too, but it is matched against flag names rather than used verbatim, so an argument that is not UTF-8 is a typed fatal parse error naming it - not the mid-iteration panic `env::args` would raise, and not a silent fallback either. + +The broker fails fast rather than degrading. +Losing the control-plane worker thread - including to a panic mid-lifecycle, which may leave provider state applied and un-rolled-back - stops the serve loop and exits the process non-zero instead of answering later requests with an internal fault. +Deploy it under a supervisor that restarts on failure, and let the watchdog own recovery of any state left behind. + +#### Deferred broker work + +- Gateway wiring: nothing shipped connects to the broker yet, because the gateway still opens its own in-process control plane, so `BrokerClient` in `crates/ipc` is exercised only by the broker's end-to-end tests. Promoting the gateway onto that client also has to settle what becomes of the gateway's own journal once the privileged audit journal is the record of every apply. +- `fpsm-broker-splitacl`: the real split-privilege ACL, replacing the interim same-uid policy described above, plus journaling the verified peer uid and pid against each lifecycle and authenticating the client-supplied owner label against them. +- Client reconnect on `Closed`: the broker closes a connection idle for 30 seconds, and `BrokerClient` holds one long-lived stream with no keepalive or reconnect, so a caller whose requests are further apart than that gets `ClientError::Closed`. Whether the client reconnects transparently, the server distinguishes a healthy idle peer from a stalled one, or callers connect per request is undecided. +- `broker-dispatch-unbounded`: neither `BrokerHandle::dispatch` nor `BrokerClient::request` bounds the wait on the single control-plane worker, so a provider that blocks inside `apply` or `verify` stalls every peer rather than failing one. Deferred to the pass that adds graceful shutdown, which has to answer the same question: what a request already in flight is owed when the broker stops serving. + ### Watchdog The independent watchdog owns lease deadlines and emergency rollback. It must restore state without the gateway, agent, or experiment runner. On lease expiry or a safety violation it reverts to the pre-state snapshot through the privileged broker. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e388750..c9091b2 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -32,7 +32,8 @@ The LLM proposes declarative experiments. It never receives administrator creden - Rust 2024 edition on the pinned stable toolchain - Cargo workspace and `xtask` orchestration -- Tokio for async services and Windows named pipes +- Tokio for async services, Unix domain sockets, and Windows named pipes +- Rustix for the POSIX calls the privileged broker needs, because unsafe Rust is forbidden workspace-wide - Interim alpha northbound transport: a minimal, hand-rolled stdio JSON-RPC MCP subset for the Linux-safe mock path - Official Rust MCP SDK remains the target northbound interface once the transport is promoted beyond the alpha seam - Microsoft Rust crates for focused Windows Registry and service APIs diff --git a/docs/adr/0002-alpha-experiment-journal.md b/docs/adr/0002-alpha-experiment-journal.md index 3ee0024..4f01e73 100644 --- a/docs/adr/0002-alpha-experiment-journal.md +++ b/docs/adr/0002-alpha-experiment-journal.md @@ -44,5 +44,5 @@ Attributing a flagged archive to the constant that moved needs the policy envelo - `doctor` reports experiments with an `apply-intent` record but no terminal outcome as dangling; it does not yet inspect `experiment_trials`. - Correlation IDs are allocated inside an immediate transaction with a busy timeout, so concurrent gateways sharing one journal file cannot mint duplicate IDs. -- `LifecycleResult` stays in `crates/control-plane` as a deliberate alpha seam even though the gateway serializes it into MCP tool-result text; it moves to `crates/contracts` with a pinned JSON schema at broker promotion. -- Broker promotion revisits journaling as part of the privileged transaction log design. +- `LifecycleResult` stays in `crates/control-plane` for the gateway, which still serializes it into MCP tool-result text; the privileged broker mirrors the same fields onto the wire as `LifecycleReport` in `crates/contracts`, pinned by `schemas/broker-response.schema.json`, so the unprivileged side of that boundary never links against the control plane. +- The privileged broker owns a control plane and an audit journal of its own, in this same single-table format, at `FPSMAXXING_BROKER_JOURNAL_PATH` or inside its private directory; full two-phase intent/result journaling for every stage and a dedicated experiments table remain outstanding for the privileged transaction log design. diff --git a/docs/threat-model/README.md b/docs/threat-model/README.md index 1d737cb..3d766de 100644 --- a/docs/threat-model/README.md +++ b/docs/threat-model/README.md @@ -34,3 +34,13 @@ - Append-only trial records re-evaluated and re-gated against current policy on replay - A signed or hash-chained journal before recorded measurement content itself is trusted - Hardware-in-the-loop fault tests before enabling writes + +## Interim state of the local IPC boundary + +The Linux broker in `apps/broker` implements the authenticated local IPC boundary; these parts of the required mitigations are not there yet. + +- The peer ACL is an interim same-uid check (`SO_PEERCRED`). It refuses every other local user, but it does not separate an unprivileged gateway from a privileged broker: once the broker runs as a service account, the gateway it is meant to serve would be refused. The split-privilege ACL arrives with the Windows named-pipe SID authorizer, tracked as `fpsm-broker-splitacl`. +- The verified peer uid and pid are checked before any request is read and then dropped: they are not journaled against a lifecycle, and the client-supplied owner label is not authenticated against them. Both wait on the same follow-up, because under a same-uid ACL every authorized peer is one identity. +- Policy is enforced in the broker for the requests it serves, but the gateway still runs its own in-process control plane rather than calling the broker, so no shipped path crosses this boundary yet. + +`docs/ARCHITECTURE.md` records the filesystem, single-instance, and fail-fast reasoning behind the boundary as built. diff --git a/schemas/broker-request.schema.json b/schemas/broker-request.schema.json new file mode 100644 index 0000000..2f8a714 --- /dev/null +++ b/schemas/broker-request.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/undeemed/fpsmaxxing/schemas/broker-request.schema.json", + "title": "FPSMaxxing broker request", + "type": "object", + "additionalProperties": false, + "required": ["op"], + "properties": { + "op": { "enum": ["discover", "run-lifecycle"] }, + "owner": { "type": "string", "minLength": 1 }, + "change": { "$ref": "#/$defs/ChangeRequest" } + }, + "$defs": { + "ChangeRequest": { + "type": "object", + "additionalProperties": false, + "required": ["capability_id", "parameters", "lease_seconds"], + "properties": { + "capability_id": { "type": "string", "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" }, + "parameters": { "type": "object" }, + "lease_seconds": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/schemas/broker-response.schema.json b/schemas/broker-response.schema.json new file mode 100644 index 0000000..7b4c584 --- /dev/null +++ b/schemas/broker-response.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/undeemed/fpsmaxxing/schemas/broker-response.schema.json", + "title": "FPSMaxxing broker response", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "capabilities"], + "properties": { + "outcome": { "const": "capabilities" }, + "capabilities": { "$ref": "sidecar.schema.json" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "lifecycle"], + "properties": { + "outcome": { "const": "lifecycle" }, + "lifecycle": { "$ref": "#/$defs/LifecycleReport" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "error"], + "properties": { + "outcome": { "const": "error" }, + "error": { "$ref": "#/$defs/BrokerErrorBody" } + } + } + ], + "$defs": { + "LifecycleReport": { + "type": "object", + "additionalProperties": false, + "required": ["provider_id", "preview", "verified", "rolled_back"], + "properties": { + "provider_id": { "type": "string" }, + "preview": { "type": "string" }, + "verified": { "type": "boolean" }, + "rolled_back": { "type": "boolean" } + } + }, + "BrokerErrorBody": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "message"], + "properties": { + "kind": { + "enum": [ + "unauthenticated", + "malformed", + "unknown-capability", + "policy-denied", + "owner-conflict", + "lifecycle-failed", + "internal" + ] + }, + "message": { "type": "string" } + } + } + } +}