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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
1d33d50907
commit
bf98445fcb
7 files changed
+70
-52
No files matched your search
+15
-13
@@ -7,9 +7,9 @@ later (M6.1).
|
|||||||
|
|
||||||
## What's here
|
## What's here
|
||||||
|
|
||||||
- `silverbullet/compose.yaml`: SilverBullet, its version pinned. It mounts the
|
- `silverbullet/compose.yaml`: SilverBullet, its version pinned, serving the
|
||||||
vault, with the agents' `memory/` read-only, and its own data folder.
|
vault as its one space, with the agents' `memory/` read-only. Nothing else of
|
||||||
Nothing else of the host.
|
the host. It has no accounts: the operator logs in through Authelia.
|
||||||
- `setup.sh`: makes the vault's folders, installs the stack under
|
- `setup.sh`: makes the vault's folders, installs the stack under
|
||||||
`/opt/stacks/silverbullet` and the firewall, and starts it. Safe to run again.
|
`/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
|
- `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
|
- `check.sh`: run on the host by `scripts/agent-host.sh check`; says "all
|
||||||
checks passed" only once every check has run.
|
checks passed" only once every check has run.
|
||||||
- `caddy/nisse-memory.dongar.net.caddy`: the site for the operator's Caddy on
|
- `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
|
`docker.garrett-hall.com`. HTTPS from Caddy's usual certificate,
|
||||||
garrett-hall.com only.
|
behind Authelia.
|
||||||
|
|
||||||
## The vault
|
## 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
|
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`
|
**Making `memory/` writable for a while** (for example, to upload material that
|
||||||
and reloads Caddy.
|
will be sorted into the agents' folders): on the host, add
|
||||||
2. **The operator runs SilverBullet's setup wizard at once,** at
|
`/opt/stacks/silverbullet/compose.override.yaml` that replaces the volumes with
|
||||||
`https://nisse-memory.dongar.net`. Until it's done, whoever opens the page first
|
the vault alone (`volumes: !override [/srv/nisse/vault:/data]`), give `memory/`
|
||||||
makes the administrator account.
|
to uid 1000, and `docker compose up -d`. `check` fails while it's in place.
|
||||||
- Account: the operator's, with a strong password.
|
To end it, delete the override and run `install` again, which gives `memory/`
|
||||||
- Space: folder `/vault`, address `nisse-memory.dongar.net`, path `/`.
|
back to root.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# The vault's SilverBullet, for the operator's Caddy on docker.garrett-hall.com
|
# 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.
|
# (/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 {
|
nisse-memory.dongar.net {
|
||||||
# Anyone outside the LAN gets nothing. Caddy still answers Let's Encrypt's
|
# Anyone outside the LAN gets nothing. Caddy still answers Let's Encrypt's
|
||||||
# checks, which it handles before the site's own routes.
|
# checks, which it handles before the site's own routes.
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ cd "$stack" || { echo "FAIL: no $stack"; exit 1; }
|
|||||||
[ "$(sudo docker compose ps --status running -q | wc -l)" -ge 1 ] \
|
[ "$(sudo docker compose ps --status running -q | wc -l)" -ge 1 ] \
|
||||||
&& ok "SilverBullet is running" || bad "SilverBullet isn't running"
|
&& 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
|
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
|
else
|
||||||
ok "it keeps accounts (no single-space boot)"
|
bad "it didn't boot one space: the setup wizard may be open"
|
||||||
fi
|
fi
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/)
|
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
|
case $code in 2??|3??) ok "it answers locally ($code)" ;; *) bad "it answers $code" ;; esac
|
||||||
probe=.nisse-probe-$$
|
probe=.nisse-probe-$$
|
||||||
sudo docker compose exec -T -u 1000 silverbullet \
|
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"
|
&& 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
|
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 "/vault/memory/$probe"
|
sudo docker compose exec -T silverbullet rm -f "/data/memory/$probe"
|
||||||
bad "memory/ is writable from SilverBullet, even as root in it"
|
bad "memory/ is writable from SilverBullet, even as root in it"
|
||||||
else
|
else
|
||||||
ok "memory/ is read-only to SilverBullet"
|
ok "memory/ is read-only to SilverBullet"
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ set -euo pipefail
|
|||||||
here=$(cd "$(dirname "$0")" && pwd)
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
|
||||||
# The only address allowed to reach SilverBullet: the operator's Caddy
|
# 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
|
CADDY_ADDR=192.168.6.13
|
||||||
SB_PORT=3000
|
SB_PORT=3000
|
||||||
SB_UID=1000 # the image's PUID/PGID: the operator's side of the vault
|
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/
|
# 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.
|
# is root's, and SilverBullet mounts it read-only besides.
|
||||||
install -d -m 0755 /srv/nisse
|
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
|
||||||
install -d -o "$SB_UID" -g "$SB_UID" -m 0755 /srv/nisse/vault/infrastructure
|
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
|
install -d -o root -g root -m 0755 /srv/nisse/vault/memory
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# SilverBullet on the agent host: the operator's view of the vault (L10, M5.4).
|
# 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
|
# Installed to /opt/stacks/silverbullet by ../setup.sh. No secrets here: it
|
||||||
# accounts are made in its setup wizard and kept in its data folder.
|
# 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:
|
services:
|
||||||
silverbullet:
|
silverbullet:
|
||||||
# Pinned: the docker role updates stacks' images nightly, and a pinned
|
# 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
|
image: ghcr.io/silverbulletmd/silverbullet:2.11.1-slim
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Only these. Any SB_* setting (even SB_NAME) boots a single space with
|
|
||||||
# no accounts, serving /data instead of the vault.
|
|
||||||
PUID: "1000"
|
PUID: "1000"
|
||||||
PGID: "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:
|
ports:
|
||||||
# IPv4 only; setup.sh's firewall lets only the operator's Caddy in.
|
# IPv4 only; setup.sh's firewall lets only the operator's Caddy in.
|
||||||
- "0.0.0.0:3000:3000"
|
- "0.0.0.0:3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
# Its own accounts and settings.
|
# The vault is the space: the operator writes everything in it...
|
||||||
- /srv/nisse/silverbullet:/data
|
- /srv/nisse/vault:/data
|
||||||
# The vault, the space's folder: the operator writes everything in it...
|
|
||||||
- /srv/nisse/vault:/vault
|
|
||||||
# ...except the agents' memory, which only the agents write (one writer
|
# ...except the agents' memory, which only the agents write (one writer
|
||||||
# per folder, enforced by the kernel).
|
# per folder, enforced by the kernel).
|
||||||
- /srv/nisse/vault/memory:/vault/memory:ro
|
- /srv/nisse/vault/memory:/data/memory:ro
|
||||||
logging:
|
logging:
|
||||||
driver: journald # logs go to the host's journal (L12)
|
driver: journald # logs go to the host's journal (L12)
|
||||||
@@ -535,15 +535,18 @@ early on the agent host (`deploy/agent-host`), so the operator can keep material
|
|||||||
for `nisse-ops` there now:
|
for `nisse-ops` there now:
|
||||||
- The vault is `/srv/nisse/vault`. The operator owns its root and everything in
|
- 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
|
it but `memory/`, the agents'; `infrastructure/` is the operator's material
|
||||||
for `nisse-ops`. SilverBullet mounts the vault and, over it, `memory/`
|
for `nisse-ops`. SilverBullet serves the vault as its one space (single-space
|
||||||
read-only, and nothing else of the host but its own data.
|
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).
|
- 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
|
- 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;
|
HTTPS, reachable from anywhere through Authelia, which is the login (agents
|
||||||
SilverBullet's own login is enough), and its own instance rather than the operator's personal one, so
|
never go through SilverBullet, so this changes only the operator's access).
|
||||||
the vault stays one local copy with mounts enforcing its writers. On the host,
|
Its own instance rather than the operator's personal one, so the vault stays
|
||||||
a `DOCKER-USER` rule lets only Caddy reach it.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
assert sorted(others) == [VAULT, f"{VAULT}/memory"]
|
||||||
|
|
||||||
|
|
||||||
def test_it_sees_nothing_of_the_host_but_the_vault_and_its_own_data() -> None:
|
def test_it_sees_nothing_of_the_host_but_the_vault() -> None:
|
||||||
assert sorted(source for source, _, _ in mounts()) == [
|
assert sorted(source for source, _, _ in mounts()) == [VAULT, f"{VAULT}/memory"]
|
||||||
"/srv/nisse/silverbullet",
|
|
||||||
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:
|
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)]
|
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:
|
def test_it_boots_one_space_never_the_setup_wizard() -> None:
|
||||||
# SB_NAME alone switched 2.11 to single-space mode: no accounts, no setup
|
# Any SB_* setting forces 2.11's single-space mode. Without one, an empty
|
||||||
# wizard, and /data served in place of the vault.
|
# vault would boot the setup wizard, whose first visitor makes the admin.
|
||||||
assert set(silverbullet()["environment"]) == {"PUID", "PGID"}
|
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:
|
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(
|
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]:
|
) -> dict[str, str]:
|
||||||
"""Commands standing in for the host's: ssh runs the command here, sudo
|
"""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."""
|
just runs it, and docker reads all of stdin, as docker compose exec does."""
|
||||||
@@ -143,7 +151,7 @@ def _fake_host(
|
|||||||
'case "$*" in\n'
|
'case "$*" in\n'
|
||||||
' *"ps --status running"*) echo abc123 ;;\n'
|
' *"ps --status running"*) echo abc123 ;;\n'
|
||||||
f' *" logs "*) echo "boot mode: {boot}" ;;\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"
|
"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.returncode == 0, result.stdout + result.stderr
|
||||||
assert result.stdout.splitlines() == [
|
assert result.stdout.splitlines() == [
|
||||||
"ok: SilverBullet is running",
|
"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: it answers locally (200)",
|
||||||
"ok: the operator's folders are writable",
|
"ok: the operator's folders are writable",
|
||||||
"ok: memory/ is read-only to SilverBullet",
|
"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
|
assert "all checks passed" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_a_single_space_boot_fails_the_check(tmp_path: Path) -> None:
|
def test_a_setup_wizard_boot_fails_the_check(tmp_path: Path) -> None:
|
||||||
result = _check(_fake_host(tmp_path, memory_writable=False, boot="single-space"))
|
result = _check(_fake_host(tmp_path, memory_writable=False, boot="unconfigured"))
|
||||||
assert result.returncode == 1
|
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
|
||||||
|
)
|
||||||
Reference in new issue
Block a user