Files
wanderbooks/deploy/README.md
T
swaits c8541ea9fc fix(deploy): the tunnel's door binds loopback only (P9a)
Review finding: `--http` parsed whatever address it was given and bound
it. The door is the plaintext MCP service with a static bearer token;
plan 3.9 and the P9a model put a TLS proxy or tunnel in front of a
loopback listener, and no decision authorises exposing the books
unencrypted. `--http 0.0.0.0:7411` would have done exactly that.

`local::tunnel_address` now accepts only a loopback address
(127.0.0.0/8 or ::1; `127.0.0.1:7411` stays the default) and refuses
anything else with a message naming loopback and why. `server` refuses
while parsing, before it takes the lock or binds a socket; `serve`
refuses before it starts a host, so nothing is left half-open. The
bind-failure behaviour for a loopback address is unchanged (said, not
fatal, 3.9), as is the unit's hardening.

The test `the_tunnel_door_binds_loopback_only` refuses 0.0.0.0, a public
sample address and [::] through `server`, checks no socket or host was
left behind, refuses through `serve`, then starts a loopback listener and
reaches `books.start` with the bearer.

No accounting code, report or figure changes.
2026-10-27 09:35:36 -06:00

10 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. The TCP door is loopback only, on purpose: it is plaintext with a static bearer token, so --http refuses 0.0.0.0, a LAN address or [::] before it binds anything; the TLS proxy or tunnel is what faces the network. 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.