server-up/tests/test_verbindingsapps.py
Ramon ec49337167
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 3m18s
feat(apps): WireGuard, Tailscale, Cloudflare Tunnel en Proton Mail Bridge
De catalogus had niets om een machine elders bij je eigen netwerk te laten
komen, en niets om Proton Mail bruikbaar te maken voor apps die geen Proton
spreken.

- wireguard: client (eigen wg0.conf in wg_confs) of server, met NET_ADMIN,
  SYS_MODULE, de kernelmodules van de host en src_valid_mark.
- tailscale: network_mode host zodat de tunnel voor de hele machine geldt,
  met bewaarde staat en optionele subnetroutes.
- cloudflared: publiceert zelf geen poorten en werkt zichzelf niet bij.
- protonmail-bridge: SMTP op 1026 zodat hij naast Mailpit past; het sjabloon
  legt de eenmalige aanmelding via de CLI uit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:29:47 +02:00

115 lines
4.9 KiB
Python

"""De apps waarmee je een machine met een ander netwerk verbindt.
Deze vier hebben iets gemeen dat je in een formulier niet ziet: ze werken alleen
met rechten die een gewone container niet heeft (NET_ADMIN, /dev/net/tun), of ze
vragen een eenmalige handeling in de terminal. Zonder dat staat er een app die
start en niets doet.
"""
import json
import sys
import tempfile
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from core import boilerplates as bp
VERBINDINGSAPPS = ["cloudflared", "protonmail-bridge", "tailscale", "wireguard"]
def _tpl(app: str) -> dict:
return json.loads((WORTEL / "apps" / app / "template.json").read_text("utf-8"))
def _render(app: str) -> dict:
pad = WORTEL / "apps" / app
velden = bp.fields(pad)
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
for f in velden:
if f.get("required") and not waarden.get(f["name"]):
waarden[f["name"]] = "testwaarde12345678901234567890ab"
with tempfile.TemporaryDirectory() as t:
bp.render_to_dir(pad, Path(t), waarden)
return yaml.safe_load((Path(t) / "docker-compose.yml").read_text())
@pytest.mark.parametrize("app", VERBINDINGSAPPS)
def test_staan_in_de_catalogus_en_draaien_op_een_pi(app):
meta = _tpl(app)["metadata"]
assert {"amd64", "arm64"} <= set(meta["architectures"]), \
"juist op een Pi wil je een tunnel draaien"
assert "netwerk" in meta["categories"]
def test_wireguard_krijgt_de_rechten_die_een_tunnel_nodig_heeft():
"""Zonder NET_ADMIN en de kernelmodules start wg-quick niet, en zonder
src_valid_mark valt het verkeer stil zodra alles door de tunnel gaat."""
svc = _render("wireguard")["services"]["wireguard"]
assert "NET_ADMIN" in svc["cap_add"]
assert "SYS_MODULE" in svc["cap_add"]
assert "/lib/modules:/lib/modules:ro" in svc["volumes"]
assert "net.ipv4.conf.all.src_valid_mark=1" in svc["sysctls"]
assert any(str(p).endswith("51820/udp") for p in svc["ports"])
def test_wireguard_legt_uit_waar_je_eigen_configuratie_heen_moet():
"""De clientrol is de reden dat deze app er is; die werkt alleen als je
weet dat je wg0.conf in wg_confs hoort."""
bron = (WORTEL / "apps" / "wireguard" / "files" / "compose.yaml").read_text("utf-8")
assert "wg_confs" in bron
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "wireguard")}
assert "wg_confs" in velden["appdata_dir"]["description"]
def test_tailscale_draait_op_de_host_en_onthoudt_zijn_aanmelding():
"""In een eigen netwerknamespace bereikt de tunnel alleen die container;
en zonder bewaarde staat moet je na elke herstart opnieuw koppelen."""
svc = _render("tailscale")["services"]["tailscale"]
assert svc["network_mode"] == "host"
assert "NET_ADMIN" in svc["cap_add"]
assert any("/var/lib/tailscale" in v for v in svc["volumes"])
assert "/dev/net/tun:/dev/net/tun" in svc["volumes"]
def test_tailscale_vraagt_om_een_auth_key_als_geheim():
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "tailscale")}
assert velden["auth_key"]["secret"] is True
assert velden["auth_key"]["required"] is True
# De routes zijn optioneel: de meeste mensen willen alleen deze machine.
assert velden["routes"]["required"] is False
def test_cloudflared_zet_geen_poort_open():
"""Dat is het hele punt van een tunnel; publiceerde hij poorten, dan kon je
net zo goed een reverse proxy nemen."""
svc = _render("cloudflared")["services"]["cloudflared"]
assert "ports" not in svc
assert "--no-autoupdate" in svc["command"], \
"een container die zichzelf bijwerkt, wijkt af van wat je uitrolde"
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "cloudflared")}
assert velden["tunnel_token"]["secret"] is True
def test_protonmail_bridge_botst_niet_met_mailpit():
"""Allebei praten ze SMTP en je kunt ze prima naast elkaar draaien: de een
vangt testmail op, de ander verstuurt echte mail."""
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "protonmail-bridge")}
mailpit = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "mailpit")}
smtp_poorten = {f["default"] for f in mailpit.values()
if f.get("type") == "int"}
assert velden["port_smtp"]["default"] not in smtp_poorten
def test_protonmail_bridge_vertelt_over_de_eenmalige_aanmelding():
"""Die kan niet via een formulier: Proton vraagt om een tweede factor. Staat
het er niet bij, dan draait de bridge zonder account en snapt niemand
waarom er geen mail doorkomt."""
meta = _tpl("protonmail-bridge")["metadata"]
bron = (WORTEL / "apps" / "protonmail-bridge" / "files" / "compose.yaml").read_text("utf-8")
for tekst in (meta["architecture_note"], bron):
assert "--cli" in tekst
assert "docker exec" in meta["architecture_note"]