# 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://..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:///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 " ``` **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 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- --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.