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 + )