From eafe1b9f4a5bf7e2bdbcfe6cb21cf2204fd48b94 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Sun, 18 Oct 2026 20:55:21 +0000 Subject: [PATCH 1/7] The agent host's vault and SilverBullet (M5.4) deploy/agent-host sets up the vault on nisse-host (infra/tofu#77) and SilverBullet over it: the operator writes the vault, the agents' memory/ is read-only to it, and only the operator's Caddy reaches it, which serves vault.dongar.net to the LAN. scripts/agent-host.sh installs and checks it from here. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/README.md | 48 +++++++ .../agent-host/caddy/vault.dongar.net.caddy | 10 ++ deploy/agent-host/nisse-firewall | 12 ++ deploy/agent-host/setup.sh | 49 ++++++++ deploy/agent-host/silverbullet/compose.yaml | 26 ++++ docs/Development-Plan.md | 29 +++-- scripts/agent-host.sh | 58 +++++++++ tests/deploy/test_agent_host.py | 117 ++++++++++++++++++ 8 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 deploy/agent-host/README.md create mode 100644 deploy/agent-host/caddy/vault.dongar.net.caddy create mode 100755 deploy/agent-host/nisse-firewall create mode 100755 deploy/agent-host/setup.sh create mode 100644 deploy/agent-host/silverbullet/compose.yaml create mode 100755 scripts/agent-host.sh create mode 100644 tests/deploy/test_agent_host.py diff --git a/deploy/agent-host/README.md b/deploy/agent-host/README.md new file mode 100644 index 0000000..d1cbfc4 --- /dev/null +++ b/deploy/agent-host/README.md @@ -0,0 +1,48 @@ +# The agent host + +`nisse-host.garrett-hall.com`, made by infra/tofu#77 (architecture X1). For now +it holds the knowledge vault and SilverBullet, the operator's view of it (L10, +M5.4). The agents' containers come later (M6.1). + +## What's here + +- `silverbullet/compose.yaml`: SilverBullet, its version pinned. It mounts the + vault, with the agents' `memory/` read-only, and its own data folder. + Nothing else of the host. +- `setup.sh`: makes the vault's folders, installs the stack under + `/opt/stacks/silverbullet` and the firewall, and starts it. Safe to run again. +- `nisse-firewall`: only the operator's Caddy may reach SilverBullet's port + (Docker's `DOCKER-USER` chain, re-applied whenever Docker starts). +- `caddy/vault.dongar.net.caddy`: the site for the operator's Caddy on + `docker.garrett-hall.com`. HTTPS from Caddy's usual certificate, LAN only. + +## The vault + +`/srv/nisse/vault`, one copy on the host's disk, nothing synced: + +- **The operator's:** the root and everything in it but `memory/`, written + through SilverBullet. `infrastructure/` holds material for `nisse-ops`, which + `nisse-dev` will never be given. +- **The agents':** `memory//`, one writer each, when the agents move in. + SilverBullet sees them read-only. + +Backed up nightly with the VM (Proxmox; `backup: true` in infra/tofu). + +## Installing and checking + +From the development VM, with the bring-up key the operator added for `ubuntu`: + +```sh +scripts/agent-host.sh install # copy this folder over and run setup.sh +scripts/agent-host.sh check # running, mounts, firewall +``` + +Then: + +1. **The operator adds the Caddy site** from `caddy/vault.dongar.net.caddy` + and reloads Caddy. +2. **The operator runs SilverBullet's setup wizard at once,** at + `https://vault.dongar.net`. Until it's done, whoever opens the page first + makes the administrator account. + - Account: the operator's, with a strong password. + - Space: folder `/vault`, address `vault.dongar.net`, path `/`. diff --git a/deploy/agent-host/caddy/vault.dongar.net.caddy b/deploy/agent-host/caddy/vault.dongar.net.caddy new file mode 100644 index 0000000..6cbaf5e --- /dev/null +++ b/deploy/agent-host/caddy/vault.dongar.net.caddy @@ -0,0 +1,10 @@ +# The vault's SilverBullet, for the operator's Caddy on docker.garrett-hall.com +# (/opt/stacks). HTTPS from Caddy's usual automatic certificate; LAN only. +vault.dongar.net { + # Anyone outside the LAN gets nothing. Caddy still answers Let's Encrypt's + # checks, which it handles before the site's own routes. + @outside not remote_ip private_ranges + respond @outside 403 + + reverse_proxy nisse-host.garrett-hall.com:3000 +} diff --git a/deploy/agent-host/nisse-firewall b/deploy/agent-host/nisse-firewall new file mode 100755 index 0000000..a3a21f1 --- /dev/null +++ b/deploy/agent-host/nisse-firewall @@ -0,0 +1,12 @@ +#!/bin/sh +# nisse-firewall ADDRESS PORT: only ADDRESS may reach a port Docker publishes +# as PORT. Docker publishes ports past the host's own firewall rules, so the +# limit goes in its DOCKER-USER chain. Installed by setup.sh and run by +# nisse-firewall.service whenever Docker starts. Its own rules (tagged) are +# replaced each run, so a changed address leaves no old rule behind. +set -eu +address=$1 port=$2 +iptables -S DOCKER-USER | grep -- '--comment nisse-firewall' | sed 's/^-A /-D /' | + while read -r old; do eval "iptables $old"; done +iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport "$port" \ + --ctdir ORIGINAL ! -s "$address" -m comment --comment nisse-firewall -j DROP diff --git a/deploy/agent-host/setup.sh b/deploy/agent-host/setup.sh new file mode 100755 index 0000000..0026503 --- /dev/null +++ b/deploy/agent-host/setup.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Set up the agent host's vault and SilverBullet (M5.4). Run as root on the +# host, from this folder's copy (scripts/agent-host.sh install does that). +# Safe to run again: it changes only what differs. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) + +# The only address allowed to reach SilverBullet: the operator's Caddy +# (docker.garrett-hall.com), which serves vault.dongar.net to the LAN. +CADDY_ADDR=192.168.6.13 +SB_PORT=3000 +SB_UID=1000 # the image's PUID/PGID: the operator's side of the vault + +# The vault. Its root and everything in it belong to the operator, except +# memory/, where each agent gets a folder of its own when it moves in. memory/ +# is root's, and SilverBullet mounts it read-only besides. +install -d -m 0755 /srv/nisse +install -d -o "$SB_UID" -g "$SB_UID" -m 0750 /srv/nisse/silverbullet +install -d -o "$SB_UID" -g "$SB_UID" -m 0755 /srv/nisse/vault +install -d -o "$SB_UID" -g "$SB_UID" -m 0755 /srv/nisse/vault/infrastructure +install -d -o root -g root -m 0755 /srv/nisse/vault/memory + +# SilverBullet's stack, where the docker role keeps stacks. +install -d -m 0755 /opt/stacks/silverbullet +install -m 0644 "$here/silverbullet/compose.yaml" /opt/stacks/silverbullet/compose.yaml + +# Only Caddy may reach SilverBullet's port, re-applied whenever Docker starts. +install -m 0755 "$here/nisse-firewall" /usr/local/sbin/nisse-firewall +cat > /etc/systemd/system/nisse-firewall.service </dev/null; then + sudo docker compose exec -T silverbullet rm -f "/vault/memory/$probe" + bad "memory/ is writable from SilverBullet, even as root in it" +else + ok "memory/ is read-only to SilverBullet" +fi +sudo iptables -S DOCKER-USER | grep -q -- '--comment nisse-firewall' \ + && ok "the firewall rule is in place" || bad "no firewall rule" +exit $fail +EOF + ;; + ssh) + shift + on_host "$@" + ;; + *) + sed -n '2,7p' "$0" + exit 2 + ;; +esac diff --git a/tests/deploy/test_agent_host.py b/tests/deploy/test_agent_host.py new file mode 100644 index 0000000..2cc704f --- /dev/null +++ b/tests/deploy/test_agent_host.py @@ -0,0 +1,117 @@ +"""The agent host's vault and SilverBullet (deploy/agent-host; L10, M5.4): +the operator writes the vault, never the agents' memory; nothing but the +vault and SilverBullet's own data is mounted; the image changes only in a +PR; only the operator's Caddy reaches it.""" + +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +HOST = Path(__file__).resolve().parents[2] / "deploy" / "agent-host" +VAULT = "/srv/nisse/vault" + + +def silverbullet() -> dict[str, Any]: + compose = yaml.safe_load((HOST / "silverbullet" / "compose.yaml").read_text()) + return compose["services"]["silverbullet"] + + +def mounts() -> list[tuple[str, str, str]]: + out = [] + for volume in silverbullet()["volumes"]: + source, target, *mode = volume.split(":") + out.append((source, target, mode[0] if mode else "rw")) + return out + + +def setting(name: str) -> str: + match = re.search(rf"^{name}=(\S+)", (HOST / "setup.sh").read_text(), re.MULTILINE) + assert match, name + return match.group(1) + + +def test_the_agents_memory_is_read_only_and_the_rest_of_the_vault_is_not() -> None: + by_source = {source: (target, mode) for source, target, mode in mounts()} + vault_target, vault_mode = by_source[VAULT] + assert vault_mode == "rw" + # memory/ is mounted over the same place in the space, read-only. + assert by_source[f"{VAULT}/memory"] == (f"{vault_target}/memory", "ro") + # Nothing else of the vault is mounted, so nothing gets around that. + others = [s for s in by_source if s.startswith(VAULT)] + assert sorted(others) == [VAULT, f"{VAULT}/memory"] + + +def test_it_sees_nothing_of_the_host_but_the_vault_and_its_own_data() -> None: + assert sorted(source for source, _, _ in mounts()) == [ + "/srv/nisse/silverbullet", + VAULT, + f"{VAULT}/memory", + ] + + +def test_the_image_is_pinned_to_a_release() -> None: + # The docker role updates images nightly; a pinned release changes only here. + image = silverbullet()["image"] + assert re.fullmatch( + r"ghcr\.io/silverbulletmd/silverbullet:\d+\.\d+\.\d+(-slim)?", image + ) + + +def test_no_secrets_in_the_compose_file() -> None: + environment = silverbullet().get("environment", {}) + assert not [k for k in environment if re.search(r"USER|TOKEN|PASS|SECRET|KEY", k)] + + +def test_its_port_caddys_and_the_firewalls_agree() -> None: + port = setting("SB_PORT") + assert silverbullet()["ports"] == [f"0.0.0.0:{port}:3000"] # IPv4 only + caddy = (HOST / "caddy" / "vault.dongar.net.caddy").read_text() + assert f"reverse_proxy nisse-host.garrett-hall.com:{port}" in caddy + assert "@outside not remote_ip private_ranges" in caddy # the LAN only + + +def test_logs_go_to_the_hosts_journal() -> None: + assert silverbullet()["logging"] == {"driver": "journald"} + + +def _can_use_a_network_namespace() -> bool: + if not (shutil.which("unshare") and shutil.which("iptables")): + return False + probe = subprocess.run( + ["unshare", "-n", "iptables", "-S"], capture_output=True, check=False + ) + return probe.returncode == 0 + + +@pytest.mark.skipif( + not _can_use_a_network_namespace(), + reason="needs root, unshare and iptables to try the rule in a namespace", +) +def test_the_firewall_lets_only_one_address_in_and_replaces_its_own_rule() -> None: + script = HOST / "nisse-firewall" + steps = ( + "iptables -N DOCKER-USER; iptables -A DOCKER-USER -j RETURN; " + f"{script} 192.168.6.13 3000; {script} 192.168.6.13 3000; " + f"{script} 192.168.6.14 3000; iptables -S DOCKER-USER" + ) + run = subprocess.run( + ["unshare", "-n", "sh", "-ec", steps], + capture_output=True, + text=True, + check=True, + ) + rules = [r for r in run.stdout.splitlines() if "nisse-firewall" in r] + assert len(rules) == 1, run.stdout # run again: still one; changed: replaced + [rule] = rules + assert "! -s 192.168.6.14/32" in rule + assert "--ctorigdstport 3000" in rule + assert rule.endswith("-j DROP") + # Ahead of Docker's own return, so it's checked first. + assert run.stdout.splitlines().index(rule) < run.stdout.splitlines().index( + "-A DOCKER-USER -j RETURN" + ) From e7a0c0f1db6f39902a8553723abc71af02660589 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Mon, 19 Oct 2026 13:08:06 +0000 Subject: [PATCH 2/7] Agent host: the VM is now nisse, not nisse-host Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/README.md | 4 ++-- deploy/agent-host/caddy/vault.dongar.net.caddy | 2 +- docs/Development-Plan.md | 8 ++++---- scripts/agent-host.sh | 4 ++-- tests/deploy/test_agent_host.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/deploy/agent-host/README.md b/deploy/agent-host/README.md index d1cbfc4..c06105d 100644 --- a/deploy/agent-host/README.md +++ b/deploy/agent-host/README.md @@ -1,7 +1,7 @@ # The agent host -`nisse-host.garrett-hall.com`, made by infra/tofu#77 (architecture X1). For now -it holds the knowledge vault and SilverBullet, the operator's view of it (L10, +`nisse.garrett-hall.com`, made by infra/tofu#77 as `nisse-host` and since +renamed (architecture X1). For now it holds the knowledge vault and SilverBullet, the operator's view of it (L10, M5.4). The agents' containers come later (M6.1). ## What's here diff --git a/deploy/agent-host/caddy/vault.dongar.net.caddy b/deploy/agent-host/caddy/vault.dongar.net.caddy index 6cbaf5e..9afa56a 100644 --- a/deploy/agent-host/caddy/vault.dongar.net.caddy +++ b/deploy/agent-host/caddy/vault.dongar.net.caddy @@ -6,5 +6,5 @@ vault.dongar.net { @outside not remote_ip private_ranges respond @outside 403 - reverse_proxy nisse-host.garrett-hall.com:3000 + reverse_proxy nisse.garrett-hall.com:3000 } diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 943d4fd..abc1132 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -553,10 +553,10 @@ SilverBullet so far (M5.4). Until M6.2, the build VMs stay made by hand, with `vm ready` for a rebuilt one's host key. **M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots, -per-agent networks (X1, X2, X4). The host exists: `nisse-host` (infra/tofu#77, -2026-10-18; 2 cores, 4 GB, 100 GB, on VLAN 1, nightly Proxmox backups), requested -through nisse-dev's own quota to test that path; the operator may move it out of -the quota. It holds only the vault and SilverBullet so far (M5.4). +per-agent networks (X1, X2, X4). The host exists: `nisse` (infra/tofu#77, +2026-10-18, as `nisse-host`; 2 cores, 4 GB, 100 GB, on VLAN 1, nightly Proxmox +backups), requested through nisse-dev's own quota to test that path; the operator +has since renamed it and moved it out of the quota. It holds only the vault and SilverBullet so far (M5.4). **M6.2 Build VMs by Terraform.** *Needs discussion.* The template, VLAN and egress (D7, X2), collection when idle. diff --git a/scripts/agent-host.sh b/scripts/agent-host.sh index 4e6dead..bfb7204 100755 --- a/scripts/agent-host.sh +++ b/scripts/agent-host.sh @@ -7,9 +7,9 @@ # Trusts only the host keys bot-ansible posted (~/bin/tofu-wait.sh saved them). set -euo pipefail -host=${AGENT_HOST:-nisse-host.garrett-hall.com} +host=${AGENT_HOST:-nisse.garrett-hall.com} key=${AGENT_HOST_KEY:-/root/nisse-e2e/ssh/id_ed25519} -known=${AGENT_HOST_KNOWN:-/root/nisse-e2e/nisse-host.known_hosts} +known=${AGENT_HOST_KNOWN:-/root/nisse-e2e/nisse.known_hosts} repo=$(cd "$(dirname "$0")/.." && pwd) on_host() { diff --git a/tests/deploy/test_agent_host.py b/tests/deploy/test_agent_host.py index 2cc704f..9d65363 100644 --- a/tests/deploy/test_agent_host.py +++ b/tests/deploy/test_agent_host.py @@ -71,7 +71,7 @@ def test_its_port_caddys_and_the_firewalls_agree() -> None: port = setting("SB_PORT") assert silverbullet()["ports"] == [f"0.0.0.0:{port}:3000"] # IPv4 only caddy = (HOST / "caddy" / "vault.dongar.net.caddy").read_text() - assert f"reverse_proxy nisse-host.garrett-hall.com:{port}" in caddy + assert f"reverse_proxy nisse.garrett-hall.com:{port}" in caddy assert "@outside not remote_ip private_ranges" in caddy # the LAN only From 454326e750f6afa868348df99c3c471ac701f9a0 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Mon, 19 Oct 2026 13:08:16 +0000 Subject: [PATCH 3/7] Agent host README: rewrap the introduction Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/agent-host/README.md b/deploy/agent-host/README.md index c06105d..b4013af 100644 --- a/deploy/agent-host/README.md +++ b/deploy/agent-host/README.md @@ -1,8 +1,9 @@ # The agent host `nisse.garrett-hall.com`, made by infra/tofu#77 as `nisse-host` and since -renamed (architecture X1). For now it holds the knowledge vault and SilverBullet, the operator's view of it (L10, -M5.4). The agents' containers come later (M6.1). +renamed (architecture X1). For now it holds the knowledge vault and +SilverBullet, the operator's view of it (L10, M5.4). The agents' containers come +later (M6.1). ## What's here From 5ab0861e8e9331c8780a84e37e86d62137df4cba Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Mon, 19 Oct 2026 14:48:31 +0000 Subject: [PATCH 4/7] Agent host check: run every step, and say so The check went to the host on stdin, and the first docker compose exec read the rest of it: the memory and firewall checks never ran, and it passed. It is now its own file, sent as an argument, and ends with "all checks passed" only when every check has run. Tests fake the host with a docker that reads stdin. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/README.md | 2 + deploy/agent-host/check.sh | 28 +++++++++++++ scripts/agent-host.sh | 29 +++----------- tests/deploy/test_agent_host.py | 69 +++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 24 deletions(-) create mode 100755 deploy/agent-host/check.sh diff --git a/deploy/agent-host/README.md b/deploy/agent-host/README.md index b4013af..6b8c9b3 100644 --- a/deploy/agent-host/README.md +++ b/deploy/agent-host/README.md @@ -14,6 +14,8 @@ later (M6.1). `/opt/stacks/silverbullet` and the firewall, and starts it. Safe to run again. - `nisse-firewall`: only the operator's Caddy may reach SilverBullet's port (Docker's `DOCKER-USER` chain, re-applied whenever Docker starts). +- `check.sh`: run on the host by `scripts/agent-host.sh check`; says "all + checks passed" only once every check has run. - `caddy/vault.dongar.net.caddy`: the site for the operator's Caddy on `docker.garrett-hall.com`. HTTPS from Caddy's usual certificate, LAN only. diff --git a/deploy/agent-host/check.sh b/deploy/agent-host/check.sh new file mode 100755 index 0000000..117c4a6 --- /dev/null +++ b/deploy/agent-host/check.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Checks the vault and SilverBullet on the agent host; run there as a user with +# sudo (scripts/agent-host.sh check sends it). Exits 1 if any check fails, and +# only says "all checks passed" once every one has run. +set -u +stack=${STACK:-/opt/stacks/silverbullet} +fail=0 +ok() { echo "ok: $1"; } +bad() { echo "FAIL: $1"; fail=1; } +cd "$stack" || { echo "FAIL: no $stack"; exit 1; } +[ "$(sudo docker compose ps --status running -q | wc -l)" -ge 1 ] \ + && ok "SilverBullet is running" || bad "SilverBullet isn't running" +code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/) +case $code in 2??|3??) ok "it answers locally ($code)" ;; *) bad "it answers $code" ;; esac +probe=.nisse-probe-$$ +sudo docker compose exec -T -u 1000 silverbullet \ + sh -c "touch /vault/infrastructure/$probe && rm /vault/infrastructure/$probe" \ + && ok "the operator's folders are writable" || bad "the operator's folders aren't writable" +if sudo docker compose exec -T silverbullet sh -c "touch /vault/memory/$probe" 2>/dev/null; then + sudo docker compose exec -T silverbullet rm -f "/vault/memory/$probe" + bad "memory/ is writable from SilverBullet, even as root in it" +else + ok "memory/ is read-only to SilverBullet" +fi +sudo iptables -S DOCKER-USER | grep -q -- '--comment nisse-firewall' \ + && ok "the firewall rule is in place" || bad "no firewall rule" +[ $fail = 0 ] && echo "all checks passed" +exit $fail diff --git a/scripts/agent-host.sh b/scripts/agent-host.sh index bfb7204..f9d05bb 100755 --- a/scripts/agent-host.sh +++ b/scripts/agent-host.sh @@ -4,7 +4,8 @@ # scripts/agent-host.sh check check SilverBullet runs and the mounts and # firewall hold # scripts/agent-host.sh ssh ... run a command on the host -# Trusts only the host keys bot-ansible posted (~/bin/tofu-wait.sh saved them). +# Trusts only host keys from a trusted source: bot-ansible's comment +# (~/bin/tofu-wait.sh saves them) or the operator, read off the console. set -euo pipefail host=${AGENT_HOST:-nisse.garrett-hall.com} @@ -23,29 +24,9 @@ case ${1:-} in on_host 'rm -rf /tmp/agent-host && tar -C /tmp -xf - && sudo bash /tmp/agent-host/setup.sh' ;; check) - on_host 'bash -s' <<'EOF' -set -u -fail=0 -ok() { echo "ok: $1"; } -bad() { echo "FAIL: $1"; fail=1; } -cd /opt/stacks/silverbullet -[ "$(sudo docker compose ps --status running -q | wc -l)" -ge 1 ] \ - && ok "SilverBullet is running" || bad "SilverBullet isn't running" -code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/) -case $code in 2??|3??) ok "it answers locally ($code)" ;; *) bad "it answers $code" ;; esac -probe=.nisse-probe-$$ -sudo docker compose exec -T -u 1000 silverbullet sh -c "touch /vault/infrastructure/$probe && rm /vault/infrastructure/$probe" \ - && ok "the operator's folders are writable" || bad "the operator's folders aren't writable" -if sudo docker compose exec -T silverbullet sh -c "touch /vault/memory/$probe" 2>/dev/null; then - sudo docker compose exec -T silverbullet rm -f "/vault/memory/$probe" - bad "memory/ is writable from SilverBullet, even as root in it" -else - ok "memory/ is read-only to SilverBullet" -fi -sudo iptables -S DOCKER-USER | grep -q -- '--comment nisse-firewall' \ - && ok "the firewall rule is in place" || bad "no firewall rule" -exit $fail -EOF + # As an argument, not on stdin: docker compose exec reads stdin, and would + # swallow the rest of a script fed that way. + on_host "bash -c $(printf '%q' "$(cat "$repo/deploy/agent-host/check.sh")")" ;; ssh) shift diff --git a/tests/deploy/test_agent_host.py b/tests/deploy/test_agent_host.py index 9d65363..0e947e9 100644 --- a/tests/deploy/test_agent_host.py +++ b/tests/deploy/test_agent_host.py @@ -115,3 +115,72 @@ def test_the_firewall_lets_only_one_address_in_and_replaces_its_own_rule() -> No assert run.stdout.splitlines().index(rule) < run.stdout.splitlines().index( "-A DOCKER-USER -j RETURN" ) + + +SCRIPT = HOST.parents[1] / "scripts" / "agent-host.sh" + + +def _fake_host(tmp_path: Path, *, memory_writable: bool) -> dict[str, str]: + """Commands standing in for the host's: ssh runs the command here, sudo + just runs it, and docker reads all of stdin, as docker compose exec does.""" + bin_ = tmp_path / "bin" + bin_.mkdir() + stubs = { + "ssh": 'for last; do :; done; exec bash -c "$last"', + "sudo": 'exec "$@"', + "curl": "echo 200", + "iptables": "echo '-A DOCKER-USER -m comment --comment nisse-firewall -j DROP'", + "docker": ( + "cat > /dev/null\n" + 'case "$*" in\n' + ' *"ps --status running"*) echo abc123 ;;\n' + f' *"/vault/memory/"*) exit {0 if memory_writable else 1} ;;\n' + "esac" + ), + } + for name, body in stubs.items(): + path = bin_ / name + path.write_text(f"#!/usr/bin/env bash\n{body}\n") + path.chmod(0o755) + stack = tmp_path / "stack" + stack.mkdir() + return {"PATH": f"{bin_}:/usr/bin:/bin", "STACK": str(stack), "HOME": str(tmp_path)} + + +def _check(env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT), "check"], + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + +def test_the_check_runs_every_step_even_though_docker_reads_stdin( + tmp_path: Path, +) -> None: + # Fed to the host on stdin, the first docker compose exec swallowed the + # rest of the check, which then passed without the last two steps. + result = _check(_fake_host(tmp_path, memory_writable=False)) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.splitlines() == [ + "ok: SilverBullet is running", + "ok: it answers locally (200)", + "ok: the operator's folders are writable", + "ok: memory/ is read-only to SilverBullet", + "ok: the firewall rule is in place", + "all checks passed", + ] + + +def test_a_writable_memory_folder_fails_the_check(tmp_path: Path) -> None: + result = _check(_fake_host(tmp_path, memory_writable=True)) + assert result.returncode == 1 + assert ( + "FAIL: memory/ is writable from SilverBullet, even as root in it" + in result.stdout + ) + assert "ok: the firewall rule is in place" in result.stdout # still checked + assert "all checks passed" not in result.stdout From e287eea2d375b4cff74eed402433e5b8b8b8dbc1 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Mon, 19 Oct 2026 16:08:30 +0000 Subject: [PATCH 5/7] Agent host: the site is nisse-memory.dongar.net, inside garrett-hall.com The operator renamed the site from vault.dongar.net. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/README.md | 11 ++++++----- ...dongar.net.caddy => nisse-memory.dongar.net.caddy} | 2 +- deploy/agent-host/setup.sh | 2 +- docs/Development-Plan.md | 6 +++--- tests/deploy/test_agent_host.py | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) rename deploy/agent-host/caddy/{vault.dongar.net.caddy => nisse-memory.dongar.net.caddy} (93%) diff --git a/deploy/agent-host/README.md b/deploy/agent-host/README.md index 6b8c9b3..af96fed 100644 --- a/deploy/agent-host/README.md +++ b/deploy/agent-host/README.md @@ -16,8 +16,9 @@ later (M6.1). (Docker's `DOCKER-USER` chain, re-applied whenever Docker starts). - `check.sh`: run on the host by `scripts/agent-host.sh check`; says "all checks passed" only once every check has run. -- `caddy/vault.dongar.net.caddy`: the site for the operator's Caddy on - `docker.garrett-hall.com`. HTTPS from Caddy's usual certificate, LAN only. +- `caddy/nisse-memory.dongar.net.caddy`: the site for the operator's Caddy on + `docker.garrett-hall.com`. HTTPS from Caddy's usual certificate, inside + garrett-hall.com only. ## The vault @@ -42,10 +43,10 @@ scripts/agent-host.sh check # running, mounts, firewall Then: -1. **The operator adds the Caddy site** from `caddy/vault.dongar.net.caddy` +1. **The operator adds the Caddy site** from `caddy/nisse-memory.dongar.net.caddy` and reloads Caddy. 2. **The operator runs SilverBullet's setup wizard at once,** at - `https://vault.dongar.net`. Until it's done, whoever opens the page first + `https://nisse-memory.dongar.net`. Until it's done, whoever opens the page first makes the administrator account. - Account: the operator's, with a strong password. - - Space: folder `/vault`, address `vault.dongar.net`, path `/`. + - Space: folder `/vault`, address `nisse-memory.dongar.net`, path `/`. diff --git a/deploy/agent-host/caddy/vault.dongar.net.caddy b/deploy/agent-host/caddy/nisse-memory.dongar.net.caddy similarity index 93% rename from deploy/agent-host/caddy/vault.dongar.net.caddy rename to deploy/agent-host/caddy/nisse-memory.dongar.net.caddy index 9afa56a..2c08157 100644 --- a/deploy/agent-host/caddy/vault.dongar.net.caddy +++ b/deploy/agent-host/caddy/nisse-memory.dongar.net.caddy @@ -1,6 +1,6 @@ # The vault's SilverBullet, for the operator's Caddy on docker.garrett-hall.com # (/opt/stacks). HTTPS from Caddy's usual automatic certificate; LAN only. -vault.dongar.net { +nisse-memory.dongar.net { # Anyone outside the LAN gets nothing. Caddy still answers Let's Encrypt's # checks, which it handles before the site's own routes. @outside not remote_ip private_ranges diff --git a/deploy/agent-host/setup.sh b/deploy/agent-host/setup.sh index 0026503..f76bbdc 100755 --- a/deploy/agent-host/setup.sh +++ b/deploy/agent-host/setup.sh @@ -7,7 +7,7 @@ set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) # The only address allowed to reach SilverBullet: the operator's Caddy -# (docker.garrett-hall.com), which serves vault.dongar.net to the LAN. +# (docker.garrett-hall.com), which serves nisse-memory.dongar.net inside garrett-hall.com. CADDY_ADDR=192.168.6.13 SB_PORT=3000 SB_UID=1000 # the image's PUID/PGID: the operator's side of the vault diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index abc1132..fbd9c2d 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -539,9 +539,9 @@ for `nisse-ops` there now: read-only, and nothing else of the host but its own data. - SilverBullet's version is pinned (the docker role updates images nightly). Its accounts are its own, made in its setup wizard. -- Decided with the operator: behind their Caddy at `vault.dongar.net` for HTTPS, - the LAN only, with no Authelia (an internal instance; SilverBullet's own login - is enough), and its own instance rather than the operator's personal one, so +- Decided with the operator: behind their Caddy at `nisse-memory.dongar.net` for + HTTPS, inside garrett-hall.com only, with no Authelia (an internal instance; + SilverBullet's own login is enough), and its own instance rather than the operator's personal one, so the vault stays one local copy with mounts enforcing its writers. On the host, a `DOCKER-USER` rule lets only Caddy reach it. diff --git a/tests/deploy/test_agent_host.py b/tests/deploy/test_agent_host.py index 0e947e9..21f0b62 100644 --- a/tests/deploy/test_agent_host.py +++ b/tests/deploy/test_agent_host.py @@ -70,7 +70,7 @@ def test_no_secrets_in_the_compose_file() -> None: def test_its_port_caddys_and_the_firewalls_agree() -> None: port = setting("SB_PORT") assert silverbullet()["ports"] == [f"0.0.0.0:{port}:3000"] # IPv4 only - caddy = (HOST / "caddy" / "vault.dongar.net.caddy").read_text() + caddy = (HOST / "caddy" / "nisse-memory.dongar.net.caddy").read_text() assert f"reverse_proxy nisse.garrett-hall.com:{port}" in caddy assert "@outside not remote_ip private_ranges" in caddy # the LAN only From 1d33d50907d7b8abbdc3a6b8dde1866e8afe001c Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Mon, 19 Oct 2026 16:28:09 +0000 Subject: [PATCH 6/7] Agent host: SilverBullet boots into accounts and the vault, not a bare space SB_NAME alone put SilverBullet 2.11 in single-space mode: no accounts, no setup wizard, and its /data folder served instead of the vault. The compose file sets only PUID and PGID now, a test holds it to that, and the host check fails on a single-space boot. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/check.sh | 5 +++++ deploy/agent-host/silverbullet/compose.yaml | 3 ++- tests/deploy/test_agent_host.py | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/deploy/agent-host/check.sh b/deploy/agent-host/check.sh index 117c4a6..ac16a93 100755 --- a/deploy/agent-host/check.sh +++ b/deploy/agent-host/check.sh @@ -10,6 +10,11 @@ bad() { echo "FAIL: $1"; fail=1; } cd "$stack" || { echo "FAIL: no $stack"; exit 1; } [ "$(sudo docker compose ps --status running -q | wc -l)" -ge 1 ] \ && ok "SilverBullet is running" || bad "SilverBullet isn't running" +if sudo docker compose logs --no-color silverbullet 2>/dev/null | grep -q 'boot mode: single-space'; then + bad "it booted a single space with no accounts (an SB_* setting?)" +else + ok "it keeps accounts (no single-space boot)" +fi code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/) case $code in 2??|3??) ok "it answers locally ($code)" ;; *) bad "it answers $code" ;; esac probe=.nisse-probe-$$ diff --git a/deploy/agent-host/silverbullet/compose.yaml b/deploy/agent-host/silverbullet/compose.yaml index 73aaabc..862d666 100644 --- a/deploy/agent-host/silverbullet/compose.yaml +++ b/deploy/agent-host/silverbullet/compose.yaml @@ -8,9 +8,10 @@ services: image: ghcr.io/silverbulletmd/silverbullet:2.11.1-slim restart: unless-stopped environment: + # Only these. Any SB_* setting (even SB_NAME) boots a single space with + # no accounts, serving /data instead of the vault. PUID: "1000" PGID: "1000" - SB_NAME: Nisse vault ports: # IPv4 only; setup.sh's firewall lets only the operator's Caddy in. - "0.0.0.0:3000:3000" diff --git a/tests/deploy/test_agent_host.py b/tests/deploy/test_agent_host.py index 21f0b62..ec47048 100644 --- a/tests/deploy/test_agent_host.py +++ b/tests/deploy/test_agent_host.py @@ -67,6 +67,12 @@ def test_no_secrets_in_the_compose_file() -> None: assert not [k for k in environment if re.search(r"USER|TOKEN|PASS|SECRET|KEY", k)] +def test_it_boots_into_accounts_and_the_vault_not_a_bare_space() -> None: + # SB_NAME alone switched 2.11 to single-space mode: no accounts, no setup + # wizard, and /data served in place of the vault. + assert set(silverbullet()["environment"]) == {"PUID", "PGID"} + + def test_its_port_caddys_and_the_firewalls_agree() -> None: port = setting("SB_PORT") assert silverbullet()["ports"] == [f"0.0.0.0:{port}:3000"] # IPv4 only @@ -120,7 +126,9 @@ def test_the_firewall_lets_only_one_address_in_and_replaces_its_own_rule() -> No SCRIPT = HOST.parents[1] / "scripts" / "agent-host.sh" -def _fake_host(tmp_path: Path, *, memory_writable: bool) -> dict[str, str]: +def _fake_host( + tmp_path: Path, *, memory_writable: bool, boot: str = "multi-space" +) -> dict[str, str]: """Commands standing in for the host's: ssh runs the command here, sudo just runs it, and docker reads all of stdin, as docker compose exec does.""" bin_ = tmp_path / "bin" @@ -134,6 +142,7 @@ def _fake_host(tmp_path: Path, *, memory_writable: bool) -> dict[str, str]: "cat > /dev/null\n" 'case "$*" in\n' ' *"ps --status running"*) echo abc123 ;;\n' + f' *" logs "*) echo "boot mode: {boot}" ;;\n' f' *"/vault/memory/"*) exit {0 if memory_writable else 1} ;;\n' "esac" ), @@ -167,6 +176,7 @@ def test_the_check_runs_every_step_even_though_docker_reads_stdin( assert result.returncode == 0, result.stdout + result.stderr assert result.stdout.splitlines() == [ "ok: SilverBullet is running", + "ok: it keeps accounts (no single-space boot)", "ok: it answers locally (200)", "ok: the operator's folders are writable", "ok: memory/ is read-only to SilverBullet", @@ -184,3 +194,9 @@ def test_a_writable_memory_folder_fails_the_check(tmp_path: Path) -> None: ) assert "ok: the firewall rule is in place" in result.stdout # still checked assert "all checks passed" not in result.stdout + + +def test_a_single_space_boot_fails_the_check(tmp_path: Path) -> None: + result = _check(_fake_host(tmp_path, memory_writable=False, boot="single-space")) + assert result.returncode == 1 + assert "FAIL: it booted a single space with no accounts" in result.stdout From bf98445fcbbb3a71b418d1f47bf117f4fea872b7 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Mon, 19 Oct 2026 16:41:29 +0000 Subject: [PATCH 7/7] Agent host: SilverBullet serves the vault as its one space, behind Authelia The operator chose single-space mode: the vault is mounted at /data, with memory/ read-only over it, and there are no accounts or setup wizard; Authelia in the operator's Caddy is the login. SB_NAME forces the mode, so an empty vault can't boot the wizard. SilverBullet's own data folder is gone. check now fails if the wizard could be open. The README says how to make memory/ writable for a while and back. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- deploy/agent-host/README.md | 28 +++++++------ .../caddy/nisse-memory.dongar.net.caddy | 2 + deploy/agent-host/check.sh | 10 ++--- deploy/agent-host/setup.sh | 4 +- deploy/agent-host/silverbullet/compose.yaml | 19 +++++---- docs/Development-Plan.md | 17 ++++---- tests/deploy/test_agent_host.py | 42 ++++++++++++------- 7 files changed, 70 insertions(+), 52 deletions(-) diff --git a/deploy/agent-host/README.md b/deploy/agent-host/README.md index af96fed..f82e3c9 100644 --- a/deploy/agent-host/README.md +++ b/deploy/agent-host/README.md @@ -7,9 +7,9 @@ later (M6.1). ## What's here -- `silverbullet/compose.yaml`: SilverBullet, its version pinned. It mounts the - vault, with the agents' `memory/` read-only, and its own data folder. - Nothing else of the host. +- `silverbullet/compose.yaml`: SilverBullet, its version pinned, serving the + vault as its one space, with the agents' `memory/` read-only. Nothing else of + the host. It has no accounts: the operator logs in through Authelia. - `setup.sh`: makes the vault's folders, installs the stack under `/opt/stacks/silverbullet` and the firewall, and starts it. Safe to run again. - `nisse-firewall`: only the operator's Caddy may reach SilverBullet's port @@ -17,8 +17,8 @@ later (M6.1). - `check.sh`: run on the host by `scripts/agent-host.sh check`; says "all checks passed" only once every check has run. - `caddy/nisse-memory.dongar.net.caddy`: the site for the operator's Caddy on - `docker.garrett-hall.com`. HTTPS from Caddy's usual certificate, inside - garrett-hall.com only. + `docker.garrett-hall.com`. HTTPS from Caddy's usual certificate, + behind Authelia. ## The vault @@ -41,12 +41,14 @@ scripts/agent-host.sh install # copy this folder over and run setup.sh scripts/agent-host.sh check # running, mounts, firewall ``` -Then: +Then **the operator adds the Caddy site** from +`caddy/nisse-memory.dongar.net.caddy`, behind Authelia, and reloads Caddy. +There's no setup wizard: SilverBullet starts serving the vault at once. -1. **The operator adds the Caddy site** from `caddy/nisse-memory.dongar.net.caddy` - and reloads Caddy. -2. **The operator runs SilverBullet's setup wizard at once,** at - `https://nisse-memory.dongar.net`. Until it's done, whoever opens the page first - makes the administrator account. - - Account: the operator's, with a strong password. - - Space: folder `/vault`, address `nisse-memory.dongar.net`, path `/`. +**Making `memory/` writable for a while** (for example, to upload material that +will be sorted into the agents' folders): on the host, add +`/opt/stacks/silverbullet/compose.override.yaml` that replaces the volumes with +the vault alone (`volumes: !override [/srv/nisse/vault:/data]`), give `memory/` +to uid 1000, and `docker compose up -d`. `check` fails while it's in place. +To end it, delete the override and run `install` again, which gives `memory/` +back to root. diff --git a/deploy/agent-host/caddy/nisse-memory.dongar.net.caddy b/deploy/agent-host/caddy/nisse-memory.dongar.net.caddy index 2c08157..c794889 100644 --- a/deploy/agent-host/caddy/nisse-memory.dongar.net.caddy +++ b/deploy/agent-host/caddy/nisse-memory.dongar.net.caddy @@ -1,5 +1,7 @@ # The vault's SilverBullet, for the operator's Caddy on docker.garrett-hall.com # (/opt/stacks). HTTPS from Caddy's usual automatic certificate; LAN only. +# Out of date: the live site is behind Authelia and open beyond the LAN. This +# copy is updated once the operator shares it. nisse-memory.dongar.net { # Anyone outside the LAN gets nothing. Caddy still answers Let's Encrypt's # checks, which it handles before the site's own routes. diff --git a/deploy/agent-host/check.sh b/deploy/agent-host/check.sh index ac16a93..36d9033 100755 --- a/deploy/agent-host/check.sh +++ b/deploy/agent-host/check.sh @@ -11,18 +11,18 @@ cd "$stack" || { echo "FAIL: no $stack"; exit 1; } [ "$(sudo docker compose ps --status running -q | wc -l)" -ge 1 ] \ && ok "SilverBullet is running" || bad "SilverBullet isn't running" if sudo docker compose logs --no-color silverbullet 2>/dev/null | grep -q 'boot mode: single-space'; then - bad "it booted a single space with no accounts (an SB_* setting?)" + ok "it serves the vault as one space (no setup wizard)" else - ok "it keeps accounts (no single-space boot)" + bad "it didn't boot one space: the setup wizard may be open" fi code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/) case $code in 2??|3??) ok "it answers locally ($code)" ;; *) bad "it answers $code" ;; esac probe=.nisse-probe-$$ sudo docker compose exec -T -u 1000 silverbullet \ - sh -c "touch /vault/infrastructure/$probe && rm /vault/infrastructure/$probe" \ + sh -c "touch /data/infrastructure/$probe && rm /data/infrastructure/$probe" \ && ok "the operator's folders are writable" || bad "the operator's folders aren't writable" -if sudo docker compose exec -T silverbullet sh -c "touch /vault/memory/$probe" 2>/dev/null; then - sudo docker compose exec -T silverbullet rm -f "/vault/memory/$probe" +if sudo docker compose exec -T silverbullet sh -c "touch /data/memory/$probe" 2>/dev/null; then + sudo docker compose exec -T silverbullet rm -f "/data/memory/$probe" bad "memory/ is writable from SilverBullet, even as root in it" else ok "memory/ is read-only to SilverBullet" diff --git a/deploy/agent-host/setup.sh b/deploy/agent-host/setup.sh index f76bbdc..ccbe025 100755 --- a/deploy/agent-host/setup.sh +++ b/deploy/agent-host/setup.sh @@ -7,7 +7,8 @@ set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) # The only address allowed to reach SilverBullet: the operator's Caddy -# (docker.garrett-hall.com), which serves nisse-memory.dongar.net inside garrett-hall.com. +# (docker.garrett-hall.com), which serves nisse-memory.dongar.net behind +# Authelia. CADDY_ADDR=192.168.6.13 SB_PORT=3000 SB_UID=1000 # the image's PUID/PGID: the operator's side of the vault @@ -16,7 +17,6 @@ SB_UID=1000 # the image's PUID/PGID: the operator's side of the vault # memory/, where each agent gets a folder of its own when it moves in. memory/ # is root's, and SilverBullet mounts it read-only besides. install -d -m 0755 /srv/nisse -install -d -o "$SB_UID" -g "$SB_UID" -m 0750 /srv/nisse/silverbullet install -d -o "$SB_UID" -g "$SB_UID" -m 0755 /srv/nisse/vault install -d -o "$SB_UID" -g "$SB_UID" -m 0755 /srv/nisse/vault/infrastructure install -d -o root -g root -m 0755 /srv/nisse/vault/memory diff --git a/deploy/agent-host/silverbullet/compose.yaml b/deploy/agent-host/silverbullet/compose.yaml index 862d666..589bc50 100644 --- a/deploy/agent-host/silverbullet/compose.yaml +++ b/deploy/agent-host/silverbullet/compose.yaml @@ -1,6 +1,7 @@ # SilverBullet on the agent host: the operator's view of the vault (L10, M5.4). -# Installed to /opt/stacks/silverbullet by ../setup.sh. No secrets here: its -# accounts are made in its setup wizard and kept in its data folder. +# Installed to /opt/stacks/silverbullet by ../setup.sh. No secrets here: it +# has no accounts of its own; the operator logs in through Authelia, in front of +# it in their Caddy, and only Caddy can reach it (setup.sh's firewall). services: silverbullet: # Pinned: the docker role updates stacks' images nightly, and a pinned @@ -8,20 +9,20 @@ services: image: ghcr.io/silverbulletmd/silverbullet:2.11.1-slim restart: unless-stopped environment: - # Only these. Any SB_* setting (even SB_NAME) boots a single space with - # no accounts, serving /data instead of the vault. PUID: "1000" PGID: "1000" + # Set on purpose: any SB_* setting boots one space, the vault mounted at + # /data, with no accounts or setup wizard. Without one, an empty vault + # would boot the wizard instead. + SB_NAME: Nisse vault ports: # IPv4 only; setup.sh's firewall lets only the operator's Caddy in. - "0.0.0.0:3000:3000" volumes: - # Its own accounts and settings. - - /srv/nisse/silverbullet:/data - # The vault, the space's folder: the operator writes everything in it... - - /srv/nisse/vault:/vault + # The vault is the space: the operator writes everything in it... + - /srv/nisse/vault:/data # ...except the agents' memory, which only the agents write (one writer # per folder, enforced by the kernel). - - /srv/nisse/vault/memory:/vault/memory:ro + - /srv/nisse/vault/memory:/data/memory:ro logging: driver: journald # logs go to the host's journal (L12) diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index fbd9c2d..f2bd1ba 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -535,15 +535,18 @@ early on the agent host (`deploy/agent-host`), so the operator can keep material for `nisse-ops` there now: - The vault is `/srv/nisse/vault`. The operator owns its root and everything in it but `memory/`, the agents'; `infrastructure/` is the operator's material - for `nisse-ops`. SilverBullet mounts the vault and, over it, `memory/` - read-only, and nothing else of the host but its own data. + for `nisse-ops`. SilverBullet serves the vault as its one space (single-space + mode, mounted at `/data`), with `memory/` mounted over it read-only, and sees + nothing else of the host. - SilverBullet's version is pinned (the docker role updates images nightly). - Its accounts are its own, made in its setup wizard. + It has no accounts and no setup wizard; `SB_NAME` forces single-space mode, + so an empty vault can't boot the wizard. - Decided with the operator: behind their Caddy at `nisse-memory.dongar.net` for - HTTPS, inside garrett-hall.com only, with no Authelia (an internal instance; - SilverBullet's own login is enough), and its own instance rather than the operator's personal one, so - the vault stays one local copy with mounts enforcing its writers. On the host, - a `DOCKER-USER` rule lets only Caddy reach it. + HTTPS, reachable from anywhere through Authelia, which is the login (agents + never go through SilverBullet, so this changes only the operator's access). + Its own instance rather than the operator's personal one, so the vault stays + one local copy with mounts enforcing its writers. On the host, a + `DOCKER-USER` rule lets only Caddy reach it, so nothing gets around Authelia. ### Phase 6 — Production diff --git a/tests/deploy/test_agent_host.py b/tests/deploy/test_agent_host.py index ec47048..2aa7145 100644 --- a/tests/deploy/test_agent_host.py +++ b/tests/deploy/test_agent_host.py @@ -46,12 +46,14 @@ def test_the_agents_memory_is_read_only_and_the_rest_of_the_vault_is_not() -> No assert sorted(others) == [VAULT, f"{VAULT}/memory"] -def test_it_sees_nothing_of_the_host_but_the_vault_and_its_own_data() -> None: - assert sorted(source for source, _, _ in mounts()) == [ - "/srv/nisse/silverbullet", - VAULT, - f"{VAULT}/memory", - ] +def test_it_sees_nothing_of_the_host_but_the_vault() -> None: + assert sorted(source for source, _, _ in mounts()) == [VAULT, f"{VAULT}/memory"] + + +def test_the_vault_is_its_space() -> None: + # One space, the vault, at the data folder: no accounts or settings of its + # own outside the vault. + assert (VAULT, "/data", "rw") in mounts() def test_the_image_is_pinned_to_a_release() -> None: @@ -67,10 +69,13 @@ def test_no_secrets_in_the_compose_file() -> None: assert not [k for k in environment if re.search(r"USER|TOKEN|PASS|SECRET|KEY", k)] -def test_it_boots_into_accounts_and_the_vault_not_a_bare_space() -> None: - # SB_NAME alone switched 2.11 to single-space mode: no accounts, no setup - # wizard, and /data served in place of the vault. - assert set(silverbullet()["environment"]) == {"PUID", "PGID"} +def test_it_boots_one_space_never_the_setup_wizard() -> None: + # Any SB_* setting forces 2.11's single-space mode. Without one, an empty + # vault would boot the setup wizard, whose first visitor makes the admin. + environment = silverbullet()["environment"] + assert any(name.startswith("SB_") for name in environment) + # No credentials: the operator logs in through Authelia in front. + assert not {"SB_USER", "SB_AUTH"} & set(environment) def test_its_port_caddys_and_the_firewalls_agree() -> None: @@ -127,7 +132,10 @@ SCRIPT = HOST.parents[1] / "scripts" / "agent-host.sh" def _fake_host( - tmp_path: Path, *, memory_writable: bool, boot: str = "multi-space" + tmp_path: Path, + *, + memory_writable: bool, + boot: str = "single-space (folder is not empty)", ) -> dict[str, str]: """Commands standing in for the host's: ssh runs the command here, sudo just runs it, and docker reads all of stdin, as docker compose exec does.""" @@ -143,7 +151,7 @@ def _fake_host( 'case "$*" in\n' ' *"ps --status running"*) echo abc123 ;;\n' f' *" logs "*) echo "boot mode: {boot}" ;;\n' - f' *"/vault/memory/"*) exit {0 if memory_writable else 1} ;;\n' + f' *"/data/memory/"*) exit {0 if memory_writable else 1} ;;\n' "esac" ), } @@ -176,7 +184,7 @@ def test_the_check_runs_every_step_even_though_docker_reads_stdin( assert result.returncode == 0, result.stdout + result.stderr assert result.stdout.splitlines() == [ "ok: SilverBullet is running", - "ok: it keeps accounts (no single-space boot)", + "ok: it serves the vault as one space (no setup wizard)", "ok: it answers locally (200)", "ok: the operator's folders are writable", "ok: memory/ is read-only to SilverBullet", @@ -196,7 +204,9 @@ def test_a_writable_memory_folder_fails_the_check(tmp_path: Path) -> None: assert "all checks passed" not in result.stdout -def test_a_single_space_boot_fails_the_check(tmp_path: Path) -> None: - result = _check(_fake_host(tmp_path, memory_writable=False, boot="single-space")) +def test_a_setup_wizard_boot_fails_the_check(tmp_path: Path) -> None: + result = _check(_fake_host(tmp_path, memory_writable=False, boot="unconfigured")) assert result.returncode == 1 - assert "FAIL: it booted a single space with no accounts" in result.stdout + assert ( + "FAIL: it didn't boot one space: the setup wizard may be open" in result.stdout + )