feat(deploy): self-host recipe, the enforced isolation flag, and the tunnel's door (P9a)

The books can live on a machine of the owner's own (plan 3.8, 8.2 P9a,
D-51). Three things make that honest instead of nominal:

- `--isolation same_user|enforced` sets `server::Isolation`, carried
  through `Served` and `BooksServer` to `books.start`. Locally the books
  stay `same_user (advisory)`; under the self-host unit the server is told
  `enforced` and reports `service_user (enforced)`. The server never
  guesses, and the literal it used to report is gone.
- `--http [ADDR]` binds the same MCP router on loopback (default
  127.0.0.1:7411) beside the Unix sockets, before the ready line, for the
  TLS proxy or tunnel in front of the server. It is the same bearer door
  and the same graceful close; a failed bind is reported and is not fatal
  (3.9). `serve` carries both flags through the host, so the system unit
  can ask for them once.

`deploy/wanderbooks-server.service` is the unit: a dedicated account, a
0700 data root, `ProtectSystem=strict`, no seccomp or Landlock (D-32), and
loopback-only networking. `deploy/README.md` is the recipe: install, setup,
move the books by `books adopt` with its head check (D-31: a copied book
is the transport), mint a bearer token, put Caddy or Tailscale Funnel in
front of the loopback door, and point Claude Code and Codex at it.

The two-account CI job (`.github/workflows/two-user.yml`) makes the
deployment and runs `crates/ledger-cli/tests/two_user.rs` as another
account: `agent_uid_cannot_read_tenant` proves the tenant root and its
journal are refused, then reads, writes, uploads, files, exports and
verifies over HTTP with the bearer (in a second book, so the moved books'
head is not advanced); `self_host_adopt_same_head` compares the moved
books' head and event count with the source clone's.
`isolation_label_is_enforced_under_the_unit` runs everywhere, including
the unit-file read that keeps the recipe from drifting.

Accounting reason for the shape: nothing here changes an entry, a report
or a figure. It changes who can reach them: the move is lossless and
verifiable at the head, and the boundary is the operating system's, not a
promise in our text. No journal code, kernel function, Kani harness or
Lean step is touched, so `just prove` is not required.

No go-ahead needed: no new journal feature or event; the live books are
unaffected.
This commit is contained in:
swaits committed 2026-10-27 09:35:36 -06:00
1 parent cc4f104f06
commit 1441dabcca
18 files changed
+1269 -27

No files matched your search

+464
View File
@@ -0,0 +1,464 @@
//! The two-account self-host deployment of the books (plan 8.2 P9a,
//! D-51): the server runs as one account on the owner's own machine and
//! an agent runs as another, reaching the books over HTTP with a static
//! bearer token.
//!
//! ```text
//! GOAL: two things the plan promises are checked, not assumed: an agent
//! with its own account cannot read the data root, and it reaches
//! every tool through the server's HTTP door all the same; and the
//! books moved to that machine verify to the head they had.
//! SOURCE: plan 8.2 P9a (Build items 1 and 2; Done when 1), 2 I-1
//! (isolation: advisory locally, enforced on a machine of the
//! owner's), 3.3 (the static bearer), 3.8 (local vs self-host),
//! 3.9 (the server's sockets, and `--http`), D-51 (P9 split),
//! D-31 (a backup clone is the move's transport).
//! INVARIANTS:
//! 1. The two-account tests run only against a deployment the caller
//! made (the job in `.github/workflows/two-user.yml`, or the recipe
//! in `deploy/README.md`); with no deployment they say they skipped,
//! so a laptop's `cargo test` stays green. `WB_TWO_USER=1` turns
//! them on, and then every other setting is required.
//! 2. The test never reads the books' files in the tenant: it proves it
//! cannot (INVARIANT 3), then asks the server for everything.
//! 3. The tenant root is unreadable to this account: `opendir` and a
//! journal `open` are refused.
//! 4. The books say `isolation: "service_user (enforced)"`: the server
//! was told `--isolation enforced` by the unit or the job, never
//! guessed.
//! 5. A read, a write, an upload and an export go over the tunnel's
//! HTTP door with the bearer; the local Unix socket is not used.
//! They go to a second book (`WB_WRITE_BOOK`), so the moved books'
//! head is never advanced by a check.
//! 6. The moved books' head (seq and hash) and every event are the
//! source's: the move loses nothing.
//! ```
use common::{BOOK, Client, Home, Wire};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
mod common;
// ---------------------------------------------------------------- harness
/// The deployment the caller made, from the environment
/// (INVARIANTS 1 to 3). `None` when `WB_TWO_USER` is unset.
struct Deployment {
/// The data root this account must not be able to read.
root: PathBuf,
/// `http://host:port`, the tunnel's door's base.
server: String,
book: String,
token: String,
/// A second book the write checks go to, so the moved books' head is
/// never advanced by a check (defaults to `book`).
write_book: String,
write_token: String,
/// The backup clone the books were moved from, readable here.
source: Option<PathBuf>,
}
fn deployment() -> Option<Deployment> {
std::env::var_os("WB_TWO_USER")?;
let need = |name: &str| -> String {
std::env::var(name).unwrap_or_else(|_| panic!("WB_TWO_USER is set, so {name} must be too"))
};
let book = need("WB_BOOK");
let token = need("WB_TOKEN");
let write_book = std::env::var("WB_WRITE_BOOK").unwrap_or_else(|_| book.clone());
let write_token = std::env::var("WB_WRITE_TOKEN").unwrap_or_else(|_| token.clone());
Some(Deployment {
root: PathBuf::from(need("WB_TENANT_ROOT")),
server: need("WB_SERVER_URL"),
book,
token,
write_book,
write_token,
source: std::env::var_os("WB_SOURCE_DIR").map(PathBuf::from),
})
}
impl Deployment {
/// The MCP URL of `book`.
fn mcp_url(&self, book: &str) -> String {
format!("{}/b/{}/mcp", self.server, book)
}
/// A URL under `/b/{book}/`.
fn book_url(&self, book: &str, rest: &str) -> String {
format!("{}/b/{}/{}", self.server, book, rest)
}
}
/// A client of the books over the tunnel's HTTP door (INVARIANT 5), with
/// the static bearer token.
async fn connect_over_http(d: &Deployment, book: &str, token: &str) -> Client {
use rmcp::ServiceExt;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
let config = StreamableHttpClientTransportConfig::with_uri(d.mcp_url(book))
.auth_header(token.to_string());
let http = reqwest::Client::new();
let transport = StreamableHttpClientTransport::with_client(http, config);
rmcp::model::ClientConfig::default()
.serve(transport)
.await
.unwrap_or_else(|e| panic!("the tunnel's door refused the session: {e}"))
}
/// GET `url` with the bearer: the status and the bytes.
async fn get_with_token(url: &str, token: &str) -> (u16, Vec<u8>) {
let r = reqwest::Client::new()
.get(url)
.bearer_auth(token)
.send()
.await
.unwrap();
let status = r.status().as_u16();
(status, r.bytes().await.unwrap_or_default().to_vec())
}
/// A running `wanderbooks server` on `home`'s root with `args` beside the
/// root, ended (by its own PID) when dropped. The harness's `Server` is
/// the same without extra flags; this test needs `--isolation` and
/// `--http`.
struct FlaggedServer {
child: Child,
}
impl FlaggedServer {
fn start(home: &Home, args: &[String]) -> Self {
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(home.dir.path().join("server.log"))
.unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("server")
.arg("--root")
.arg(home.root())
.args(args)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(log)
.spawn()
.unwrap();
let mut line = String::new();
std::io::BufRead::read_line(
&mut std::io::BufReader::new(child.stdout.as_mut().unwrap()),
&mut line,
)
.unwrap();
assert_eq!(line, "ready\n", "the server didn't say ready");
Self { child }
}
/// Close it as the host does (stdin ends) and wait for it to exit.
fn close(mut self) -> std::process::ExitStatus {
drop(self.child.stdin.take());
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = self.child.try_wait().unwrap() {
return status;
}
assert!(Instant::now() < deadline, "the server didn't close");
std::thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for FlaggedServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// A port nothing is listening on now, for the tunnel's door.
fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
/// `books.start` over a wire, as the tools' answer.
fn start_of(wire: &mut Wire) -> Value {
let r = wire.call("books.start", json!({}));
common::answer_of(&r).unwrap_or_else(|e| panic!("books.start: {e}"))
}
// ---------------------------------------------------------------- the tests
/// The server told `--isolation enforced` says so (`books.start`), over
/// its Unix socket and over the HTTP door, and a server not told says the
/// local truth: the server never guesses (plan 3.8, P9a).
#[test]
fn isolation_label_is_enforced_under_the_unit() {
// The local default first: no flag, the advisory truth (D-32).
let local = Home::new("isolation-local");
let server = FlaggedServer::start(&local, &[]);
local.create(BOOK, "USD");
{
let mut wire = Wire::new(&local, BOOK);
wire.initialize("self-host-check");
assert_eq!(start_of(&mut wire)["isolation"], "same_user (advisory)");
}
assert!(server.close().success());
// Told enforced: `books.start` says so, on both doors (INVARIANT 4).
let home = Home::new("isolation-enforced");
let port = free_port();
let server = FlaggedServer::start(
&home,
&[
"--isolation".into(),
"enforced".into(),
"--http".into(),
format!("127.0.0.1:{port}"),
],
);
home.create(BOOK, "USD");
let over_socket = {
let mut wire = Wire::new(&home, BOOK);
wire.initialize("self-host-check");
start_of(&mut wire)
};
assert_eq!(over_socket["isolation"], "service_user (enforced)");
// The HTTP door is the same service with the same bearer token.
let d = Deployment {
root: home.root(),
server: format!("http://127.0.0.1:{port}"),
book: BOOK.into(),
token: home.token(BOOK),
write_book: BOOK.into(),
write_token: home.token(BOOK),
source: None,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let over_http = rt.block_on(async {
let client = connect_over_http(&d, &d.book, &d.token).await;
let answer = common::call(&client, "books.start", json!({})).await;
let _ = client.cancel().await;
answer
});
let over_http = over_http.expect("books.start over HTTP");
assert_eq!(over_http["isolation"], "service_user (enforced)");
assert_eq!(over_http["book"]["name"], over_socket["book"]["name"]);
assert_eq!(over_http["head"], over_socket["head"]);
assert!(server.close().success());
// The unit's own line is what starts a server that way (P9a step 1):
// it passes the flag this test exercises, and the tunnel's door.
let unit = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../deploy/wanderbooks-server.service"
))
.expect("deploy/wanderbooks-server.service");
for line in [
"User=wanderbooks",
"--isolation enforced",
"--http 127.0.0.1:7411",
"IPAddressDeny=any",
"IPAddressAllow=localhost",
] {
assert!(unit.contains(line), "the unit lacks {line:?}:\n{unit}");
}
}
/// An agent with its own account cannot read the books' data root, and
/// every tool it needs still answers over the tunnel's HTTP door with the
/// bearer (INVARIANTS 2, 3, 4 and 5).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_uid_cannot_read_tenant() {
let Some(d) = deployment() else {
eprintln!(
"SKIPPED agent_uid_cannot_read_tenant: no two-account deployment (WB_TWO_USER unset; `.github/workflows/two-user.yml` or deploy/README.md makes one)"
);
return;
};
// INVARIANT 3: the root, and a journal inside it, are unreadable.
match std::fs::read_dir(&d.root) {
Err(e) => assert_eq!(
e.kind(),
std::io::ErrorKind::PermissionDenied,
"the tenant root is not refused for the wrong reason: {e}"
),
Ok(_) => panic!(
"this account can read the tenant root ({}): the deployment did not make it private",
d.root.display()
),
}
let journal = d.root.join("books").join(&d.book).join("journal.jsonl");
match std::fs::File::open(&journal) {
Err(e) => assert_eq!(
e.kind(),
std::io::ErrorKind::PermissionDenied,
"the journal is not refused for the wrong reason: {e}"
),
Ok(_) => panic!("this account can read the books' journal"),
}
// INVARIANT 4: the books were told enforced, and say so.
let books = connect_over_http(&d, &d.book, &d.token).await;
let start = common::call(&books, "books.start", json!({}))
.await
.expect("books.start over the tunnel");
assert_eq!(start["isolation"], "service_user (enforced)");
assert!(
!start.to_string().contains(&d.root.display().to_string()),
"the answer names the data root"
);
// The write checks go to a second book, so the moved books' head is
// never advanced by a check (`self_host_adopt_same_head` reads it).
let client = connect_over_http(&d, &d.write_book, &d.write_token).await;
let write_start = common::call(&client, "books.start", json!({}))
.await
.expect("books.start over the tunnel");
assert_eq!(write_start["isolation"], "service_user (enforced)");
// A read.
let chart = common::call(&client, "books.chart", json!({}))
.await
.expect("books.chart over the tunnel");
assert!(chart["accounts"].is_array(), "{chart}");
// A write: an account added over the tunnel, then read back.
let code = "1999";
let added = common::call(
&client,
"books.add_account",
json!({"code": code, "name": "Over the tunnel", "account_type": "asset"}),
)
.await
.expect("books.add_account over the tunnel");
assert!(added["account_id"].is_number(), "{added}");
let chart = common::call(&client, "books.chart", json!({}))
.await
.expect("books.chart over the tunnel");
assert!(
chart["accounts"]
.as_array()
.unwrap()
.iter()
.any(|a| a["code"] == code),
"the account written over the tunnel is not in the chart"
);
// A file in: an upload through the tunnel's own route.
let bytes = b"a receipt filed over the tunnel";
let begun = common::call(
&client,
"books.begin_upload",
common::upload_args(bytes, "tunnel.txt", "text/plain", "receipt"),
)
.await
.expect("books.begin_upload over the tunnel");
let upload_id = begun["upload_id"]
.as_str()
.unwrap_or_else(|| panic!("no upload begun: {begun}"));
let sent = reqwest::Client::new()
.put(d.book_url(&d.write_book, &format!("u/{upload_id}")))
.bearer_auth(&d.write_token)
.body(bytes.to_vec())
.send()
.await
.unwrap();
assert_eq!(sent.status().as_u16(), 200, "the upload route refused it");
// And file it, so it is a stored, named attachment the books verify.
let filed = common::call(
&client,
"books.file_document",
json!({"category": "other", "upload_id": upload_id,
"file_name": "tunnel.txt", "mime_type": "text/plain",
"description": "filed over the tunnel"}),
)
.await
.expect("books.file_document over the tunnel");
assert!(filed["attachment_id"].is_number(), "{filed}");
// A file out: an export handed over, fetched through the tunnel.
let exported = common::call(
&client,
"books.export",
json!({"format": "csv", "report": "trial_balance"}),
)
.await
.expect("books.export over the tunnel");
let file = &exported["file"];
let command = file["download"].as_str().expect("a download command");
let id = command
.split(' ')
.find(|w| w.starts_with("ex_"))
.unwrap_or_else(|| panic!("no export id in {command}"));
let (status, fetched) = get_with_token(
&d.book_url(&d.write_book, &format!("x/{id}")),
&d.write_token,
)
.await;
assert_eq!(status, 200, "the export route refused it");
assert_eq!(file["size_bytes"], json!(fetched.len()));
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&fetched))
);
// And the books verify, stored files included: the upload landed.
let verified = common::call(&client, "books.verify", json!({"blobs": true}))
.await
.expect("books.verify over the tunnel");
assert_eq!(verified["status"], "ok");
assert!(verified["blobs_verified"].as_u64().unwrap_or(0) >= 1);
let _ = client.cancel().await;
let _ = books.cancel().await;
}
/// The books moved to the server verify to the head the source clone had:
/// the same events, the same seq and hash (INVARIANTS 2 and 6).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn self_host_adopt_same_head() {
let Some(d) = deployment() else {
eprintln!(
"SKIPPED self_host_adopt_same_head: no two-account deployment (WB_TWO_USER unset)"
);
return;
};
let Some(source) = d.source.as_deref() else {
eprintln!("SKIPPED self_host_adopt_same_head: WB_SOURCE_DIR is not set");
return;
};
let (seq, hash, events) = source_head(source);
let client = connect_over_http(&d, &d.book, &d.token).await;
let start = common::call(&client, "books.start", json!({}))
.await
.expect("books.start over the tunnel");
assert_eq!(start["head"]["seq"], seq, "the moved books' seq");
assert_eq!(start["head"]["hash"], hash, "the moved books' head hash");
let verified = common::call(&client, "books.verify", json!({}))
.await
.expect("books.verify over the tunnel");
assert_eq!(verified["status"], "ok");
assert_eq!(verified["events"], events, "every event moved");
assert_eq!(verified["head"], hash, "the verified head");
let _ = client.cancel().await;
}
/// The source clone's head and event count, read as the move's transport
/// is read (plan D-31): the journal's last line's seq and canonical hash.
fn source_head(source: &Path) -> (u64, String, u64) {
let lines = ledger_store::journal::read_and_verify_journal(&source.join("journal.jsonl"))
.unwrap_or_else(|e| panic!("the source clone doesn't verify: {e}"));
let seq = lines.last().map_or(0, |l| l.seq);
let hash = lines.last().map_or_else(
|| ledger_store::journal::GENESIS_PREV.to_string(),
|l| ledger_store::journal::sha256_hex(l.to_canonical().as_bytes()),
);
(seq, hash, lines.len() as u64)
}
+32 -3
View File
@@ -231,16 +231,44 @@ pub fn serve(
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut args = args.into_iter();
let mut server_flags: Vec<String> = Vec::new();
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
// What this server is told (P9a): passed through the host.
"--isolation" => {
let level = args
.next()
.ok_or("--isolation needs same_user or enforced")?;
if !matches!(
level.as_str(),
"same_user" | "advisory" | "service_user" | "enforced"
) {
eprintln!("error: --isolation takes same_user or enforced, not {level:?}");
return Err(exit(2));
}
server_flags.push(arg);
server_flags.push(level);
}
"--http" => {
server_flags.push(arg);
if let Some(v) = args.peek()
&& !v.starts_with('-')
{
server_flags.push(args.next().unwrap_or_default());
}
}
"--help" | "-h" => {
eprintln!("usage: {prog}");
eprintln!("usage: {prog} [--root DIR] [--isolation LEVEL] [--http [ADDR]]");
eprintln!(" Opens the books and holds them open until Ctrl-C. They close 10 s");
eprintln!(" after nothing else (the app, an agent session) holds them.");
eprintln!(" --isolation same_user | enforced: how books.start says these books");
eprintln!(" are kept from agents (enforced: the owner's own machine, P9a).");
eprintln!(" --http [ADDR]: also serve the MCP door on 127.0.0.1:7411 (or ADDR),");
eprintln!(" for a TLS proxy or a tunnel in front of it.");
return Ok(());
}
other => {
@@ -259,7 +287,8 @@ pub fn serve(
common.knobs,
Kind::Serve,
None,
)?;
)?
.starting_with(server_flags);
// Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever
// serve is doing (opening, waiting to try again): none is missed.
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
+27 -2
View File
@@ -244,16 +244,32 @@ struct Args {
/// server finds the root itself, and no command line names it.
root: Option<PathBuf>,
knobs: Knobs,
/// Flags the server is started with (P9a): `--isolation`, `--http`.
server_flags: Vec<String>,
}
fn parse_args(prog: &str, args: impl IntoIterator<Item = String>) -> Result<Option<Args>, String> {
let mut root = None;
let mut knobs = Knobs::default();
let mut args = args.into_iter();
let mut server_flags: Vec<String> = Vec::new();
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
// Passed through to the server (P9a).
"--isolation" => {
server_flags.push(arg);
server_flags.push(args.next().ok_or("--isolation needs a level")?);
}
"--http" => {
server_flags.push(arg);
if let Some(v) = args.peek()
&& !v.starts_with('-')
{
server_flags.push(args.next().unwrap_or_default());
}
}
"--help" | "-h" => {
eprintln!("usage: {prog} [--root DIR]");
eprintln!(" Holds the books open while anything holds a lease on it; started by");
@@ -263,7 +279,11 @@ fn parse_args(prog: &str, args: impl IntoIterator<Item = String>) -> Result<Opti
other => return Err(format!("unknown argument: {other}")),
}
}
Ok(Some(Args { root, knobs }))
Ok(Some(Args {
root,
knobs,
server_flags,
}))
}
/// `wanderbooks host`.
@@ -395,6 +415,8 @@ struct Host {
root: Root,
run: PathBuf,
knobs: Knobs,
/// Flags the server is started with (P9a).
server_flags: Vec<String>,
leases: BTreeMap<u64, Held>,
next_lease: u64,
ready: bool,
@@ -417,6 +439,7 @@ impl Host {
root,
run,
knobs: args.knobs,
server_flags: args.server_flags,
leases: BTreeMap::new(),
next_lease: 1,
ready: false,
@@ -655,6 +678,8 @@ impl Host {
if self.explicit_root {
command.arg("--root").arg(self.root.path());
}
// What `serve` was told, for the server (P9a).
command.args(&self.server_flags);
let d = Knobs::default();
for (flag, now, default) in [
("--backup-quiet", self.knobs.backup_quiet, d.backup_quiet),
+21 -3
View File
@@ -100,6 +100,9 @@ pub struct Opener {
opened_at: Option<std::time::Instant>,
/// Whether this holder has held a lease (INVARIANT 5).
has_held: bool,
/// Flags the server this opener starts is started with (P9a):
/// `--isolation` and `--http`. Empty for every ordinary holder.
server_flags: Vec<String>,
}
/// A held lease: the books stay open while it lives.
@@ -146,9 +149,20 @@ impl Opener {
failures: 0,
opened_at: None,
has_held: false,
server_flags: Vec::new(),
})
}
/// Start the server this opener brings up with these flags (P9a:
/// `--isolation`, `--http`). Only `serve` uses it: on the owner's own
/// machine it is the one holder, so the host it starts is the server's
/// only one. A host already running was started with other flags, and
/// this one has no way to change it.
pub fn starting_with(mut self, flags: Vec<String>) -> Self {
self.server_flags = flags;
self
}
/// The timings in force.
pub fn knobs(&self) -> Knobs {
self.knobs
@@ -256,9 +270,12 @@ impl Opener {
async fn start(&self) -> Result<(), String> {
let root = self.root.clone();
let knobs = self.knobs;
tokio::task::spawn_blocking(move || crate::start::start_host(root.as_deref(), &knobs))
.await
.map_err(|e| e.to_string())?
let flags = self.server_flags.clone();
tokio::task::spawn_blocking(move || {
crate::start::start_host(root.as_deref(), &knobs, &flags)
})
.await
.map_err(|e| e.to_string())?
}
/// Say hello on a connection and read the host's first word.
@@ -457,6 +474,7 @@ mod tests {
failures: 0,
opened_at: None,
has_held: false,
server_flags: Vec::new(),
};
for _ in 0..MAX_TRIES {
o.failed().await.unwrap();
+9 -2
View File
@@ -174,8 +174,12 @@ fn systemd_run() -> Option<&'static str> {
}
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs (INVARIANTS 1-5).
pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> {
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
pub fn start_host(
root: Option<&Path>,
knobs: &Knobs,
server_flags: &[String],
) -> Result<(), String> {
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
@@ -187,6 +191,9 @@ pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> {
args.push(found.path().as_os_str().to_os_string());
}
args.extend(knobs.args().into_iter().map(Into::into));
// What the server this host starts is told (P9a): the same flags
// `serve` was given.
args.extend(server_flags.iter().map(Into::into));
let log = || {
let path = run.join(HOST_LOG);
// A large log is moved aside first (to `.1`), never from under a
+29
View File
@@ -0,0 +1,29 @@
# On a machine of the owner's own
These books may live on a machine the owner controls, not on the computer
an agent runs on. The server there runs as a separate account, and the
agent has no account on that machine: `books.start` says
`isolation: "service_user (enforced)"`. Locally it says
`same_user (advisory)`, which is honest: hidden, not locked.
What that means for you:
- Reach the books only through these tools. The machine they live on is
not yours to inspect, and you have no way to reach it except through
the server.
- The owner moves the books there with a verified copy or a backup, and
the move is confirmed at the head the preview read. If `books.verify`
or `books.start` reports a head the owner does not expect, say so and
stop; do not touch the books until the owner has looked.
- Each client has its own token. Never ask the owner for one, never print
one, and never put one in a note, an entry, a receipt or a file in the
books. If a tool says the token was refused, ask the owner to make a
new one for that client.
- Everything else is the same as on the owner's computer: the receipt
rule, the preview before a post, the books' own settings and
conventions. The machine changes where the books are, not how they are
kept.
If the owner asks how the machine is set up, point them at the deployment
notes that came with the app; do not invent steps for them, and do not
try to run any yourself.
+1
View File
@@ -86,6 +86,7 @@ pub const SECTIONS: &[(&str, &str)] = &[
),
("viewer", include_str!("../handbook/viewer.md")),
("backups", include_str!("../handbook/backups.md")),
("self-host", include_str!("../handbook/self-host.md")),
(
"format-upgrade",
include_str!("../handbook/format-upgrade.md"),
+73 -5
View File
@@ -60,7 +60,7 @@
//! ```
use crate::admin::{self, Request, Response};
use crate::server::{BooksServer, Served, SharedStore};
use crate::server::{BooksServer, Isolation, Served, SharedStore};
use ledger_store::Store;
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
use ledger_store::auth::{Principals, Scope, Token};
@@ -187,6 +187,9 @@ struct Books {
drill_first: Duration,
/// Held through a restore: one at a time.
restoring: Mutex<()>,
/// How these books are kept from agents (plan 3.8, P9a): what
/// `books.start` reports (`--isolation`).
isolation: Isolation,
}
impl Books {
@@ -294,7 +297,7 @@ impl Books {
}
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
let database = (store.database_file(), store.other_databases());
let served = Served::open(store).map_err(|e| self.hide(e))?;
let served = Served::open_with(store, self.isolation).map_err(|e| self.hide(e))?;
let config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(true)
@@ -1853,9 +1856,13 @@ fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
}
fn print_usage(prog: &str) {
eprintln!("usage: {prog} [--root DIR]");
eprintln!("usage: {prog} [--root DIR] [--isolation LEVEL] [--http [ADDR]]");
eprintln!(" The books server for the data root: started by the host, not by hand.");
eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP.");
eprintln!(" --isolation same_user | enforced: what books.start says of how these books");
eprintln!(" are kept from agents (enforced on the owner's own machine: P9a).");
eprintln!(" --http [ADDR]: also serve the MCP door on 127.0.0.1:7411 (or ADDR), for a");
eprintln!(" TLS proxy or a tunnel in front of it. Off unless given.");
}
/// `wanderbooks server`: serve every book in the data root until told to
@@ -1867,10 +1874,34 @@ pub fn run(
let mut root: Option<PathBuf> = None;
let mut backup_quiet = BACKUP_QUIET;
let mut drill_first = DRILL_FIRST;
let mut args = args.into_iter();
let mut isolation = Isolation::SameUser;
let mut tunnel: Option<std::net::SocketAddr> = None;
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
// How the books are kept from agents (plan 3.8, P9a): the
// system unit on the owner's own machine passes `enforced`;
// the server never guesses.
"--isolation" => {
let value = args
.next()
.ok_or("--isolation needs same_user or enforced")?;
isolation = Isolation::parse(&value)?;
}
// The tunnel's door (plan 3.9, P9a): the same MCP service on
// 127.0.0.1, for a TLS proxy or tunnel in front of it. Off
// unless asked for; the bearer token still guards every call.
"--http" => {
let addr = match args.peek() {
Some(v) if !v.starts_with('-') => args.next().unwrap_or_default(),
_ => "127.0.0.1:7411".to_string(),
};
tunnel =
Some(addr.parse().map_err(|e| {
format!("--http takes an address like 127.0.0.1:7411: {e}")
})?);
}
// How long the books stay unwritten before a backup (tests).
"--backup-quiet" => {
let ms: u64 = args
@@ -1967,6 +1998,7 @@ pub fn run(
backup_quiet,
drill_first,
restoring: Mutex::new(()),
isolation,
});
{
let loaded = books.load_principals();
@@ -1984,7 +2016,7 @@ pub fn run(
eprintln!("wanderbooks server: told to close while opening: closed");
Ok(())
} else {
rt.block_on(serve(books.clone(), &run, &stops))
rt.block_on(serve(books.clone(), &run, &stops, tunnel))
};
// No request is running now (serve waited for each); the stores go
// before the lock, and the process exit would release both.
@@ -2024,9 +2056,26 @@ async fn serve(
books: Arc<Books>,
run: &Path,
stops: &Stops,
tunnel: Option<std::net::SocketAddr>,
) -> Result<(), Box<dyn std::error::Error>> {
let mcp_listener = OwnerOnly(bind(run, MCP_SOCKET)?);
let mut admin_listener = OwnerOnly(bind(run, ADMIN_SOCKET)?);
// The tunnel's door (plan 3.9, P9a): the same service on 127.0.0.1,
// for the TLS proxy or tunnel in front of it. Bound before ready, so
// what starts beside the server finds it; a bind that fails is said
// and is not fatal (3.9: the books' own sockets still serve).
let tunnel_listener = match tunnel {
Some(addr) => match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => Some(listener),
Err(e) => {
eprintln!(
"wanderbooks server: the tunnel's door at {addr} can't be bound ({e}); serving the local sockets only"
);
None
}
},
None => None,
};
let stop = stops.close.clone();
{
use std::io::Write;
@@ -2083,11 +2132,21 @@ async fn serve(
// close is done: a client is never cut off mid-request by it.
let shut = CancellationToken::new();
let graceful = shut.clone();
let tunnel_app = app.clone();
let mut http = tokio::spawn(async move {
axum::serve(mcp_listener, app)
.with_graceful_shutdown(async move { graceful.cancelled().await })
.await
});
let tunnel_http = tunnel_listener.map(|listener| {
let app = tunnel_app;
let graceful = shut.clone();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move { graceful.cancelled().await })
.await
})
});
let admin_jobs = TaskTracker::new();
loop {
tokio::select! {
@@ -2172,6 +2231,14 @@ async fn serve(
Ok(Err(e)) => eprintln!("wanderbooks server: {e}"),
Err(_) => http.abort(),
}
if let Some(mut tunnel) = tunnel_http {
match tokio::time::timeout(HTTP_GRACE, &mut tunnel).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(e))) => eprintln!("wanderbooks server: {e}"),
Ok(Err(e)) => eprintln!("wanderbooks server: {e}"),
Err(_) => tunnel.abort(),
}
}
eprintln!("wanderbooks server: closed");
Ok(())
}
@@ -2599,6 +2666,7 @@ mod tests {
backup_quiet: BACKUP_QUIET,
drill_first: DRILL_FIRST,
restoring: Mutex::new(()),
isolation: Isolation::SameUser,
});
let loaded = books.load_principals();
*books.principals.write().unwrap() = loaded;
+52 -2
View File
@@ -74,6 +74,46 @@ pub struct BooksServer {
exports: ledger_store::exports::Exports,
/// The books' backups (backup.rs).
backups: Arc<crate::backup::Backups>,
/// How these books are kept from agents (plan 3.8, P9a): what
/// `books.start` reports.
isolation: Isolation,
}
/// How the books are kept from agents (plan 3.8, 2.1; D-32, D-51).
///
/// Locally the server runs as the human and the books are hidden, not
/// locked: `SameUser`. On a machine of the owner's the server runs as a
/// dedicated account and agents have no account there: `ServiceUser`.
/// The server never guesses which; `--isolation enforced` says so
/// (`serve --isolation enforced` under the system unit, P9a).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Isolation {
/// Hidden from the agent in the owner's own account, not locked.
SameUser,
/// A separate account on another machine; the agent has none there.
ServiceUser,
}
impl Isolation {
/// What `books.start` says (the strings plan 3.8 names).
pub fn label(self) -> &'static str {
match self {
Self::SameUser => "same_user (advisory)",
Self::ServiceUser => "service_user (enforced)",
}
}
/// What `--isolation` takes: `same_user`, `advisory`, `service_user`
/// or `enforced`.
pub fn parse(text: &str) -> Result<Self, String> {
match text {
"same_user" | "advisory" => Ok(Self::SameUser),
"service_user" | "enforced" => Ok(Self::ServiceUser),
other => Err(format!(
"--isolation takes same_user or enforced, not {other:?}"
)),
}
}
}
/// One book as the books server serves it (local.rs), shared by every
@@ -88,15 +128,23 @@ pub(crate) struct Served {
pub(crate) uploads: ledger_store::uploads::Uploads,
pub(crate) exports: ledger_store::exports::Exports,
pub(crate) backups: Arc<crate::backup::Backups>,
pub(crate) isolation: Isolation,
}
impl Served {
/// [`Self::open_with`] for books served the local way
/// ([`Isolation::SameUser`]): how the server's own tests open them.
#[cfg(test)]
pub(crate) fn open(store: Store) -> Result<Self, String> {
Self::open_with(store, Isolation::SameUser)
}
/// What serving the books `store` holds needs, opened beside them:
/// the settings in force (the books' own), the access log, the uploads
/// and exports, the viewer's side channel, and the backups. The books
/// server opens every book this way (local.rs), the only way books are
/// served (plan D-44).
pub(crate) fn open(store: Store) -> Result<Self, String> {
/// served (plan D-44), saying how the server runs (plan 3.8, P9a).
pub(crate) fn open_with(store: Store, isolation: Isolation) -> Result<Self, String> {
let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code());
let access = Arc::new(
@@ -130,6 +178,7 @@ impl Served {
uploads,
exports,
backups,
isolation,
})
}
}
@@ -498,6 +547,7 @@ impl BooksServer {
uploads: served.uploads.clone(),
exports: served.exports.clone(),
backups: served.backups.clone(),
isolation: served.isolation,
}
}
+6 -4
View File
@@ -266,9 +266,11 @@ pub struct StartOut {
pub format_upgrade: Option<FormatUpgradeOut>,
/// What to do next, in order.
pub next_steps: Vec<NextStepOut>,
/// How the books are kept from agents: "same_user (advisory)", hidden
/// from them in the human's own account, not locked against them
/// (plan D-32): reach them only through these tools.
/// How the books are kept from agents: "same_user (advisory)" locally
/// (hidden from them in the human's own account, not locked against
/// them: plan D-32), or "service_user (enforced)" when the books run
/// on the owner's own machine as a dedicated account (plan 3.8, P9a).
/// Either way, reach them only through these tools.
pub isolation: String,
}
@@ -921,7 +923,7 @@ impl BooksServer {
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
}),
next_steps,
isolation: "same_user (advisory)".to_string(),
isolation: self.isolation.label().to_string(),
};
// Fit (INVARIANT 4): whole lists if they fit; else summaries go,
// then the lists shorten from the end, each saying how many more.
+1 -1
View File
@@ -4719,7 +4719,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams {
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
#[serde(default)]
pub section: Option<String>,
}