Files
wanderbooks/deploy/README.md
T
swaits 1441dabcca 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.
2026-10-27 09:35:36 -06:00

9.8 KiB

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

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

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:

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:

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:

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:

# /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:

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:

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):

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):

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):

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):

[mcp_servers.wanderbooks-acme]
url = "https://books.example.com/b/acme/mcp"
bearer_token_env_var = "WANDERBOOKS_ACME_TOKEN"
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:

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):

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.