feat(apps): WireGuard, Tailscale, Cloudflare Tunnel en Proton Mail Bridge
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 3m18s

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
This commit is contained in:
Ramon 2026-08-07 15:29:47 +02:00
parent 5068997599
commit ec49337167
11 changed files with 520 additions and 3 deletions

View file

@ -1,4 +1,24 @@
# v0.8.30-beta — De updater neemt genoegen met het adres uit je adresbalk
# v0.8.30-beta — Verbinden met een netwerk, en de updater neemt genoegen met het adres uit je adresbalk
Vier nieuwe apps voor iets wat de catalogus nog niet had: een machine ergens
anders bij je eigen netwerk laten komen, en Proton Mail bruikbaar maken voor
apps die geen Proton spreken.
- **WireGuard.** Verbind deze machine met een ander netwerk door je eigen
`wg0.conf` in de map `wg_confs` te zetten, of laat hem zelf een server met
clientprofielen aanmaken. Krijgt de rechten die een tunnel nodig heeft:
`NET_ADMIN`, de kernelmodules van de host en `src_valid_mark`.
- **Tailscale.** Zet de machine in je eigen tailnet, met `network_mode: host`
zodat de tunnel voor de hele machine geldt. Optioneel als subnetrouter, zodat
je bij je hele thuisnetwerk kunt.
- **Cloudflare Tunnel.** Een app van buitenaf bereikbaar zonder een poort open
te zetten; publiceert zelf niets, want dat is het hele punt.
- **Proton Mail Bridge.** Vertaalt je Proton-account naar gewone IMAP en SMTP.
Staat op poort 1026 in plaats van 1025, zodat hij naast Mailpit past. Het
sjabloon vertelt hoe je de eenmalige aanmelding doet — die kan niet via een
formulier, want Proton vraagt om een tweede factor.
En de updater is niet meer kieskeurig over het adres dat je invult.
Wie in Forgejo naar zijn releases keek en dat adres in de instellingen plakte,
kreeg een webpagina in plaats van JSON — en als enige melding

View file

@ -0,0 +1,12 @@
# De tunnel belt zelf naar buiten naar Cloudflare, dus er hoeft geen poort open
# in je router en je publieke IP blijft onzichtbaar. Wat er door de tunnel
# bereikbaar is, bepaal je in het Cloudflare-dashboard bij de tunnel zelf.
services:
<< service_name >>:
image: cloudflare/cloudflared:latest
container_name: << service_name >>
command: tunnel --no-autoupdate run --token << tunnel_token >>
environment:
- TZ=<< timezone >>
- TUNNEL_METRICS=0.0.0.0:2000
restart: unless-stopped

View file

@ -0,0 +1,31 @@
{
"kind": "compose",
"metadata": {
"name": "Cloudflare Tunnel",
"description": "Maak een app van buitenaf bereikbaar zonder een poort open te zetten. De tunnel belt zelf naar Cloudflare; je publieke IP blijft onzichtbaar en er komt niets ongevraagd binnen.",
"tags": ["tunnel", "proxy", "op afstand", "netwerk", "cloudflare"],
"icon": {"provider": "selfhst", "id": "cloudflare"},
"version": {"name": "latest"},
"categories": ["netwerk", "beveiliging"],
"architectures": ["amd64", "arm64"]
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": "cloudflared", "required": true},
{"name": "timezone", "type": "str", "title": "Tijdzone",
"default": "Europe/Amsterdam", "required": true}
]
},
{
"title": "Toegang",
"items": [
{"name": "tunnel_token", "type": "str", "title": "Tunneltoken",
"default": "", "required": true, "secret": true, "eigen_invoer": true,
"help": "Maak in het Zero Trust-dashboard van Cloudflare een tunnel aan; bij 'Docker' staat het token in het voorbeeldcommando. Koppel daar ook welke app op welk adres uitkomt."}
]
}
]
}

View file

@ -0,0 +1,24 @@
# De bridge vertaalt je Proton Mail-account naar gewone IMAP en SMTP, zodat
# apps die geen Proton spreken er toch bij kunnen.
#
# Eenmalig aanmelden gebeurt in de container zelf; dat kan niet via een
# formulier omdat Proton om een tweede factor kan vragen:
#
# docker exec -it << service_name >> /protonmail/bridge --cli
# login — je Proton-account, inclusief tweede factor
# info — toont de gebruikersnaam en het wachtwoord voor je mailclient
#
# Let op: het wachtwoord dat 'info' toont is een bridge-wachtwoord, niet je
# Proton-wachtwoord. Dat vul je in bij de app die mail moet versturen.
services:
<< service_name >>:
image: shenxn/protonmail-bridge:latest
container_name: << service_name >>
environment:
- TZ=<< timezone >>
volumes:
- << appdata_dir >>/<< service_name >>:/root
ports:
- "<< port_imap >>:143"
- "<< port_smtp >>:25"
restart: unless-stopped

View file

@ -0,0 +1,75 @@
{
"kind": "compose",
"metadata": {
"name": "Proton Mail Bridge",
"description": "Vertaalt je Proton Mail-account naar gewone IMAP en SMTP, zodat apps die geen Proton spreken toch mail kunnen lezen en versturen. Denk aan meldingen uit Uptime Kuma, Vaultwarden of Paperless.",
"tags": [
"mail",
"smtp",
"imap",
"proton",
"meldingen"
],
"icon": {
"provider": "selfhst",
"id": "proton-mail"
},
"version": {
"name": "latest"
},
"categories": [
"communicatie",
"netwerk"
],
"architectures": [
"amd64",
"arm64"
],
"architecture_note": "Aanmelden gebeurt eenmalig in de container zelf, omdat Proton om een tweede factor kan vragen: docker exec -it protonmail-bridge /protonmail/bridge --cli, dan 'login' en daarna 'info' voor de gegevens voor je mailclient."
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "protonmail-bridge",
"required": true
},
{
"name": "port_smtp",
"type": "int",
"title": "SMTP-poort",
"default": 1026,
"required": true,
"description": "Hierop laat je andere apps hun mail aanbieden. Standaard 1026 en niet 1025, omdat Mailpit die al gebruikt en je ze prima naast elkaar kunt draaien."
},
{
"name": "port_imap",
"type": "int",
"title": "IMAP-poort",
"default": 1143,
"required": true,
"description": "Alleen nodig als een app je mail moet kunnen lézen."
},
{
"name": "appdata_dir",
"type": "str",
"title": "Appdata-map",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Hier blijft de aanmelding staan, zodat je na een herstart niet opnieuw hoeft in te loggen."
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
}
]
}

View file

@ -0,0 +1,23 @@
# Tailscale bouwt zijn eigen netwerk over bestaande verbindingen heen: geen
# poort open in je router, geen vaste adressen. Deze container brengt de hele
# host in dat netwerk als je hem als subnetrouter laat draaien.
services:
<< service_name >>:
image: tailscale/tailscale:latest
container_name: << service_name >>
hostname: << machine_naam >>
environment:
- TS_AUTHKEY=<< auth_key >>
- TS_STATE_DIR=/var/lib/tailscale
- TS_EXTRA_ARGS=<< extra_args >>
- TS_ROUTES=<< routes >>
- TS_ACCEPT_DNS=<< accept_dns >>
- TS_USERSPACE=false
volumes:
- << appdata_dir >>/<< service_name >>:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: host
restart: unless-stopped

View file

@ -0,0 +1,50 @@
{
"kind": "compose",
"metadata": {
"name": "Tailscale",
"description": "Zet deze machine in je eigen Tailscale-netwerk, zodat je er vanaf elke locatie bij kunt zonder poorten open te zetten. Als subnetrouter maakt hij ook de rest van je thuisnetwerk bereikbaar.",
"tags": ["vpn", "wireguard", "op afstand", "netwerk", "mesh"],
"icon": {"provider": "selfhst", "id": "tailscale"},
"version": {"name": "latest"},
"categories": ["netwerk", "beveiliging"],
"architectures": ["amd64", "arm64"],
"architecture_note": "Draait met network_mode: host, zodat de tunnel voor de hele machine geldt en niet alleen voor deze container."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": "tailscale", "required": true},
{"name": "machine_naam", "type": "str", "title": "Naam in je netwerk",
"default": "server-up", "required": true,
"description": "Onder deze naam verschijnt de machine in je tailnet."},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": "/opt/serverup/appdata", "required": true,
"description": "Hier blijft de aanmelding staan, zodat je na een herstart niet opnieuw hoeft te koppelen."}
]
},
{
"title": "Toegang",
"items": [
{"name": "auth_key", "type": "str", "title": "Auth key",
"default": "", "required": true, "secret": true, "eigen_invoer": true,
"help": "Maak er een aan in de Tailscale-console onder Settings → Keys. Een reusable key is handig als je meerdere machines aansluit."}
]
},
{
"title": "Netwerk",
"items": [
{"name": "routes", "type": "str", "title": "Netwerken achter deze machine",
"default": "", "required": false,
"description": "Bijvoorbeeld 10.0.20.0/24 om je hele thuisnetwerk bereikbaar te maken. Keur de route daarna goed in de Tailscale-console. Leeg = alleen deze machine."},
{"name": "accept_dns", "type": "str", "title": "DNS van Tailscale gebruiken",
"default": "false", "required": true, "advanced": true,
"description": "'true' laat deze machine de DNS-instellingen uit je tailnet volgen — handig voor split-DNS naar je eigen domein, maar het overschrijft de resolver van de host."},
{"name": "extra_args", "type": "str", "title": "Extra opties",
"default": "--accept-routes", "required": false, "advanced": true,
"description": "Wordt meegegeven aan het commando 'tailscale up'."}
]
}
]
}

View file

@ -0,0 +1,29 @@
# Twee rollen in één image. Zet je eigen `wg0.conf` in de map wg_confs, dan
# verbindt deze container met dát netwerk (client). Laat je die map leeg en vul
# je de servervelden in, dan maakt hij zelf een server met clientprofielen aan.
services:
<< service_name >>:
image: lscr.io/linuxserver/wireguard:latest
container_name: << service_name >>
environment:
- PUID=<< puid >>
- PGID=<< pgid >>
- TZ=<< timezone >>
# Alleen voor de serverrol; bij een eigen wg0.conf doen ze niets.
- SERVERURL=<< server_url >>
- SERVERPORT=<< port >>
- PEERS=<< peers >>
- PEERDNS=<< peer_dns >>
- INTERNAL_SUBNET=<< internal_subnet >>
- ALLOWEDIPS=<< allowed_ips >>
volumes:
- << appdata_dir >>/<< service_name >>:/config
- /lib/modules:/lib/modules:ro
ports:
- "<< port >>:51820/udp"
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped

View file

@ -0,0 +1,138 @@
{
"kind": "compose",
"metadata": {
"name": "WireGuard",
"description": "Verbind deze machine met een ander netwerk via WireGuard, of draai er zelf een VPN-server mee. Zet je eigen wg0.conf in de configuratiemap voor de clientrol; vul de servervelden in om profielen te laten aanmaken.",
"tags": [
"vpn",
"wireguard",
"tunnel",
"netwerk",
"op afstand"
],
"icon": {
"provider": "selfhst",
"id": "wireguard"
},
"version": {
"name": "latest"
},
"categories": [
"netwerk",
"beveiliging"
],
"architectures": [
"amd64",
"arm64"
],
"architecture_note": "De kernelmodule komt van de host. Op een Raspberry Pi met een recente 64-bits kernel zit WireGuard er standaard in; op oudere kernels moet je hem zelf installeren."
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "wireguard",
"required": true
},
{
"name": "port",
"type": "int",
"title": "WireGuard-poort (UDP)",
"default": 51820,
"required": true,
"description": "Alleen nodig als deze machine de server is. Als client verbind je naar buiten en hoeft er niets open."
},
{
"name": "appdata_dir",
"type": "str",
"title": "Appdata-map",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Hierin komt de map wg_confs; zet daar je eigen wg0.conf neer om met een bestaand netwerk te verbinden."
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
},
{
"title": "Gebruiker",
"items": [
{
"name": "puid",
"type": "int",
"title": "PUID",
"default": 1000,
"required": true,
"advanced": true
},
{
"name": "pgid",
"type": "int",
"title": "PGID",
"default": 1000,
"required": true,
"advanced": true
}
]
},
{
"title": "Als server",
"items": [
{
"name": "server_url",
"type": "str",
"title": "Publiek adres",
"default": "auto",
"required": true,
"description": "Het adres waarop clients deze server bereiken. 'auto' laat de container zijn eigen publieke IP opzoeken.",
"advanced": true
},
{
"name": "peers",
"type": "str",
"title": "Clientprofielen",
"default": "1",
"required": true,
"description": "Een aantal (bijvoorbeeld 3) of namen gescheiden door kommas (laptop,telefoon). De profielen en QR-codes komen in de appdata-map.",
"advanced": true
},
{
"name": "peer_dns",
"type": "str",
"title": "DNS voor clients",
"default": "auto",
"required": true,
"description": "Wijs dit naar je eigen DNS-server als je thuisnamen wilt kunnen opzoeken door de tunnel.",
"advanced": true
},
{
"name": "internal_subnet",
"type": "str",
"title": "Subnet van de tunnel",
"default": "10.13.13.0",
"required": true,
"description": "Mag niet botsen met een netwerk dat je al gebruikt.",
"advanced": true
},
{
"name": "allowed_ips",
"type": "str",
"title": "Netwerken door de tunnel",
"default": "0.0.0.0/0",
"required": true,
"description": "Wat clients via deze server bereiken. Alleen je thuisnetwerk? Zet er dan bijvoorbeeld 10.0.20.0/24 neer in plaats van al het verkeer.",
"advanced": true
}
]
}
]
}

View file

@ -157,8 +157,8 @@ GEDEELDE_POORTEN = {
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
53: {"adguard-home", "pihole"}, # dns-blokkers
# Twee manieren om een WireGuard-tunnel te draaien; je gebruikt er één.
51820: {"pangolin", "wg-easy"},
# Drie manieren om een WireGuard-eindpunt te draaien; je gebruikt er één.
51820: {"pangolin", "wg-easy", "wireguard"},
# Hieronder: botsingen die er al waren vóór deze testsuite. De
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.

View file

@ -0,0 +1,115 @@
"""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"]