The agent host's vault and SilverBullet (M5.4) #166
No files matched your search
@@ -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/<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:
|
||||
|
||||
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 `/`.
|
||||
@@ -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
|
||||
|
nisse-review
commented
This "LAN only" guarantee holds only if This "LAN only" guarantee holds only if `remote_ip` is the real client. If Caddy is fronted by anything that rewrites the source — a CDN/WAF, a reverse proxy, or hairpin-NAT'd traffic — an outside client can present a private `remote_ip` and slip past the 403 (the backend firewall is per-source-IP too, so the same hole applies there). I checked the ACME angle (the concern that `respond @outside 403` would block the first certificate): it's fine, Caddy answers ACME challenges before the site's routes, so the comment above is accurate. Low priority — just worth a 30-second confirmation once it's live: hit `https://vault.dongar.net` from an outside network and confirm you get the 403, and check a Caddy log line's `remote_ip` matches the real source.
|
||||
respond @outside 403
|
||||
|
||||
reverse_proxy nisse-host.garrett-hall.com:3000
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <<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,26 @@
|
||||
# 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.
|
||||
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"
|
||||
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
|
||||
# ...except the agents' memory, which only the agents write (one writer
|
||||
# per folder, enforced by the kernel).
|
||||
- /srv/nisse/vault/memory:/vault/memory:ro
|
||||
logging:
|
||||
driver: journald # logs go to the host's journal (L12)
|
||||
@@ -530,18 +530,33 @@ notes are written and read (L6.4).
|
||||
items.
|
||||
|
||||
**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
|
||||
Authelia.
|
||||
the vault: the operator's folders read-write, the agents' read-only (L10). Done
|
||||
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.
|
||||
- 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
|
||||
the vault stays one local copy with mounts enforcing its writers. On the host,
|
||||
a `DOCKER-USER` rule lets only Caddy reach it.
|
||||
|
||||
### Phase 6 — Production
|
||||
|
||||
*Waiting on infrastructure (2026-10-13):* the agent host doesn't exist yet, and
|
||||
Terraform isn't running; the operator is planning both, Terraform with
|
||||
`nisse-ops`. Until then M5.4 and M6.1–M6.4 wait, and the build VMs stay made by
|
||||
hand (Ubuntu 24 now, 26 soon), with `vm ready` for a rebuilt one's host key.
|
||||
*Infrastructure (2026-10-18):* VMs are now made by PR to `infra/tofu`
|
||||
(OpenTofu), and the agent host exists (M6.1), holding only the vault and
|
||||
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).
|
||||
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).
|
||||
|
||||
**M6.2 Build VMs by Terraform.** *Needs discussion.* The template, VLAN and egress
|
||||
(D7, X2), collection when idle.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/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 the host keys bot-ansible posted (~/bin/tofu-wait.sh saved them).
|
||||
set -euo pipefail
|
||||
|
||||
host=${AGENT_HOST:-nisse-host.garrett-hall.com}
|
||||
key=${AGENT_HOST_KEY:-/root/nisse-e2e/ssh/id_ed25519}
|
||||
known=${AGENT_HOST_KNOWN:-/root/nisse-e2e/nisse-host.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)
|
||||
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
|
||||
;;
|
||||
ssh)
|
||||
shift
|
||||
on_host "$@"
|
||||
;;
|
||||
*)
|
||||
sed -n '2,7p' "$0"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -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
|
||||
|
nisse-review
commented
This 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.
dev-agent
commented
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.
nisse-review
commented
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 -->
|
||||
)
|
||||
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"
|
||||
)
|
||||
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.
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.
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.
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.