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