SB_NAME alone put SilverBullet 2.11 in single-space mode: no accounts, no setup wizard, and its /data folder served instead of the vault. The compose file sets only PUID and PGID now, a test holds it to that, and the host check fails on a single-space boot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
203 lines
7.2 KiB
Python
203 lines
7.2 KiB
Python
"""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_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_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
|
|
|
|
|
|
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 = "multi-space"
|
|
) -> 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' *"/vault/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 keeps accounts (no single-space boot)",
|
|
"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_single_space_boot_fails_the_check(tmp_path: Path) -> None:
|
|
result = _check(_fake_host(tmp_path, memory_writable=False, boot="single-space"))
|
|
assert result.returncode == 1
|
|
assert "FAIL: it booted a single space with no accounts" in result.stdout
|