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

+258
View File
@@ -0,0 +1,258 @@
# The books on a machine of your own
Plan 8.2 P9a, section 3.8. This is the recipe for the enforced deployment:
the books live on a machine you control, in a folder only a dedicated
account can read, and agents reach them through the server with a token.
Locally the books are `same_user (advisory)` — hidden from the agent, not
locked against it; here they are behind an account and a machine boundary.
Nothing here needs the owner's books: every step works on a copy.
The pieces:
- `wanderbooks-server.service` — the system unit (one account, one data
root, loopback only).
- `wanderbooks` — the server-only build of the one executable
(`cargo build --release -p ledger-cli --no-default-features`).
- Caddy (or Tailscale Funnel, or cloudflared) — TLS and the door from the
internet.
- `crates/ledger-cli/tests/two_user.rs` — the two-account checks the CI job
`.github/workflows/two-user.yml` runs, and which you can run on one
machine (step 9).
## 1. The account and the folder
```sh
sudo useradd --system --create-home --home-dir /var/lib/wanderbooks \
--shell /usr/sbin/nologin wanderbooks
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/wanderbooks
sudo install -m 0755 target/release/wanderbooks /usr/local/bin/wanderbooks
sudo install -D -m 0644 deploy/wanderbooks-server.service \
/etc/systemd/system/wanderbooks-server.service
```
The executable is the server-only build: no viewer, no window libraries.
On a Linux package install (P11) this is the same binary and the package
manager writes the file; until then, copy it as above.
## 2. Make the data root, once
```sh
sudo -u wanderbooks -H /usr/local/bin/wanderbooks setup --root /srv/wanderbooks
```
`setup` makes the folder private (0700), records it for that account and
asks for the owner passphrase when a terminal is attached. Self-host has
no `/h/` pages (D-28, D-51); the passphrase guards a format upgrade only.
Set it at a terminal if the machine has none:
```sh
ssh -t you@server 'sudo -u wanderbooks -H /usr/local/bin/wanderbooks passphrase'
```
## 3. Bring the books over
The transport is a backup clone or a copied data folder (D-31: there is no
`fetch`, and no separate archive verifier). On the machine the books are
on now:
```sh
wanderbooks close # nothing writes while it is copied
wanderbooks books list # note the id, e.g. acme
cp -a "$XDG_DATA_HOME/wanderbooks/books/acme" /media/usb/acme
```
The copy must hold the journal, the stored files, the vault and its key;
`cp -a` of the book's folder holds all four. If you would rather move a
verified backup, make one (`wanderbooks books back-up acme`) and clone the
repository it wrote; both are the same books.
Bring that folder to the server, start the service, and adopt it:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now wanderbooks-server
sudo -u wanderbooks -H /usr/local/bin/wanderbooks books adopt /media/usb/acme \
--root /srv/wanderbooks --id acme
```
The preview names the source's head (`Head: event N (short hash)`); with
`--yes` (or a `y` at the prompt) the move is confirmed against exactly
that head, so a source that changed after the preview is refused. The
original is left as it was; nothing is deleted.
**The head check.** Ask the agent on the server for `books.verify` (or
`books.start`): both report the head. Compare it with `books.verify` on
the machine the books came from, taken before the move. They are the same
event count, sequence number and hash: the move loses nothing. (The copy
is written in this version's journal format; a source written by an older
version gains one format event at adoption, and then the counts differ by
exactly that event, which the preview says: "The copy is in this version's
journal format".)
## 4. Keep the machine's hardening
The unit says `--isolation enforced`, so `books.start` reports
`service_user (enforced)`: the server is told, it never guesses. The unit
also sets `ProtectSystem=strict`, `NoNewPrivileges=yes`, `ProtectHome=yes`
and `IPAddressDeny=any` with `IPAddressAllow=localhost`, and no seccomp or
Landlock lines (D-32, D-51).
The loopback-only rule is deliberate: the server reaches nothing out. A
book whose backup target is a **remote** git repository needs that host
allowed, or its backups fail:
```ini
# /etc/systemd/system/wanderbooks-server.service.d/backup.conf
[Service]
IPAddressAllow=203.0.113.10
```
A backup to a folder on this machine needs nothing. The tunnel and Caddy
run as their own services and reach 127.0.0.1:7411, which the rule allows.
## 5. A token for each client
The token is a static bearer (`wb_` and 32 random bytes); the server keeps
only its SHA-256. Mint one per client, named for it:
```sh
sudo -u wanderbooks -H /usr/local/bin/wanderbooks token create \
--root /srv/wanderbooks --book acme --name laptop
sudo -u wanderbooks -H /usr/local/bin/wanderbooks token print \
--root /srv/wanderbooks --book acme --name laptop
```
`token create` keeps the text in the server account's OS keyring and
`token print` shows it once; copy it to that client and nowhere else.
Revoke it with `token revoke --book acme --name laptop` when a client is
lost.
A headless machine has no Secret Service by default, and `token create`
refuses to mint a token it cannot keep. Run the two commands inside a
session that has one, for example:
```sh
sudo -u wanderbooks -H dbus-run-session -- sh -c '
gnome-keyring-daemon --unlock --components=secrets >/dev/null
/usr/local/bin/wanderbooks token create --root /srv/wanderbooks --book acme --name laptop
/usr/local/bin/wanderbooks token print --root /srv/wanderbooks --book acme --name laptop
'
```
The keyring lives only for that session, which is enough: the text is
copied to the laptop, and the server checks the token's SHA-256 from the
books, not from the keyring. (Making a token without keeping it anywhere
is not a command; if your machine has a persistent Secret Service, use it.)
## 6. TLS and the door
The server listens on 127.0.0.1:7411 for the tunnel, and on its own Unix
sockets for anything running as the same account. Put TLS in front of the
loopback port. Two ways that need no open inbound port:
**Tailscale Funnel** (a tailnet name, TLS included):
```sh
sudo tailscale funnel --bg 7411
```
The books are then at `https://<machine>.<tailnet>.ts.net/b/acme/mcp`.
**Caddy** (a domain you own, automatic certificates):
```caddyfile
books.example.com {
reverse_proxy 127.0.0.1:7411
}
```
with `cloudflared tunnel --url http://127.0.0.1:7411` instead of Caddy if
you use a Cloudflare tunnel. Either way the path is passed through
unchanged: the MCP endpoint is `https://<host>/b/acme/mcp`.
Nothing else listens. Do not forward 7411 on the router, and do not put
the Unix sockets behind the proxy: they check the caller's account (3.9)
and a proxy would hide it.
## 7. Point the agent at it
**Claude Code** (a client that sends headers):
```sh
claude mcp add --transport http wanderbooks-acme https://books.example.com/b/acme/mcp \
--header "Authorization: Bearer <the token from step 5>"
```
**Codex** (`~/.codex/config.toml`; the token stays in the environment, not
the file):
```toml
[mcp_servers.wanderbooks-acme]
url = "https://books.example.com/b/acme/mcp"
bearer_token_env_var = "WANDERBOOKS_ACME_TOKEN"
```
```sh
export WANDERBOOKS_ACME_TOKEN='<the token from step 5>'
```
The plan's local rule is that no client configuration holds a token (the
bridge reads the keyring). For a remote machine there is no command yet
that keeps a remote token in the client's keyring, so this recipe puts it
in Claude's configuration (mode 0600) or in Codex's environment. That
gap is recorded in `plan/served.md` 8.2 P9a's As-built block.
## 8. First start
Start a session and ask for `books.start`. It answers
`isolation: "service_user (enforced)"`, and every tool works over the TLS
door with the token. Logs:
```sh
journalctl -u wanderbooks-server -f
```
The server runs no jobs in the background: `serve` holds the books open
until systemd stops it. On a stop it finishes the call in progress, makes
a last backup and closes (3.9), so `TimeoutStopSec=600` is generous rather
than needed.
Backups still run on their own, from the books' settings, as on the
desktop (3.7, D-30, D-31); this machine runs git itself.
## 9. Prove the boundary (what CI runs)
`.github/workflows/two-user.yml` runs the same job on every change:
1. makes two accounts, `wanderbooks` (server) and the runner (agent);
2. makes books with this version in a desktop root, copies them, and
adopts the copy into `/srv/wanderbooks`;
3. serves them as `wanderbooks` with `--isolation enforced --http
127.0.0.1:17411`, and mints a token;
4. runs `crates/ledger-cli/tests/two_user.rs` as the runner with
`WB_TWO_USER=1` and the deployment's settings. The test proves the
runner cannot open `/srv/wanderbooks` or its journal, then reads,
writes, uploads, files, exports and verifies over the HTTP door (in a
second book, `tunnel`, so the moved books' head is never advanced), and
compares the moved books' head with the source's.
`agent_uid_cannot_read_tenant` and `self_host_adopt_same_head` skip
themselves when `WB_TWO_USER` is unset, so an ordinary `cargo test` on a
laptop stays green; `isolation_label_is_enforced_under_the_unit` needs no
second account and always runs.
On one machine without a second account, run the client side in a user
namespace (a different user ID than the server's):
```sh
WB_TWO_USER=1 WB_TENANT_ROOT=... WB_SERVER_URL=http://127.0.0.1:17411 \
WB_BOOK=acme WB_TOKEN=... WB_WRITE_BOOK=tunnel WB_WRITE_TOKEN=... \
WB_SOURCE_DIR=... \
unshare --user --map-auto --setuid 0 --setgid 0 -- target/debug/deps/two_user-<hash> --nocapture
```
The two books and their tokens are made by the job (or by hand as in
steps 2 to 5): `acme` is the moved books, and `tunnel` is empty, so the
write checks never move `acme`'s head. `WB_SOURCE_DIR` is the journal the
books were moved from, readable by the agent account.