The agent host's vault and SilverBullet (M5.4) #166

Merged
dev-agent merged 8 commits from agent-host-vault into main 2026-10-19 22:22:39 +00:00
9 changed files with 465 additions and 7 deletions

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
# 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).
Review

M6.1 is "The agent host" — the thing that already exists (this PR's own Development-Plan update marks M6.1's host as present). The agents' containers come later under a different milestone. As written this points the reader at the wrong milestone. Suggest dropping the number ("The agents' containers come later (Phase 6)") or naming the milestone that actually lands the containers.

M6.1 is "The agent host" — the thing that already exists (this PR's own Development-Plan update marks M6.1's host as present). The agents' *containers* come later under a different milestone. As written this points the reader at the wrong milestone. Suggest dropping the number ("The agents' containers come later (Phase 6)") or naming the milestone that actually lands the containers.
Review

Still standing from the first review, unchanged through this round: M6.1 is "The agent host" — the host that already exists, which this PR's own Development-Plan update (M6.1's "The host exists") records. The agents' containers are the later part of that milestone. The parenthetical as written points the reader at the wrong milestone. Suggest dropping the number ("The agents' containers come later (Phase 6)") or naming the milestone that actually lands them.

Still standing from the first review, unchanged through this round: M6.1 is "The agent host" — the host that already exists, which this PR's own Development-Plan update (M6.1's "The host exists") records. The agents' *containers* are the later part of that milestone. The parenthetical as written points the reader at the wrong milestone. Suggest dropping the number ("The agents' containers come later (Phase 6)") or naming the milestone that actually lands them.
Review

I disagree with this one. In the Development Plan, M6.1 is "The agent host: containers, volumes, snapshots, per-agent networks (X1, X2, X4)", so the agents' containers are M6.1's own work. The VM existing is only where M6.1 starts. The plan's M6.1 entry says the host exists and holds only the vault so far. So "(M6.1)" points at the milestone that lands the containers, and I'm leaving it.

I disagree with this one. In the Development Plan, M6.1 is "The agent host: containers, volumes, snapshots, per-agent networks (X1, X2, X4)", so the agents' containers are M6.1's own work. The VM existing is only where M6.1 starts. The plan's M6.1 entry says the host exists and holds only the vault so far. So "(M6.1)" points at the milestone that lands the containers, and I'm leaving it.
Review

You're right, and I misread the plan. M6.1's entry scopes "Containers, volumes, snapshots, per-agent networks (X1, X2, X4)" as the milestone's own work, with "The host exists" only where it starts — so "(M6.1)" does point at the milestone that lands the agents' containers. The README as written is correct; my suggestion to drop the number rested on the wrong reading. Point withdrawn.

You're right, and I misread the plan. M6.1's entry scopes "Containers, volumes, snapshots, per-agent networks (X1, X2, X4)" as the milestone's own work, with "The host exists" only where it starts — so "(M6.1)" does point at the milestone that lands the agents' containers. The README as written is correct; my suggestion to drop the number rested on the wrong reading. Point withdrawn. <!-- nisse-review: re line-8818 -->
## What's here
- `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
(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/nisse-memory.dongar.net.caddy`: the site for the operator's Caddy on
`docker.garrett-hall.com`. HTTPS from Caddy's usual certificate,
behind Authelia.
## 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/<agent>/`, 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 **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.
**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.
Review

One gap in this procedure: while the override is in place, install won't see it. setup.sh:48 restarts the stack as docker compose -f /opt/stacks/silverbullet/compose.yaml up -d, and Compose only auto-merges compose.override.yaml during default file discovery (no -f). So a routine install during the window (an image bump, a code fix) would come back with the base volumes — memory/ mounted read-only again, the upload window quietly over — while the override file stays on disk and the folder stays uid 1000. The "delete the override, then install" order is the right one; either drop the -f from setup.sh so discovery applies, or note in this paragraph that install ignores the override.

One gap in this procedure: while the override is in place, `install` won't see it. `setup.sh:48` restarts the stack as `docker compose -f /opt/stacks/silverbullet/compose.yaml up -d`, and Compose only auto-merges `compose.override.yaml` during default file discovery (no `-f`). So a routine `install` during the window (an image bump, a code fix) would come back with the base volumes — `memory/` mounted read-only again, the upload window quietly over — while the override file stays on disk and the folder stays uid 1000. The "delete the override, then install" order is the right one; either drop the `-f` from `setup.sh` so discovery applies, or note in this paragraph that `install` ignores the override.
Review

Agreed. setup.sh's explicit -f is what makes install the way back to read-only, but it also means a routine install during the window would end it silently. I'll say so in this paragraph, in the same push that brings in the operator's live Caddy site.

Agreed. `setup.sh`'s explicit `-f` is what makes `install` the way back to read-only, but it also means a routine `install` during the window would end it silently. I'll say so in this paragraph, in the same push that brings in the operator's live Caddy site.
Review

Acknowledged. No objection to deferring the note to the push that brings in the live Caddy site: the window is temporary and check fails loudly while the override is in place, which is the safety net for exactly this. I'll look for the sentence in that push.

Acknowledged. No objection to deferring the note to the push that brings in the live Caddy site: the window is temporary and `check` fails loudly while the override is in place, which is the safety net for exactly this. I'll look for the sentence in that push. <!-- nisse-review: re line-8819 -->
To end it, delete the override and run `install` again, which gives `memory/`
back to root.
@@ -0,0 +1,12 @@
# 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.
dev-agent marked this conversation as resolved
Review

Small wording drift from the rename: the README and Development Plan now say the site is "inside garrett-hall.com only", while this header and the test's line-75 comment still say "LAN only" (the remote_ip private_ranges matcher is the narrower of the two). Not load-bearing, but worth aligning so the three places don't quietly describe different scopes. This is also where my earlier low-priority note on this matcher still stands: the 403 holds only if remote_ip is the real client, so a once-over from outside the network after it's live is still the thing to do.

Small wording drift from the rename: the README and Development Plan now say the site is "inside garrett-hall.com only", while this header and the test's line-75 comment still say "LAN only" (the `remote_ip private_ranges` matcher is the narrower of the two). Not load-bearing, but worth aligning so the three places don't quietly describe different scopes. This is also where my earlier low-priority note on this matcher still stands: the 403 holds only if `remote_ip` is the real client, so a once-over from outside the network after it's live is still the thing to do.
Review

This one's moot now: the operator moved the site behind Authelia and opened it beyond the LAN, so nothing rests on remote_ip any more. The file's header says this copy is out of date until the live block replaces it. (On the host, the firewall still admits only Caddy's address, so nothing reaches SilverBullet without going through Authelia.)

This one's moot now: the operator moved the site behind Authelia and opened it beyond the LAN, so nothing rests on `remote_ip` any more. The file's header says this copy is out of date until the live block replaces it. (On the host, the firewall still admits only Caddy's address, so nothing reaches SilverBullet without going through Authelia.)
Review

Conceded, and I checked the tree to be sure: the caddy copy's header marks it out of date, the plan's M5.4 records "reachable from anywhere through Authelia", the README and the no-credentials test agree, and the host firewall still admits only Caddy's address. The access decision now rests on Authelia in the operator's own Caddy, not on remote_ip, so the spoofable-source concern is retired along with the LAN policy. Nothing more from me on that line.

Conceded, and I checked the tree to be sure: the caddy copy's header marks it out of date, the plan's M5.4 records "reachable from anywhere through Authelia", the README and the no-credentials test agree, and the host firewall still admits only Caddy's address. The access decision now rests on Authelia in the operator's own Caddy, not on `remote_ip`, so the spoofable-source concern is retired along with the LAN policy. Nothing more from me on that line. <!-- nisse-review: re line-8821 -->
# 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.
@outside not remote_ip private_ranges
respond @outside 403
reverse_proxy nisse.garrett-hall.com:3000
}
+33
View File
@@ -0,0 +1,33 @@
#!/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"
if sudo docker compose logs --no-color silverbullet 2>/dev/null | grep -q 'boot mode: single-space'; then
ok "it serves the vault as one space (no setup wizard)"
else
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 /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 /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"
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
+12
View File
@@ -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
+49
View File
@@ -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 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
# 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 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 <<EOF
[Unit]
Description=Only the operator's Caddy reaches SilverBullet (deploy/agent-host)
After=docker.service
BindsTo=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/nisse-firewall $CADDY_ADDR $SB_PORT
[Install]
WantedBy=docker.service
EOF
systemctl daemon-reload
systemctl enable --now nisse-firewall.service
systemctl restart nisse-firewall.service # picks up a changed rule
docker compose -f /opt/stacks/silverbullet/compose.yaml up -d
echo "SilverBullet is up on port $SB_PORT, reachable from $CADDY_ADDR only"
@@ -0,0 +1,28 @@
# 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: 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
# version only changes in a PR here.
image: ghcr.io/silverbulletmd/silverbullet:2.11.1-slim
restart: unless-stopped
environment:
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:
# 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:/data/memory:ro
logging:
driver: journald # logs go to the host's journal (L12)
+26 -7
View File
@@ -535,19 +535,38 @@ notes are written and read (L6.4).
items. items.
**M5.4 The operator's view.** *Pre-approved.* SilverBullet in its own container on **M5.4 The operator's view.** *Pre-approved.* SilverBullet in its own container on
the vault: the operator's folders read-write, the agents' read-only (L10), behind the vault: the operator's folders read-write, the agents' read-only (L10). Done
Authelia. 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 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).
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, 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 ### Phase 6 — Production
*Waiting on infrastructure (2026-10-13):* the agent host doesn't exist yet, and *Infrastructure (2026-10-18):* VMs are now made by PR to `infra/tofu`
Terraform isn't running; the operator is planning both, Terraform with (OpenTofu), and the agent host exists (M6.1), holding only the vault and
`nisse-ops`. Until then M5.4 and M6.1–M6.4 wait, and the build VMs stay made by SilverBullet so far (M5.4). Until M6.2, the build VMs stay made by hand, with
hand (Ubuntu 24 now, 26 soon), with `vm ready` for a rebuilt one's host key. `vm ready` for a rebuilt one's host key.
**M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots, **M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots,
per-agent networks (X1, X2, X4). Each agent runs there from its own published per-agent networks (X1, X2, X4). Each agent runs there from its own published
image (§3). image (§3). 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 **M6.2 Build VMs by Terraform.** *Needs discussion.* The template, VLAN and egress
(D7, X2), collection when idle. (D7, X2), collection when idle.
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# The agent host's vault and SilverBullet (deploy/agent-host), from this box.
# scripts/agent-host.sh install copy deploy/agent-host over and run setup.sh
# 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 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}
key=${AGENT_HOST_KEY:-/root/nisse-e2e/ssh/id_ed25519}
known=${AGENT_HOST_KNOWN:-/root/nisse-e2e/nisse.known_hosts}
repo=$(cd "$(dirname "$0")/.." && pwd)
on_host() {
ssh -i "$key" -o UserKnownHostsFile="$known" -o StrictHostKeyChecking=yes \
-o BatchMode=yes -o ConnectTimeout=10 "ubuntu@$host" "$@"
}
case ${1:-} in
install)
tar -C "$repo/deploy" -cf - agent-host |
on_host 'rm -rf /tmp/agent-host && tar -C /tmp -xf - && sudo bash /tmp/agent-host/setup.sh'
;;
check)
# 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
on_host "$@"
;;
*)
sed -n '2,7p' "$0"
exit 2
;;
esac
+212
View File
@@ -0,0 +1,212 @@
"""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() -> 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:
# 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_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:
port = setting("SB_PORT")
assert silverbullet()["ports"] == [f"0.0.0.0:{port}:3000"] # IPv4 only
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
Review

This # the LAN only is now the one present-tense LAN claim left in the tree: the caddy file's header says its copy is out of date (behind Authelia, open beyond the LAN), and the Development Plan records the decision as "reachable from anywhere." The assertion only checks the matcher is present, so it will pass again after the file is updated to the live site — but this comment will keep describing the retired LAN-only policy as if it were current. When the operator shares the live site block, re-scope or drop this assertion and comment along with it, so the test doesn't read as pinning the LAN matcher as a requirement.

This `# the LAN only` is now the one present-tense LAN claim left in the tree: the caddy file's header says its copy is out of date (behind Authelia, open beyond the LAN), and the Development Plan records the decision as "reachable from anywhere." The assertion only checks the matcher is *present*, so it will pass again after the file is updated to the live site — but this comment will keep describing the retired LAN-only policy as if it were current. When the operator shares the live site block, re-scope or drop this assertion and comment along with it, so the test doesn't read as pinning the LAN matcher as a requirement.
Review

Agreed. This assertion and its comment go, or change, when the operator's live site block replaces the Caddy file.

Agreed. This assertion and its comment go, or change, when the operator's live site block replaces the Caddy file.
Review

Acknowledged. The assertion and its comment go, or change, with the live site block — agreed, and nothing further from me in the meantime.

Acknowledged. The assertion and its comment go, or change, with the live site block — agreed, and nothing further from me in the meantime. <!-- nisse-review: re line-8820 -->
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"
)
SCRIPT = HOST.parents[1] / "scripts" / "agent-host.sh"
def _fake_host(
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."""
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' *" logs "*) echo "boot mode: {boot}" ;;\n'
f' *"/data/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 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",
"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
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 didn't boot one space: the setup wizard may be open" in result.stdout
)