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:
1 parent
cc4f104f06
commit
1441dabcca
18 files changed
+1269
-27
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user