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
@@ -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.
|
||||
@@ -0,0 +1,60 @@
|
||||
# The books on a machine of the owner's own (plan 8.2 P9a, section 3.8).
|
||||
#
|
||||
# One server, one account, one door. It runs `wanderbooks serve` as the
|
||||
# dedicated account `wanderbooks`, over a data root only that account can
|
||||
# read, and says `service_user (enforced)` in `books.start` (the flag; the
|
||||
# server never guesses). Agents have no account on this machine and reach
|
||||
# the books through the TLS door on 127.0.0.1:7411 (a tunnel or Caddy in
|
||||
# front of it), with a static bearer token.
|
||||
#
|
||||
# No seccomp or Landlock lines: D-32 (no self-confinement) and D-51 (P9a
|
||||
# is the recipe and the boundary between accounts, nothing more). What is
|
||||
# here is the unit's own hardening of the account and the filesystem.
|
||||
#
|
||||
# Install: /usr/local/bin/wanderbooks is the server-only build
|
||||
# (`cargo build --release -p ledger-cli --no-default-features`), and the
|
||||
# root has been made once by `wanderbooks setup --root /srv/wanderbooks`
|
||||
# as the wanderbooks account. See README.md in this folder for the steps.
|
||||
|
||||
[Unit]
|
||||
Description=wanderbooks: the books server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=wanderbooks
|
||||
Group=wanderbooks
|
||||
DynamicUser=no
|
||||
# The account's own home (the keyring, when one runs; the recorded root).
|
||||
StateDirectory=wanderbooks
|
||||
Environment=HOME=/var/lib/wanderbooks
|
||||
WorkingDirectory=/
|
||||
# The books' data root, and the tunnel's door. `--isolation enforced` is
|
||||
# what `books.start` reports; `--http` serves the same MCP service on
|
||||
# 127.0.0.1 for Caddy or a tunnel. Drop `--http` if this machine has no
|
||||
# TLS door.
|
||||
ExecStart=/usr/local/bin/wanderbooks serve --root /srv/wanderbooks --isolation enforced --http 127.0.0.1:7411
|
||||
# `serve` hears SIGTERM and lets go: the server finishes the call in
|
||||
# progress, makes a last backup and closes. A long call is never cut off.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=600
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
|
||||
# The account and the filesystem (plan 3.8, 3.9).
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/srv/wanderbooks
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
# No network but loopback: the tunnel reaches 127.0.0.1:7411, and the
|
||||
# server reaches nothing out. A book whose backup target is a remote git
|
||||
# repository needs that host allowed here (`IPAddressAllow=` its address)
|
||||
# or its backups fail; see README.md.
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in new issue
Block a user